feat(systemviews): add favorites CRUD controls
- commit
- 40aec41a2a689bf5d7d32d89112b776a70b98107
- author
- brice · · 2026-04-24 02:33 UTC
- parent
- db23629
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
16 files · +1228 -52
M assets/systemviews/systemviews.css +89 -0
| @@ -393,3 +393,92 @@ body { | |||
| 393 | 393 | background: var(--sv-surface-variant, #1d2129); | |
| 394 | 394 | font-family: ui-monospace, SFMono-Regular, Menlo, monospace; | |
| 395 | 395 | } | |
| 396 | + | ||
| 397 | + | .sv-card-form, | |
| 398 | + | .sv-inline-form { | |
| 399 | + | display: flex; | |
| 400 | + | flex-wrap: wrap; | |
| 401 | + | gap: 0.65rem; | |
| 402 | + | align-items: end; | |
| 403 | + | margin-top: 0.75rem; | |
| 404 | + | } | |
| 405 | + | ||
| 406 | + | .sv-card-form { | |
| 407 | + | padding: 0.75rem; | |
| 408 | + | border: 1px solid var(--sv-border, #2a313d); | |
| 409 | + | border-radius: 0.75rem; | |
| 410 | + | background: var(--sv-surface-variant, #1d2129); | |
| 411 | + | } | |
| 412 | + | ||
| 413 | + | .sv-card-form label, | |
| 414 | + | .sv-inline-form label { | |
| 415 | + | display: grid; | |
| 416 | + | gap: 0.25rem; | |
| 417 | + | min-width: min(14rem, 100%); | |
| 418 | + | color: var(--sv-muted, #9aa3b2); | |
| 419 | + | font-size: 12px; | |
| 420 | + | font-weight: 600; | |
| 421 | + | } | |
| 422 | + | ||
| 423 | + | .sv-card-form input, | |
| 424 | + | .sv-card-form select, | |
| 425 | + | .sv-inline-form input, | |
| 426 | + | .sv-inline-form select { | |
| 427 | + | border: 1px solid var(--sv-border, #2a313d); | |
| 428 | + | border-radius: 0.55rem; | |
| 429 | + | padding: 0.5rem 0.6rem; | |
| 430 | + | background: var(--sv-surface-variant, #1d2129); | |
| 431 | + | color: var(--sv-fg, #e8ebf0); | |
| 432 | + | font: inherit; | |
| 433 | + | } | |
| 434 | + | ||
| 435 | + | .sv-filter-panel { | |
| 436 | + | display: grid; | |
| 437 | + | gap: 0.6rem; | |
| 438 | + | margin-top: 0.75rem; | |
| 439 | + | } | |
| 440 | + | ||
| 441 | + | .sv-button-active { | |
| 442 | + | border-color: var(--sv-accent, #8ab4ff); | |
| 443 | + | box-shadow: 0 0 0 1px var(--sv-accent, #8ab4ff) inset; | |
| 444 | + | } | |
| 445 | + | ||
| 446 | + | .sv-favorite-item { | |
| 447 | + | display: grid; | |
| 448 | + | gap: 0.75rem; | |
| 449 | + | } | |
| 450 | + | ||
| 451 | + | .sv-favorite-header { | |
| 452 | + | display: flex; | |
| 453 | + | justify-content: space-between; | |
| 454 | + | gap: 1rem; | |
| 455 | + | align-items: start; | |
| 456 | + | } | |
| 457 | + | ||
| 458 | + | .sv-tag-controls { | |
| 459 | + | display: flex; | |
| 460 | + | flex-wrap: wrap; | |
| 461 | + | gap: 0.45rem; | |
| 462 | + | align-items: center; | |
| 463 | + | } | |
| 464 | + | ||
| 465 | + | .sv-tag-dot { | |
| 466 | + | display: inline-block; | |
| 467 | + | width: 0.65rem; | |
| 468 | + | height: 0.65rem; | |
| 469 | + | border-radius: 999px; | |
| 470 | + | margin-right: 0.35rem; | |
| 471 | + | border: 1px solid color-mix(in srgb, var(--sv-border, #2a313d) 65%, transparent); | |
| 472 | + | } | |
| 473 | + | ||
| 474 | + | .sv-favorite-item.sv-focused { | |
| 475 | + | outline: 2px solid var(--sv-accent, #8ab4ff); | |
| 476 | + | outline-offset: 0.35rem; | |
| 477 | + | border-radius: 0.5rem; | |
| 478 | + | } | |
| 479 | + | ||
| 480 | + | @media (max-width: 720px) { | |
| 481 | + | .sv-favorite-header { | |
| 482 | + | display: grid; | |
| 483 | + | } | |
| 484 | + | } | |
| @@ -393,3 +393,92 @@ body { | |||||
| 393 | background: var(--sv-surface-variant, #1d2129); | 393 | background: var(--sv-surface-variant, #1d2129); | ||
| 394 | font-family: ui-monospace, SFMono-Regular, Menlo, monospace; | 394 | font-family: ui-monospace, SFMono-Regular, Menlo, monospace; | ||
| 395 | } | 395 | } | ||
| 396 | + | ||||
| 397 | + | .sv-card-form, | |||
| 398 | + | .sv-inline-form { | |||
| 399 | + | display: flex; | |||
| 400 | + | flex-wrap: wrap; | |||
| 401 | + | gap: 0.65rem; | |||
| 402 | + | align-items: end; | |||
| 403 | + | margin-top: 0.75rem; | |||
| 404 | + | } | |||
| 405 | + | ||||
| 406 | + | .sv-card-form { | |||
| 407 | + | padding: 0.75rem; | |||
| 408 | + | border: 1px solid var(--sv-border, #2a313d); | |||
| 409 | + | border-radius: 0.75rem; | |||
| 410 | + | background: var(--sv-surface-variant, #1d2129); | |||
| 411 | + | } | |||
| 412 | + | ||||
| 413 | + | .sv-card-form label, | |||
| 414 | + | .sv-inline-form label { | |||
| 415 | + | display: grid; | |||
| 416 | + | gap: 0.25rem; | |||
| 417 | + | min-width: min(14rem, 100%); | |||
| 418 | + | color: var(--sv-muted, #9aa3b2); | |||
| 419 | + | font-size: 12px; | |||
| 420 | + | font-weight: 600; | |||
| 421 | + | } | |||
| 422 | + | ||||
| 423 | + | .sv-card-form input, | |||
| 424 | + | .sv-card-form select, | |||
| 425 | + | .sv-inline-form input, | |||
| 426 | + | .sv-inline-form select { | |||
| 427 | + | border: 1px solid var(--sv-border, #2a313d); | |||
| 428 | + | border-radius: 0.55rem; | |||
| 429 | + | padding: 0.5rem 0.6rem; | |||
| 430 | + | background: var(--sv-surface-variant, #1d2129); | |||
| 431 | + | color: var(--sv-fg, #e8ebf0); | |||
| 432 | + | font: inherit; | |||
| 433 | + | } | |||
| 434 | + | ||||
| 435 | + | .sv-filter-panel { | |||
| 436 | + | display: grid; | |||
| 437 | + | gap: 0.6rem; | |||
| 438 | + | margin-top: 0.75rem; | |||
| 439 | + | } | |||
| 440 | + | ||||
| 441 | + | .sv-button-active { | |||
| 442 | + | border-color: var(--sv-accent, #8ab4ff); | |||
| 443 | + | box-shadow: 0 0 0 1px var(--sv-accent, #8ab4ff) inset; | |||
| 444 | + | } | |||
| 445 | + | ||||
| 446 | + | .sv-favorite-item { | |||
| 447 | + | display: grid; | |||
| 448 | + | gap: 0.75rem; | |||
| 449 | + | } | |||
| 450 | + | ||||
| 451 | + | .sv-favorite-header { | |||
| 452 | + | display: flex; | |||
| 453 | + | justify-content: space-between; | |||
| 454 | + | gap: 1rem; | |||
| 455 | + | align-items: start; | |||
| 456 | + | } | |||
| 457 | + | ||||
| 458 | + | .sv-tag-controls { | |||
| 459 | + | display: flex; | |||
| 460 | + | flex-wrap: wrap; | |||
| 461 | + | gap: 0.45rem; | |||
| 462 | + | align-items: center; | |||
| 463 | + | } | |||
| 464 | + | ||||
| 465 | + | .sv-tag-dot { | |||
| 466 | + | display: inline-block; | |||
| 467 | + | width: 0.65rem; | |||
| 468 | + | height: 0.65rem; | |||
| 469 | + | border-radius: 999px; | |||
| 470 | + | margin-right: 0.35rem; | |||
| 471 | + | border: 1px solid color-mix(in srgb, var(--sv-border, #2a313d) 65%, transparent); | |||
| 472 | + | } | |||
| 473 | + | ||||
| 474 | + | .sv-favorite-item.sv-focused { | |||
| 475 | + | outline: 2px solid var(--sv-accent, #8ab4ff); | |||
| 476 | + | outline-offset: 0.35rem; | |||
| 477 | + | border-radius: 0.5rem; | |||
| 478 | + | } | |||
| 479 | + | ||||
| 480 | + | @media (max-width: 720px) { | |||
| 481 | + | .sv-favorite-header { | |||
| 482 | + | display: grid; | |||
| 483 | + | } | |||
| 484 | + | } | |||
M internal/application/port/homepage.go +21 -0
| @@ -31,10 +31,31 @@ type HomepageHistory interface { | |||
| 31 | 31 | DeleteByDomain(ctx context.Context, domain string) error | |
| 32 | 32 | } | |
| 33 | 33 | ||
| 34 | + | // FavoriteCreateInput contains fields needed to create a favorite from a UI port. | |
| 35 | + | type FavoriteCreateInput struct { | |
| 36 | + | URL string `json:"url"` | |
| 37 | + | Title string `json:"title"` | |
| 38 | + | FaviconURL string `json:"favicon_url"` | |
| 39 | + | FolderID *entity.FolderID `json:"folder_id"` | |
| 40 | + | Tags []entity.TagID `json:"tags"` | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | // FavoriteUpdateInput contains editable favorite metadata. | |
| 44 | + | type FavoriteUpdateInput struct { | |
| 45 | + | ID entity.FavoriteID `json:"id"` | |
| 46 | + | Title string `json:"title"` | |
| 47 | + | FaviconURL string `json:"favicon_url"` | |
| 48 | + | FolderID *entity.FolderID `json:"folder_id"` | |
| 49 | + | ShortcutKey *int `json:"shortcut_key"` | |
| 50 | + | } | |
| 51 | + | ||
| 34 | 52 | // HomepageFavorites provides favorites/folders/tags operations | |
| 35 | 53 | // needed by the WebUI homepage handlers. | |
| 36 | 54 | type HomepageFavorites interface { | |
| 37 | 55 | GetAll(ctx context.Context) ([]*entity.Favorite, error) | |
| 56 | + | AddFavorite(ctx context.Context, input FavoriteCreateInput) (*entity.Favorite, error) | |
| 57 | + | UpdateFavorite(ctx context.Context, input FavoriteUpdateInput) (*entity.Favorite, error) | |
| 58 | + | DeleteFavorite(ctx context.Context, id entity.FavoriteID) error | |
| 38 | 59 | SetShortcut(ctx context.Context, id entity.FavoriteID, key *int) error | |
| 39 | 60 | GetByShortcut(ctx context.Context, key int) (*entity.Favorite, error) | |
| 40 | 61 | Move(ctx context.Context, id entity.FavoriteID, folderID *entity.FolderID) error | |
| @@ -31,10 +31,31 @@ type HomepageHistory interface { | |||||
| 31 | DeleteByDomain(ctx context.Context, domain string) error | 31 | DeleteByDomain(ctx context.Context, domain string) error | ||
| 32 | } | 32 | } | ||
| 33 | 33 | ||||
| 34 | + | // FavoriteCreateInput contains fields needed to create a favorite from a UI port. | |||
| 35 | + | type FavoriteCreateInput struct { | |||
| 36 | + | URL string `json:"url"` | |||
| 37 | + | Title string `json:"title"` | |||
| 38 | + | FaviconURL string `json:"favicon_url"` | |||
| 39 | + | FolderID *entity.FolderID `json:"folder_id"` | |||
| 40 | + | Tags []entity.TagID `json:"tags"` | |||
| 41 | + | } | |||
| 42 | + | ||||
| 43 | + | // FavoriteUpdateInput contains editable favorite metadata. | |||
| 44 | + | type FavoriteUpdateInput struct { | |||
| 45 | + | ID entity.FavoriteID `json:"id"` | |||
| 46 | + | Title string `json:"title"` | |||
| 47 | + | FaviconURL string `json:"favicon_url"` | |||
| 48 | + | FolderID *entity.FolderID `json:"folder_id"` | |||
| 49 | + | ShortcutKey *int `json:"shortcut_key"` | |||
| 50 | + | } | |||
| 51 | + | ||||
| 34 | // HomepageFavorites provides favorites/folders/tags operations | 52 | // HomepageFavorites provides favorites/folders/tags operations | ||
| 35 | // needed by the WebUI homepage handlers. | 53 | // needed by the WebUI homepage handlers. | ||
| 36 | type HomepageFavorites interface { | 54 | type HomepageFavorites interface { | ||
| 37 | GetAll(ctx context.Context) ([]*entity.Favorite, error) | 55 | GetAll(ctx context.Context) ([]*entity.Favorite, error) | ||
| 56 | + | AddFavorite(ctx context.Context, input FavoriteCreateInput) (*entity.Favorite, error) | |||
| 57 | + | UpdateFavorite(ctx context.Context, input FavoriteUpdateInput) (*entity.Favorite, error) | |||
| 58 | + | DeleteFavorite(ctx context.Context, id entity.FavoriteID) error | |||
| 38 | SetShortcut(ctx context.Context, id entity.FavoriteID, key *int) error | 59 | SetShortcut(ctx context.Context, id entity.FavoriteID, key *int) error | ||
| 39 | GetByShortcut(ctx context.Context, key int) (*entity.Favorite, error) | 60 | GetByShortcut(ctx context.Context, key int) (*entity.Favorite, error) | ||
| 40 | Move(ctx context.Context, id entity.FavoriteID, folderID *entity.FolderID) error | 61 | Move(ctx context.Context, id entity.FavoriteID, folderID *entity.FolderID) error | ||
M internal/application/port/systemviews.go +3 -0
| @@ -20,6 +20,9 @@ type SystemviewHistoryService interface { | |||
| 20 | 20 | // SystemviewFavoritesService exposes favorite, folder, and tag operations. | |
| 21 | 21 | type SystemviewFavoritesService interface { | |
| 22 | 22 | List(ctx context.Context) ([]*entity.Favorite, error) | |
| 23 | + | CreateFavorite(ctx context.Context, input FavoriteCreateInput) (*entity.Favorite, error) | |
| 24 | + | UpdateFavorite(ctx context.Context, input FavoriteUpdateInput) (*entity.Favorite, error) | |
| 25 | + | DeleteFavorite(ctx context.Context, id int64) error | |
| 23 | 26 | ListFolders(ctx context.Context) ([]*entity.Folder, error) | |
| 24 | 27 | ListTags(ctx context.Context) ([]*entity.Tag, error) | |
| 25 | 28 | SetShortcut(ctx context.Context, favoriteID int64, shortcutKey *int) error | |
| @@ -20,6 +20,9 @@ type SystemviewHistoryService interface { | |||||
| 20 | // SystemviewFavoritesService exposes favorite, folder, and tag operations. | 20 | // SystemviewFavoritesService exposes favorite, folder, and tag operations. | ||
| 21 | type SystemviewFavoritesService interface { | 21 | type SystemviewFavoritesService interface { | ||
| 22 | List(ctx context.Context) ([]*entity.Favorite, error) | 22 | List(ctx context.Context) ([]*entity.Favorite, error) | ||
| 23 | + | CreateFavorite(ctx context.Context, input FavoriteCreateInput) (*entity.Favorite, error) | |||
| 24 | + | UpdateFavorite(ctx context.Context, input FavoriteUpdateInput) (*entity.Favorite, error) | |||
| 25 | + | DeleteFavorite(ctx context.Context, id int64) error | |||
| 23 | ListFolders(ctx context.Context) ([]*entity.Folder, error) | 26 | ListFolders(ctx context.Context) ([]*entity.Folder, error) | ||
| 24 | ListTags(ctx context.Context) ([]*entity.Tag, error) | 27 | ListTags(ctx context.Context) ([]*entity.Tag, error) | ||
| 25 | SetShortcut(ctx context.Context, favoriteID int64, shortcutKey *int) error | 28 | SetShortcut(ctx context.Context, favoriteID int64, shortcutKey *int) error | ||
M internal/application/usecase/manage_favorites.go +57 -0
| @@ -93,6 +93,59 @@ func (uc *ManageFavoritesUseCase) Add(ctx context.Context, input AddFavoriteInpu | |||
| 93 | 93 | return fav, nil | |
| 94 | 94 | } | |
| 95 | 95 | ||
| 96 | + | // AddFavorite creates a favorite from the UI-facing application port. | |
| 97 | + | func (uc *ManageFavoritesUseCase) AddFavorite(ctx context.Context, input port.FavoriteCreateInput) (*entity.Favorite, error) { | |
| 98 | + | if strings.TrimSpace(input.URL) == "" { | |
| 99 | + | return nil, fmt.Errorf("favorite URL is required") | |
| 100 | + | } | |
| 101 | + | tags := make([]entity.TagID, 0, len(input.Tags)) | |
| 102 | + | for _, tagID := range input.Tags { | |
| 103 | + | if tagID > 0 { | |
| 104 | + | tags = append(tags, tagID) | |
| 105 | + | } | |
| 106 | + | } | |
| 107 | + | return uc.Add(ctx, AddFavoriteInput{ | |
| 108 | + | URL: strings.TrimSpace(input.URL), | |
| 109 | + | Title: strings.TrimSpace(input.Title), | |
| 110 | + | FaviconURL: strings.TrimSpace(input.FaviconURL), | |
| 111 | + | FolderID: input.FolderID, | |
| 112 | + | Tags: tags, | |
| 113 | + | }) | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | // UpdateFavorite updates editable favorite metadata from the UI-facing application port. | |
| 117 | + | func (uc *ManageFavoritesUseCase) UpdateFavorite(ctx context.Context, input port.FavoriteUpdateInput) (*entity.Favorite, error) { | |
| 118 | + | if input.ID <= 0 { | |
| 119 | + | return nil, fmt.Errorf("favorite ID is required") | |
| 120 | + | } | |
| 121 | + | if input.ShortcutKey != nil && (*input.ShortcutKey < 1 || *input.ShortcutKey > 9) { | |
| 122 | + | return nil, fmt.Errorf("shortcut key must be 1-9, got %d", *input.ShortcutKey) | |
| 123 | + | } | |
| 124 | + | ||
| 125 | + | fav, err := uc.favoriteRepo.FindByID(ctx, input.ID) | |
| 126 | + | if err != nil { | |
| 127 | + | return nil, fmt.Errorf("failed to find favorite: %w", err) | |
| 128 | + | } | |
| 129 | + | if fav == nil { | |
| 130 | + | return nil, fmt.Errorf("favorite %d not found", input.ID) | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | fav.Title = strings.TrimSpace(input.Title) | |
| 134 | + | fav.FaviconURL = strings.TrimSpace(input.FaviconURL) | |
| 135 | + | fav.FolderID = input.FolderID | |
| 136 | + | fav.ShortcutKey = input.ShortcutKey | |
| 137 | + | ||
| 138 | + | if err := uc.Update(ctx, fav); err != nil { | |
| 139 | + | return nil, err | |
| 140 | + | } | |
| 141 | + | return fav, nil | |
| 142 | + | } | |
| 143 | + | ||
| 144 | + | // DeleteFavorite deletes a favorite from the UI-facing application port. | |
| 145 | + | func (uc *ManageFavoritesUseCase) DeleteFavorite(ctx context.Context, id entity.FavoriteID) error { | |
| 146 | + | return uc.Remove(ctx, id) | |
| 147 | + | } | |
| 148 | + | ||
| 96 | 149 | // Remove deletes a favorite by ID. | |
| 97 | 150 | func (uc *ManageFavoritesUseCase) Remove(ctx context.Context, id entity.FavoriteID) error { | |
| 98 | 151 | log := logging.FromContext(ctx) | |
| @@ -574,6 +627,7 @@ func (uc *ManageFavoritesUseCase) DeleteTag(ctx context.Context, id entity.TagID | |||
| 574 | 627 | } | |
| 575 | 628 | ||
| 576 | 629 | log.Info().Int64("id", int64(id)).Msg("tag deleted") | |
| 630 | + | uc.invalidateCache() | |
| 577 | 631 | return nil | |
| 578 | 632 | } | |
| 579 | 633 | ||
| @@ -596,6 +650,7 @@ func (uc *ManageFavoritesUseCase) TagFavorite(ctx context.Context, favID entity. | |||
| 596 | 650 | if err := uc.tagRepo.AssignToFavorite(ctx, tagID, favID); err != nil { | |
| 597 | 651 | return fmt.Errorf("failed to tag favorite: %w", err) | |
| 598 | 652 | } | |
| 653 | + | uc.invalidateCache() | |
| 599 | 654 | ||
| 600 | 655 | return nil | |
| 601 | 656 | } | |
| @@ -611,6 +666,7 @@ func (uc *ManageFavoritesUseCase) UntagFavorite(ctx context.Context, favID entit | |||
| 611 | 666 | if err := uc.tagRepo.RemoveFromFavorite(ctx, tagID, favID); err != nil { | |
| 612 | 667 | return fmt.Errorf("failed to untag favorite: %w", err) | |
| 613 | 668 | } | |
| 669 | + | uc.invalidateCache() | |
| 614 | 670 | ||
| 615 | 671 | return nil | |
| 616 | 672 | } | |
| @@ -674,5 +730,6 @@ func (uc *ManageFavoritesUseCase) UpdateTag(ctx context.Context, id entity.TagID | |||
| 674 | 730 | } | |
| 675 | 731 | ||
| 676 | 732 | log.Info().Int64("id", int64(id)).Msg("tag updated") | |
| 733 | + | uc.invalidateCache() | |
| 677 | 734 | return nil | |
| 678 | 735 | } | |
| @@ -93,6 +93,59 @@ func (uc *ManageFavoritesUseCase) Add(ctx context.Context, input AddFavoriteInpu | |||||
| 93 | return fav, nil | 93 | return fav, nil | ||
| 94 | } | 94 | } | ||
| 95 | 95 | ||||
| 96 | + | // AddFavorite creates a favorite from the UI-facing application port. | |||
| 97 | + | func (uc *ManageFavoritesUseCase) AddFavorite(ctx context.Context, input port.FavoriteCreateInput) (*entity.Favorite, error) { | |||
| 98 | + | if strings.TrimSpace(input.URL) == "" { | |||
| 99 | + | return nil, fmt.Errorf("favorite URL is required") | |||
| 100 | + | } | |||
| 101 | + | tags := make([]entity.TagID, 0, len(input.Tags)) | |||
| 102 | + | for _, tagID := range input.Tags { | |||
| 103 | + | if tagID > 0 { | |||
| 104 | + | tags = append(tags, tagID) | |||
| 105 | + | } | |||
| 106 | + | } | |||
| 107 | + | return uc.Add(ctx, AddFavoriteInput{ | |||
| 108 | + | URL: strings.TrimSpace(input.URL), | |||
| 109 | + | Title: strings.TrimSpace(input.Title), | |||
| 110 | + | FaviconURL: strings.TrimSpace(input.FaviconURL), | |||
| 111 | + | FolderID: input.FolderID, | |||
| 112 | + | Tags: tags, | |||
| 113 | + | }) | |||
| 114 | + | } | |||
| 115 | + | ||||
| 116 | + | // UpdateFavorite updates editable favorite metadata from the UI-facing application port. | |||
| 117 | + | func (uc *ManageFavoritesUseCase) UpdateFavorite(ctx context.Context, input port.FavoriteUpdateInput) (*entity.Favorite, error) { | |||
| 118 | + | if input.ID <= 0 { | |||
| 119 | + | return nil, fmt.Errorf("favorite ID is required") | |||
| 120 | + | } | |||
| 121 | + | if input.ShortcutKey != nil && (*input.ShortcutKey < 1 || *input.ShortcutKey > 9) { | |||
| 122 | + | return nil, fmt.Errorf("shortcut key must be 1-9, got %d", *input.ShortcutKey) | |||
| 123 | + | } | |||
| 124 | + | ||||
| 125 | + | fav, err := uc.favoriteRepo.FindByID(ctx, input.ID) | |||
| 126 | + | if err != nil { | |||
| 127 | + | return nil, fmt.Errorf("failed to find favorite: %w", err) | |||
| 128 | + | } | |||
| 129 | + | if fav == nil { | |||
| 130 | + | return nil, fmt.Errorf("favorite %d not found", input.ID) | |||
| 131 | + | } | |||
| 132 | + | ||||
| 133 | + | fav.Title = strings.TrimSpace(input.Title) | |||
| 134 | + | fav.FaviconURL = strings.TrimSpace(input.FaviconURL) | |||
| 135 | + | fav.FolderID = input.FolderID | |||
| 136 | + | fav.ShortcutKey = input.ShortcutKey | |||
| 137 | + | ||||
| 138 | + | if err := uc.Update(ctx, fav); err != nil { | |||
| 139 | + | return nil, err | |||
| 140 | + | } | |||
| 141 | + | return fav, nil | |||
| 142 | + | } | |||
| 143 | + | ||||
| 144 | + | // DeleteFavorite deletes a favorite from the UI-facing application port. | |||
| 145 | + | func (uc *ManageFavoritesUseCase) DeleteFavorite(ctx context.Context, id entity.FavoriteID) error { | |||
| 146 | + | return uc.Remove(ctx, id) | |||
| 147 | + | } | |||
| 148 | + | ||||
| 96 | // Remove deletes a favorite by ID. | 149 | // Remove deletes a favorite by ID. | ||
| 97 | func (uc *ManageFavoritesUseCase) Remove(ctx context.Context, id entity.FavoriteID) error { | 150 | func (uc *ManageFavoritesUseCase) Remove(ctx context.Context, id entity.FavoriteID) error { | ||
| 98 | log := logging.FromContext(ctx) | 151 | log := logging.FromContext(ctx) | ||
| @@ -574,6 +627,7 @@ func (uc *ManageFavoritesUseCase) DeleteTag(ctx context.Context, id entity.TagID | |||||
| 574 | } | 627 | } | ||
| 575 | 628 | ||||
| 576 | log.Info().Int64("id", int64(id)).Msg("tag deleted") | 629 | log.Info().Int64("id", int64(id)).Msg("tag deleted") | ||
| 630 | + | uc.invalidateCache() | |||
| 577 | return nil | 631 | return nil | ||
| 578 | } | 632 | } | ||
| 579 | 633 | ||||
| @@ -596,6 +650,7 @@ func (uc *ManageFavoritesUseCase) TagFavorite(ctx context.Context, favID entity. | |||||
| 596 | if err := uc.tagRepo.AssignToFavorite(ctx, tagID, favID); err != nil { | 650 | if err := uc.tagRepo.AssignToFavorite(ctx, tagID, favID); err != nil { | ||
| 597 | return fmt.Errorf("failed to tag favorite: %w", err) | 651 | return fmt.Errorf("failed to tag favorite: %w", err) | ||
| 598 | } | 652 | } | ||
| 653 | + | uc.invalidateCache() | |||
| 599 | 654 | ||||
| 600 | return nil | 655 | return nil | ||
| 601 | } | 656 | } | ||
| @@ -611,6 +666,7 @@ func (uc *ManageFavoritesUseCase) UntagFavorite(ctx context.Context, favID entit | |||||
| 611 | if err := uc.tagRepo.RemoveFromFavorite(ctx, tagID, favID); err != nil { | 666 | if err := uc.tagRepo.RemoveFromFavorite(ctx, tagID, favID); err != nil { | ||
| 612 | return fmt.Errorf("failed to untag favorite: %w", err) | 667 | return fmt.Errorf("failed to untag favorite: %w", err) | ||
| 613 | } | 668 | } | ||
| 669 | + | uc.invalidateCache() | |||
| 614 | 670 | ||||
| 615 | return nil | 671 | return nil | ||
| 616 | } | 672 | } | ||
| @@ -674,5 +730,6 @@ func (uc *ManageFavoritesUseCase) UpdateTag(ctx context.Context, id entity.TagID | |||||
| 674 | } | 730 | } | ||
| 675 | 731 | ||||
| 676 | log.Info().Int64("id", int64(id)).Msg("tag updated") | 732 | log.Info().Int64("id", int64(id)).Msg("tag updated") | ||
| 733 | + | uc.invalidateCache() | |||
| 677 | return nil | 734 | return nil | ||
| 678 | } | 735 | } | ||
M internal/infrastructure/handlers/homepage/favorites.go +125 -0
| @@ -39,6 +39,113 @@ func (h *FavoritesHandlers) HandleList() port.WebUIMessageHandler { | |||
| 39 | 39 | }) | |
| 40 | 40 | } | |
| 41 | 41 | ||
| 42 | + | type favoriteCreateRequest struct { | |
| 43 | + | RequestID string `json:"requestId"` | |
| 44 | + | URL string `json:"url"` | |
| 45 | + | Title string `json:"title"` | |
| 46 | + | FaviconURL string `json:"favicon_url"` | |
| 47 | + | FolderID *int64 `json:"folder_id"` | |
| 48 | + | Tags []int64 `json:"tags"` | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | // HandleCreate handles favorite_create messages. | |
| 52 | + | func (h *FavoritesHandlers) HandleCreate() port.WebUIMessageHandler { | |
| 53 | + | return port.WebUIMessageHandlerFunc(func(ctx context.Context, _ port.WebViewID, payload json.RawMessage) (any, error) { | |
| 54 | + | log := logging.FromContext(ctx) | |
| 55 | + | ||
| 56 | + | var req favoriteCreateRequest | |
| 57 | + | if err := json.Unmarshal(payload, &req); err != nil { | |
| 58 | + | return NewErrorResponse("", err), nil | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | log.Debug(). | |
| 62 | + | Str("request_id", req.RequestID). | |
| 63 | + | Str("url", req.URL). | |
| 64 | + | Msg("handling favorite_create") | |
| 65 | + | ||
| 66 | + | input := port.FavoriteCreateInput{ | |
| 67 | + | URL: req.URL, | |
| 68 | + | Title: req.Title, | |
| 69 | + | FaviconURL: req.FaviconURL, | |
| 70 | + | FolderID: folderIDFromInt64(req.FolderID), | |
| 71 | + | Tags: tagIDsFromInt64s(req.Tags), | |
| 72 | + | } | |
| 73 | + | favorite, err := h.favoritesUC.AddFavorite(ctx, input) | |
| 74 | + | if err != nil { | |
| 75 | + | return NewErrorResponse(req.RequestID, err), nil | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | return NewSuccessResponse(req.RequestID, favorite), nil | |
| 79 | + | }) | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | type favoriteUpdateRequest struct { | |
| 83 | + | RequestID string `json:"requestId"` | |
| 84 | + | ID int64 `json:"id"` | |
| 85 | + | Title string `json:"title"` | |
| 86 | + | FaviconURL string `json:"favicon_url"` | |
| 87 | + | FolderID *int64 `json:"folder_id"` | |
| 88 | + | ShortcutKey *int `json:"shortcut_key"` | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | // HandleUpdate handles favorite_update messages. | |
| 92 | + | func (h *FavoritesHandlers) HandleUpdate() port.WebUIMessageHandler { | |
| 93 | + | return port.WebUIMessageHandlerFunc(func(ctx context.Context, _ port.WebViewID, payload json.RawMessage) (any, error) { | |
| 94 | + | log := logging.FromContext(ctx) | |
| 95 | + | ||
| 96 | + | var req favoriteUpdateRequest | |
| 97 | + | if err := json.Unmarshal(payload, &req); err != nil { | |
| 98 | + | return NewErrorResponse("", err), nil | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | log.Debug(). | |
| 102 | + | Str("request_id", req.RequestID). | |
| 103 | + | Int64("favorite_id", req.ID). | |
| 104 | + | Msg("handling favorite_update") | |
| 105 | + | ||
| 106 | + | favorite, err := h.favoritesUC.UpdateFavorite(ctx, port.FavoriteUpdateInput{ | |
| 107 | + | ID: entity.FavoriteID(req.ID), | |
| 108 | + | Title: req.Title, | |
| 109 | + | FaviconURL: req.FaviconURL, | |
| 110 | + | FolderID: folderIDFromInt64(req.FolderID), | |
| 111 | + | ShortcutKey: req.ShortcutKey, | |
| 112 | + | }) | |
| 113 | + | if err != nil { | |
| 114 | + | return NewErrorResponse(req.RequestID, err), nil | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | return NewSuccessResponse(req.RequestID, favorite), nil | |
| 118 | + | }) | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | type favoriteDeleteRequest struct { | |
| 122 | + | RequestID string `json:"requestId"` | |
| 123 | + | ID int64 `json:"id"` | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | // HandleDelete handles favorite_delete messages. | |
| 127 | + | func (h *FavoritesHandlers) HandleDelete() port.WebUIMessageHandler { | |
| 128 | + | return port.WebUIMessageHandlerFunc(func(ctx context.Context, _ port.WebViewID, payload json.RawMessage) (any, error) { | |
| 129 | + | log := logging.FromContext(ctx) | |
| 130 | + | ||
| 131 | + | var req favoriteDeleteRequest | |
| 132 | + | if err := json.Unmarshal(payload, &req); err != nil { | |
| 133 | + | return NewErrorResponse("", err), nil | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | log.Debug(). | |
| 137 | + | Str("request_id", req.RequestID). | |
| 138 | + | Int64("favorite_id", req.ID). | |
| 139 | + | Msg("handling favorite_delete") | |
| 140 | + | ||
| 141 | + | if err := h.favoritesUC.DeleteFavorite(ctx, entity.FavoriteID(req.ID)); err != nil { | |
| 142 | + | return NewErrorResponse(req.RequestID, err), nil | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | return NewSuccessResponse(req.RequestID, nil), nil | |
| 146 | + | }) | |
| 147 | + | } | |
| 148 | + | ||
| 42 | 149 | // setShortcutRequest is the payload for favorite_set_shortcut messages. | |
| 43 | 150 | type setShortcutRequest struct { | |
| 44 | 151 | RequestID string `json:"requestId"` | |
| @@ -107,6 +214,24 @@ type setFolderRequest struct { | |||
| 107 | 214 | } | |
| 108 | 215 | ||
| 109 | 216 | // HandleSetFolder handles favorite_set_folder messages. | |
| 217 | + | func folderIDFromInt64(id *int64) *entity.FolderID { | |
| 218 | + | if id == nil { | |
| 219 | + | return nil | |
| 220 | + | } | |
| 221 | + | folderID := entity.FolderID(*id) | |
| 222 | + | return &folderID | |
| 223 | + | } | |
| 224 | + | ||
| 225 | + | func tagIDsFromInt64s(ids []int64) []entity.TagID { | |
| 226 | + | out := make([]entity.TagID, 0, len(ids)) | |
| 227 | + | for _, id := range ids { | |
| 228 | + | if id > 0 { | |
| 229 | + | out = append(out, entity.TagID(id)) | |
| 230 | + | } | |
| 231 | + | } | |
| 232 | + | return out | |
| 233 | + | } | |
| 234 | + | ||
| 110 | 235 | func (h *FavoritesHandlers) HandleSetFolder() port.WebUIMessageHandler { | |
| 111 | 236 | return port.WebUIMessageHandlerFunc(func(ctx context.Context, _ port.WebViewID, payload json.RawMessage) (any, error) { | |
| 112 | 237 | log := logging.FromContext(ctx) | |
| @@ -39,6 +39,113 @@ func (h *FavoritesHandlers) HandleList() port.WebUIMessageHandler { | |||||
| 39 | }) | 39 | }) | ||
| 40 | } | 40 | } | ||
| 41 | 41 | ||||
| 42 | + | type favoriteCreateRequest struct { | |||
| 43 | + | RequestID string `json:"requestId"` | |||
| 44 | + | URL string `json:"url"` | |||
| 45 | + | Title string `json:"title"` | |||
| 46 | + | FaviconURL string `json:"favicon_url"` | |||
| 47 | + | FolderID *int64 `json:"folder_id"` | |||
| 48 | + | Tags []int64 `json:"tags"` | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | // HandleCreate handles favorite_create messages. | |||
| 52 | + | func (h *FavoritesHandlers) HandleCreate() port.WebUIMessageHandler { | |||
| 53 | + | return port.WebUIMessageHandlerFunc(func(ctx context.Context, _ port.WebViewID, payload json.RawMessage) (any, error) { | |||
| 54 | + | log := logging.FromContext(ctx) | |||
| 55 | + | ||||
| 56 | + | var req favoriteCreateRequest | |||
| 57 | + | if err := json.Unmarshal(payload, &req); err != nil { | |||
| 58 | + | return NewErrorResponse("", err), nil | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | log.Debug(). | |||
| 62 | + | Str("request_id", req.RequestID). | |||
| 63 | + | Str("url", req.URL). | |||
| 64 | + | Msg("handling favorite_create") | |||
| 65 | + | ||||
| 66 | + | input := port.FavoriteCreateInput{ | |||
| 67 | + | URL: req.URL, | |||
| 68 | + | Title: req.Title, | |||
| 69 | + | FaviconURL: req.FaviconURL, | |||
| 70 | + | FolderID: folderIDFromInt64(req.FolderID), | |||
| 71 | + | Tags: tagIDsFromInt64s(req.Tags), | |||
| 72 | + | } | |||
| 73 | + | favorite, err := h.favoritesUC.AddFavorite(ctx, input) | |||
| 74 | + | if err != nil { | |||
| 75 | + | return NewErrorResponse(req.RequestID, err), nil | |||
| 76 | + | } | |||
| 77 | + | ||||
| 78 | + | return NewSuccessResponse(req.RequestID, favorite), nil | |||
| 79 | + | }) | |||
| 80 | + | } | |||
| 81 | + | ||||
| 82 | + | type favoriteUpdateRequest struct { | |||
| 83 | + | RequestID string `json:"requestId"` | |||
| 84 | + | ID int64 `json:"id"` | |||
| 85 | + | Title string `json:"title"` | |||
| 86 | + | FaviconURL string `json:"favicon_url"` | |||
| 87 | + | FolderID *int64 `json:"folder_id"` | |||
| 88 | + | ShortcutKey *int `json:"shortcut_key"` | |||
| 89 | + | } | |||
| 90 | + | ||||
| 91 | + | // HandleUpdate handles favorite_update messages. | |||
| 92 | + | func (h *FavoritesHandlers) HandleUpdate() port.WebUIMessageHandler { | |||
| 93 | + | return port.WebUIMessageHandlerFunc(func(ctx context.Context, _ port.WebViewID, payload json.RawMessage) (any, error) { | |||
| 94 | + | log := logging.FromContext(ctx) | |||
| 95 | + | ||||
| 96 | + | var req favoriteUpdateRequest | |||
| 97 | + | if err := json.Unmarshal(payload, &req); err != nil { | |||
| 98 | + | return NewErrorResponse("", err), nil | |||
| 99 | + | } | |||
| 100 | + | ||||
| 101 | + | log.Debug(). | |||
| 102 | + | Str("request_id", req.RequestID). | |||
| 103 | + | Int64("favorite_id", req.ID). | |||
| 104 | + | Msg("handling favorite_update") | |||
| 105 | + | ||||
| 106 | + | favorite, err := h.favoritesUC.UpdateFavorite(ctx, port.FavoriteUpdateInput{ | |||
| 107 | + | ID: entity.FavoriteID(req.ID), | |||
| 108 | + | Title: req.Title, | |||
| 109 | + | FaviconURL: req.FaviconURL, | |||
| 110 | + | FolderID: folderIDFromInt64(req.FolderID), | |||
| 111 | + | ShortcutKey: req.ShortcutKey, | |||
| 112 | + | }) | |||
| 113 | + | if err != nil { | |||
| 114 | + | return NewErrorResponse(req.RequestID, err), nil | |||
| 115 | + | } | |||
| 116 | + | ||||
| 117 | + | return NewSuccessResponse(req.RequestID, favorite), nil | |||
| 118 | + | }) | |||
| 119 | + | } | |||
| 120 | + | ||||
| 121 | + | type favoriteDeleteRequest struct { | |||
| 122 | + | RequestID string `json:"requestId"` | |||
| 123 | + | ID int64 `json:"id"` | |||
| 124 | + | } | |||
| 125 | + | ||||
| 126 | + | // HandleDelete handles favorite_delete messages. | |||
| 127 | + | func (h *FavoritesHandlers) HandleDelete() port.WebUIMessageHandler { | |||
| 128 | + | return port.WebUIMessageHandlerFunc(func(ctx context.Context, _ port.WebViewID, payload json.RawMessage) (any, error) { | |||
| 129 | + | log := logging.FromContext(ctx) | |||
| 130 | + | ||||
| 131 | + | var req favoriteDeleteRequest | |||
| 132 | + | if err := json.Unmarshal(payload, &req); err != nil { | |||
| 133 | + | return NewErrorResponse("", err), nil | |||
| 134 | + | } | |||
| 135 | + | ||||
| 136 | + | log.Debug(). | |||
| 137 | + | Str("request_id", req.RequestID). | |||
| 138 | + | Int64("favorite_id", req.ID). | |||
| 139 | + | Msg("handling favorite_delete") | |||
| 140 | + | ||||
| 141 | + | if err := h.favoritesUC.DeleteFavorite(ctx, entity.FavoriteID(req.ID)); err != nil { | |||
| 142 | + | return NewErrorResponse(req.RequestID, err), nil | |||
| 143 | + | } | |||
| 144 | + | ||||
| 145 | + | return NewSuccessResponse(req.RequestID, nil), nil | |||
| 146 | + | }) | |||
| 147 | + | } | |||
| 148 | + | ||||
| 42 | // setShortcutRequest is the payload for favorite_set_shortcut messages. | 149 | // setShortcutRequest is the payload for favorite_set_shortcut messages. | ||
| 43 | type setShortcutRequest struct { | 150 | type setShortcutRequest struct { | ||
| 44 | RequestID string `json:"requestId"` | 151 | RequestID string `json:"requestId"` | ||
| @@ -107,6 +214,24 @@ type setFolderRequest struct { | |||||
| 107 | } | 214 | } | ||
| 108 | 215 | ||||
| 109 | // HandleSetFolder handles favorite_set_folder messages. | 216 | // HandleSetFolder handles favorite_set_folder messages. | ||
| 217 | + | func folderIDFromInt64(id *int64) *entity.FolderID { | |||
| 218 | + | if id == nil { | |||
| 219 | + | return nil | |||
| 220 | + | } | |||
| 221 | + | folderID := entity.FolderID(*id) | |||
| 222 | + | return &folderID | |||
| 223 | + | } | |||
| 224 | + | ||||
| 225 | + | func tagIDsFromInt64s(ids []int64) []entity.TagID { | |||
| 226 | + | out := make([]entity.TagID, 0, len(ids)) | |||
| 227 | + | for _, id := range ids { | |||
| 228 | + | if id > 0 { | |||
| 229 | + | out = append(out, entity.TagID(id)) | |||
| 230 | + | } | |||
| 231 | + | } | |||
| 232 | + | return out | |||
| 233 | + | } | |||
| 234 | + | ||||
| 110 | func (h *FavoritesHandlers) HandleSetFolder() port.WebUIMessageHandler { | 235 | func (h *FavoritesHandlers) HandleSetFolder() port.WebUIMessageHandler { | ||
| 111 | return port.WebUIMessageHandlerFunc(func(ctx context.Context, _ port.WebViewID, payload json.RawMessage) (any, error) { | 236 | return port.WebUIMessageHandlerFunc(func(ctx context.Context, _ port.WebViewID, payload json.RawMessage) (any, error) { | ||
| 112 | log := logging.FromContext(ctx) | 237 | log := logging.FromContext(ctx) | ||
M internal/infrastructure/handlers/homepage/registry.go +3 -0
| @@ -40,6 +40,9 @@ func RegisterHandlers(ctx context.Context, router port.WebUIHandlerRouter, cfg C | |||
| 40 | 40 | // Favorites handlers | |
| 41 | 41 | favoritesHandlers := NewFavoritesHandlers(cfg.FavoritesUC) | |
| 42 | 42 | handlers["favorite_list"] = favoritesHandlers.HandleList() | |
| 43 | + | handlers["favorite_create"] = favoritesHandlers.HandleCreate() | |
| 44 | + | handlers["favorite_update"] = favoritesHandlers.HandleUpdate() | |
| 45 | + | handlers["favorite_delete"] = favoritesHandlers.HandleDelete() | |
| 43 | 46 | handlers["favorite_set_shortcut"] = favoritesHandlers.HandleSetShortcut() | |
| 44 | 47 | handlers["favorite_get_by_shortcut"] = favoritesHandlers.HandleGetByShortcut() | |
| 45 | 48 | handlers["favorite_set_folder"] = favoritesHandlers.HandleSetFolder() | |
| @@ -40,6 +40,9 @@ func RegisterHandlers(ctx context.Context, router port.WebUIHandlerRouter, cfg C | |||||
| 40 | // Favorites handlers | 40 | // Favorites handlers | ||
| 41 | favoritesHandlers := NewFavoritesHandlers(cfg.FavoritesUC) | 41 | favoritesHandlers := NewFavoritesHandlers(cfg.FavoritesUC) | ||
| 42 | handlers["favorite_list"] = favoritesHandlers.HandleList() | 42 | handlers["favorite_list"] = favoritesHandlers.HandleList() | ||
| 43 | + | handlers["favorite_create"] = favoritesHandlers.HandleCreate() | |||
| 44 | + | handlers["favorite_update"] = favoritesHandlers.HandleUpdate() | |||
| 45 | + | handlers["favorite_delete"] = favoritesHandlers.HandleDelete() | |||
| 43 | handlers["favorite_set_shortcut"] = favoritesHandlers.HandleSetShortcut() | 46 | handlers["favorite_set_shortcut"] = favoritesHandlers.HandleSetShortcut() | ||
| 44 | handlers["favorite_get_by_shortcut"] = favoritesHandlers.HandleGetByShortcut() | 47 | handlers["favorite_get_by_shortcut"] = favoritesHandlers.HandleGetByShortcut() | ||
| 45 | handlers["favorite_set_folder"] = favoritesHandlers.HandleSetFolder() | 48 | handlers["favorite_set_folder"] = favoritesHandlers.HandleSetFolder() | ||
M internal/infrastructure/persistence/sqlite/favorite_repo.go +43 -3
| @@ -24,13 +24,30 @@ func NewFavoriteRepository(db *sql.DB) repository.FavoriteRepository { | |||
| 24 | 24 | ||
| 25 | 25 | func (r *favoriteRepo) Save(ctx context.Context, fav *entity.Favorite) error { | |
| 26 | 26 | log := logging.FromContext(ctx) | |
| 27 | - | log.Debug().Str("url", fav.URL).Msg("saving favorite") | |
| 27 | + | log.Debug().Str("url", fav.URL).Int64("id", int64(fav.ID)).Msg("saving favorite") | |
| 28 | 28 | ||
| 29 | 29 | var folderID sql.NullInt64 | |
| 30 | 30 | if fav.FolderID != nil { | |
| 31 | 31 | folderID = sql.NullInt64{Int64: int64(*fav.FolderID), Valid: true} | |
| 32 | 32 | } | |
| 33 | 33 | ||
| 34 | + | if fav.ID > 0 { | |
| 35 | + | if err := r.queries.UpdateFavorite(ctx, sqlc.UpdateFavoriteParams{ | |
| 36 | + | Title: sql.NullString{String: fav.Title, Valid: fav.Title != ""}, | |
| 37 | + | FaviconUrl: sql.NullString{String: fav.FaviconURL, Valid: fav.FaviconURL != ""}, | |
| 38 | + | ID: int64(fav.ID), | |
| 39 | + | }); err != nil { | |
| 40 | + | return err | |
| 41 | + | } | |
| 42 | + | if err := r.queries.SetFavoriteFolder(ctx, sqlc.SetFavoriteFolderParams{FolderID: folderID, ID: int64(fav.ID)}); err != nil { | |
| 43 | + | return err | |
| 44 | + | } | |
| 45 | + | return r.queries.SetFavoriteShortcut(ctx, sqlc.SetFavoriteShortcutParams{ | |
| 46 | + | ShortcutKey: sql.NullInt64{Int64: int64Value(fav.ShortcutKey), Valid: fav.ShortcutKey != nil}, | |
| 47 | + | ID: int64(fav.ID), | |
| 48 | + | }) | |
| 49 | + | } | |
| 50 | + | ||
| 34 | 51 | row, err := r.queries.CreateFavorite(ctx, sqlc.CreateFavoriteParams{ | |
| 35 | 52 | Url: fav.URL, | |
| 36 | 53 | Title: sql.NullString{String: fav.Title, Valid: fav.Title != ""}, | |
| @@ -44,6 +61,13 @@ func (r *favoriteRepo) Save(ctx context.Context, fav *entity.Favorite) error { | |||
| 44 | 61 | return nil | |
| 45 | 62 | } | |
| 46 | 63 | ||
| 64 | + | func int64Value(value *int) int64 { | |
| 65 | + | if value == nil { | |
| 66 | + | return 0 | |
| 67 | + | } | |
| 68 | + | return int64(*value) | |
| 69 | + | } | |
| 70 | + | ||
| 47 | 71 | func (r *favoriteRepo) FindByID(ctx context.Context, id entity.FavoriteID) (*entity.Favorite, error) { | |
| 48 | 72 | row, err := r.queries.GetFavoriteByID(ctx, int64(id)) | |
| 49 | 73 | if err != nil { | |
| @@ -175,13 +199,21 @@ func NewFolderRepository(db *sql.DB) repository.FolderRepository { | |||
| 175 | 199 | ||
| 176 | 200 | func (r *folderRepo) Save(ctx context.Context, folder *entity.Folder) error { | |
| 177 | 201 | log := logging.FromContext(ctx) | |
| 178 | - | log.Debug().Str("name", folder.Name).Msg("saving folder") | |
| 202 | + | log.Debug().Str("name", folder.Name).Int64("id", int64(folder.ID)).Msg("saving folder") | |
| 179 | 203 | ||
| 180 | 204 | var parentID sql.NullInt64 | |
| 181 | 205 | if folder.ParentID != nil { | |
| 182 | 206 | parentID = sql.NullInt64{Int64: int64(*folder.ParentID), Valid: true} | |
| 183 | 207 | } | |
| 184 | 208 | ||
| 209 | + | if folder.ID > 0 { | |
| 210 | + | return r.queries.UpdateFolder(ctx, sqlc.UpdateFolderParams{ | |
| 211 | + | Name: folder.Name, | |
| 212 | + | Icon: sql.NullString{String: folder.Icon, Valid: folder.Icon != ""}, | |
| 213 | + | ID: int64(folder.ID), | |
| 214 | + | }) | |
| 215 | + | } | |
| 216 | + | ||
| 185 | 217 | row, err := r.queries.CreateFolder(ctx, sqlc.CreateFolderParams{ | |
| 186 | 218 | Name: folder.Name, | |
| 187 | 219 | Icon: sql.NullString{String: folder.Icon, Valid: folder.Icon != ""}, | |
| @@ -275,7 +307,15 @@ func NewTagRepository(db *sql.DB) repository.TagRepository { | |||
| 275 | 307 | ||
| 276 | 308 | func (r *tagRepo) Save(ctx context.Context, tag *entity.Tag) error { | |
| 277 | 309 | log := logging.FromContext(ctx) | |
| 278 | - | log.Debug().Str("name", tag.Name).Msg("saving tag") | |
| 310 | + | log.Debug().Str("name", tag.Name).Int64("id", int64(tag.ID)).Msg("saving tag") | |
| 311 | + | ||
| 312 | + | if tag.ID > 0 { | |
| 313 | + | return r.queries.UpdateTag(ctx, sqlc.UpdateTagParams{ | |
| 314 | + | Name: tag.Name, | |
| 315 | + | Color: tag.Color, | |
| 316 | + | ID: int64(tag.ID), | |
| 317 | + | }) | |
| 318 | + | } | |
| 279 | 319 | ||
| 280 | 320 | row, err := r.queries.CreateTag(ctx, sqlc.CreateTagParams{ | |
| 281 | 321 | Name: tag.Name, | |
| @@ -24,13 +24,30 @@ func NewFavoriteRepository(db *sql.DB) repository.FavoriteRepository { | |||||
| 24 | 24 | ||||
| 25 | func (r *favoriteRepo) Save(ctx context.Context, fav *entity.Favorite) error { | 25 | func (r *favoriteRepo) Save(ctx context.Context, fav *entity.Favorite) error { | ||
| 26 | log := logging.FromContext(ctx) | 26 | log := logging.FromContext(ctx) | ||
| 27 | - | log.Debug().Str("url", fav.URL).Msg("saving favorite") | 27 | + | log.Debug().Str("url", fav.URL).Int64("id", int64(fav.ID)).Msg("saving favorite") |
| 28 | 28 | ||||
| 29 | var folderID sql.NullInt64 | 29 | var folderID sql.NullInt64 | ||
| 30 | if fav.FolderID != nil { | 30 | if fav.FolderID != nil { | ||
| 31 | folderID = sql.NullInt64{Int64: int64(*fav.FolderID), Valid: true} | 31 | folderID = sql.NullInt64{Int64: int64(*fav.FolderID), Valid: true} | ||
| 32 | } | 32 | } | ||
| 33 | 33 | ||||
| 34 | + | if fav.ID > 0 { | |||
| 35 | + | if err := r.queries.UpdateFavorite(ctx, sqlc.UpdateFavoriteParams{ | |||
| 36 | + | Title: sql.NullString{String: fav.Title, Valid: fav.Title != ""}, | |||
| 37 | + | FaviconUrl: sql.NullString{String: fav.FaviconURL, Valid: fav.FaviconURL != ""}, | |||
| 38 | + | ID: int64(fav.ID), | |||
| 39 | + | }); err != nil { | |||
| 40 | + | return err | |||
| 41 | + | } | |||
| 42 | + | if err := r.queries.SetFavoriteFolder(ctx, sqlc.SetFavoriteFolderParams{FolderID: folderID, ID: int64(fav.ID)}); err != nil { | |||
| 43 | + | return err | |||
| 44 | + | } | |||
| 45 | + | return r.queries.SetFavoriteShortcut(ctx, sqlc.SetFavoriteShortcutParams{ | |||
| 46 | + | ShortcutKey: sql.NullInt64{Int64: int64Value(fav.ShortcutKey), Valid: fav.ShortcutKey != nil}, | |||
| 47 | + | ID: int64(fav.ID), | |||
| 48 | + | }) | |||
| 49 | + | } | |||
| 50 | + | ||||
| 34 | row, err := r.queries.CreateFavorite(ctx, sqlc.CreateFavoriteParams{ | 51 | row, err := r.queries.CreateFavorite(ctx, sqlc.CreateFavoriteParams{ | ||
| 35 | Url: fav.URL, | 52 | Url: fav.URL, | ||
| 36 | Title: sql.NullString{String: fav.Title, Valid: fav.Title != ""}, | 53 | Title: sql.NullString{String: fav.Title, Valid: fav.Title != ""}, | ||
| @@ -44,6 +61,13 @@ func (r *favoriteRepo) Save(ctx context.Context, fav *entity.Favorite) error { | |||||
| 44 | return nil | 61 | return nil | ||
| 45 | } | 62 | } | ||
| 46 | 63 | ||||
| 64 | + | func int64Value(value *int) int64 { | |||
| 65 | + | if value == nil { | |||
| 66 | + | return 0 | |||
| 67 | + | } | |||
| 68 | + | return int64(*value) | |||
| 69 | + | } | |||
| 70 | + | ||||
| 47 | func (r *favoriteRepo) FindByID(ctx context.Context, id entity.FavoriteID) (*entity.Favorite, error) { | 71 | func (r *favoriteRepo) FindByID(ctx context.Context, id entity.FavoriteID) (*entity.Favorite, error) { | ||
| 48 | row, err := r.queries.GetFavoriteByID(ctx, int64(id)) | 72 | row, err := r.queries.GetFavoriteByID(ctx, int64(id)) | ||
| 49 | if err != nil { | 73 | if err != nil { | ||
| @@ -175,13 +199,21 @@ func NewFolderRepository(db *sql.DB) repository.FolderRepository { | |||||
| 175 | 199 | ||||
| 176 | func (r *folderRepo) Save(ctx context.Context, folder *entity.Folder) error { | 200 | func (r *folderRepo) Save(ctx context.Context, folder *entity.Folder) error { | ||
| 177 | log := logging.FromContext(ctx) | 201 | log := logging.FromContext(ctx) | ||
| 178 | - | log.Debug().Str("name", folder.Name).Msg("saving folder") | 202 | + | log.Debug().Str("name", folder.Name).Int64("id", int64(folder.ID)).Msg("saving folder") |
| 179 | 203 | ||||
| 180 | var parentID sql.NullInt64 | 204 | var parentID sql.NullInt64 | ||
| 181 | if folder.ParentID != nil { | 205 | if folder.ParentID != nil { | ||
| 182 | parentID = sql.NullInt64{Int64: int64(*folder.ParentID), Valid: true} | 206 | parentID = sql.NullInt64{Int64: int64(*folder.ParentID), Valid: true} | ||
| 183 | } | 207 | } | ||
| 184 | 208 | ||||
| 209 | + | if folder.ID > 0 { | |||
| 210 | + | return r.queries.UpdateFolder(ctx, sqlc.UpdateFolderParams{ | |||
| 211 | + | Name: folder.Name, | |||
| 212 | + | Icon: sql.NullString{String: folder.Icon, Valid: folder.Icon != ""}, | |||
| 213 | + | ID: int64(folder.ID), | |||
| 214 | + | }) | |||
| 215 | + | } | |||
| 216 | + | ||||
| 185 | row, err := r.queries.CreateFolder(ctx, sqlc.CreateFolderParams{ | 217 | row, err := r.queries.CreateFolder(ctx, sqlc.CreateFolderParams{ | ||
| 186 | Name: folder.Name, | 218 | Name: folder.Name, | ||
| 187 | Icon: sql.NullString{String: folder.Icon, Valid: folder.Icon != ""}, | 219 | Icon: sql.NullString{String: folder.Icon, Valid: folder.Icon != ""}, | ||
| @@ -275,7 +307,15 @@ func NewTagRepository(db *sql.DB) repository.TagRepository { | |||||
| 275 | 307 | ||||
| 276 | func (r *tagRepo) Save(ctx context.Context, tag *entity.Tag) error { | 308 | func (r *tagRepo) Save(ctx context.Context, tag *entity.Tag) error { | ||
| 277 | log := logging.FromContext(ctx) | 309 | log := logging.FromContext(ctx) | ||
| 278 | - | log.Debug().Str("name", tag.Name).Msg("saving tag") | 310 | + | log.Debug().Str("name", tag.Name).Int64("id", int64(tag.ID)).Msg("saving tag") |
| 311 | + | ||||
| 312 | + | if tag.ID > 0 { | |||
| 313 | + | return r.queries.UpdateTag(ctx, sqlc.UpdateTagParams{ | |||
| 314 | + | Name: tag.Name, | |||
| 315 | + | Color: tag.Color, | |||
| 316 | + | ID: int64(tag.ID), | |||
| 317 | + | }) | |||
| 318 | + | } | |||
| 279 | 319 | ||||
| 280 | row, err := r.queries.CreateTag(ctx, sqlc.CreateTagParams{ | 320 | row, err := r.queries.CreateTag(ctx, sqlc.CreateTagParams{ | ||
| 281 | Name: tag.Name, | 321 | Name: tag.Name, | ||
M internal/infrastructure/systemviewsbridge/browser_transport_js.go +1 -1
| @@ -24,7 +24,7 @@ func NewBrowserClient() *Client { | |||
| 24 | 24 | func callbackPlanForMessage(msgType string) (callbackPlan, bool) { | |
| 25 | 25 | switch msgType { | |
| 26 | 26 | case "history_timeline", "history_search_fts", "history_delete_entry", "history_delete_range", "history_analytics", | |
| 27 | - | "history_domain_stats", "history_delete_domain", "favorite_list", "folder_list", "tag_list", | |
| 27 | + | "history_domain_stats", "history_delete_domain", "favorite_list", "favorite_create", "favorite_update", "favorite_delete", "folder_list", "tag_list", | |
| 28 | 28 | "favorite_set_shortcut", "favorite_set_folder", "folder_create", "folder_update", "folder_delete", | |
| 29 | 29 | "tag_create", "tag_update", "tag_delete", "tag_assign", "tag_remove": | |
| 30 | 30 | return callbackPlan{success: "__dumber_homepage_response", failure: "__dumber_error"}, true | |
| @@ -24,7 +24,7 @@ func NewBrowserClient() *Client { | |||||
| 24 | func callbackPlanForMessage(msgType string) (callbackPlan, bool) { | 24 | func callbackPlanForMessage(msgType string) (callbackPlan, bool) { | ||
| 25 | switch msgType { | 25 | switch msgType { | ||
| 26 | case "history_timeline", "history_search_fts", "history_delete_entry", "history_delete_range", "history_analytics", | 26 | case "history_timeline", "history_search_fts", "history_delete_entry", "history_delete_range", "history_analytics", | ||
| 27 | - | "history_domain_stats", "history_delete_domain", "favorite_list", "folder_list", "tag_list", | 27 | + | "history_domain_stats", "history_delete_domain", "favorite_list", "favorite_create", "favorite_update", "favorite_delete", "folder_list", "tag_list", |
| 28 | "favorite_set_shortcut", "favorite_set_folder", "folder_create", "folder_update", "folder_delete", | 28 | "favorite_set_shortcut", "favorite_set_folder", "folder_create", "folder_update", "folder_delete", | ||
| 29 | "tag_create", "tag_update", "tag_delete", "tag_assign", "tag_remove": | 29 | "tag_create", "tag_update", "tag_delete", "tag_assign", "tag_remove": | ||
| 30 | return callbackPlan{success: "__dumber_homepage_response", failure: "__dumber_error"}, true | 30 | return callbackPlan{success: "__dumber_homepage_response", failure: "__dumber_error"}, true | ||
M internal/infrastructure/systemviewsbridge/client.go +68 -0
| @@ -154,6 +154,74 @@ func (c *Client) List(ctx context.Context) ([]*entity.Favorite, error) { | |||
| 154 | 154 | }{RequestID: nextRequestID()}) | |
| 155 | 155 | } | |
| 156 | 156 | ||
| 157 | + | func (c *Client) CreateFavorite(ctx context.Context, input port.FavoriteCreateInput) (*entity.Favorite, error) { | |
| 158 | + | return request[*entity.Favorite](c, ctx, "favorite_create", favoriteCreatePayload(input)) | |
| 159 | + | } | |
| 160 | + | ||
| 161 | + | func (c *Client) UpdateFavorite(ctx context.Context, input port.FavoriteUpdateInput) (*entity.Favorite, error) { | |
| 162 | + | return request[*entity.Favorite](c, ctx, "favorite_update", favoriteUpdatePayload(input)) | |
| 163 | + | } | |
| 164 | + | ||
| 165 | + | func (c *Client) DeleteFavorite(ctx context.Context, id int64) error { | |
| 166 | + | _, err := request[struct{}](c, ctx, "favorite_delete", struct { | |
| 167 | + | RequestID string `json:"requestId"` | |
| 168 | + | ID int64 `json:"id"` | |
| 169 | + | }{RequestID: nextRequestID(), ID: id}) | |
| 170 | + | return err | |
| 171 | + | } | |
| 172 | + | ||
| 173 | + | func favoriteCreatePayload(input port.FavoriteCreateInput) any { | |
| 174 | + | return struct { | |
| 175 | + | RequestID string `json:"requestId"` | |
| 176 | + | URL string `json:"url"` | |
| 177 | + | Title string `json:"title"` | |
| 178 | + | FaviconURL string `json:"favicon_url"` | |
| 179 | + | FolderID *int64 `json:"folder_id"` | |
| 180 | + | Tags []int64 `json:"tags"` | |
| 181 | + | }{ | |
| 182 | + | RequestID: nextRequestID(), | |
| 183 | + | URL: input.URL, | |
| 184 | + | Title: input.Title, | |
| 185 | + | FaviconURL: input.FaviconURL, | |
| 186 | + | FolderID: folderIDPayload(input.FolderID), | |
| 187 | + | Tags: tagIDPayloads(input.Tags), | |
| 188 | + | } | |
| 189 | + | } | |
| 190 | + | ||
| 191 | + | func favoriteUpdatePayload(input port.FavoriteUpdateInput) any { | |
| 192 | + | return struct { | |
| 193 | + | RequestID string `json:"requestId"` | |
| 194 | + | ID int64 `json:"id"` | |
| 195 | + | Title string `json:"title"` | |
| 196 | + | FaviconURL string `json:"favicon_url"` | |
| 197 | + | FolderID *int64 `json:"folder_id"` | |
| 198 | + | ShortcutKey *int `json:"shortcut_key"` | |
| 199 | + | }{ | |
| 200 | + | RequestID: nextRequestID(), | |
| 201 | + | ID: int64(input.ID), | |
| 202 | + | Title: input.Title, | |
| 203 | + | FaviconURL: input.FaviconURL, | |
| 204 | + | FolderID: folderIDPayload(input.FolderID), | |
| 205 | + | ShortcutKey: input.ShortcutKey, | |
| 206 | + | } | |
| 207 | + | } | |
| 208 | + | ||
| 209 | + | func folderIDPayload(id *entity.FolderID) *int64 { | |
| 210 | + | if id == nil { | |
| 211 | + | return nil | |
| 212 | + | } | |
| 213 | + | value := int64(*id) | |
| 214 | + | return &value | |
| 215 | + | } | |
| 216 | + | ||
| 217 | + | func tagIDPayloads(ids []entity.TagID) []int64 { | |
| 218 | + | out := make([]int64, 0, len(ids)) | |
| 219 | + | for _, id := range ids { | |
| 220 | + | out = append(out, int64(id)) | |
| 221 | + | } | |
| 222 | + | return out | |
| 223 | + | } | |
| 224 | + | ||
| 157 | 225 | func (c *Client) ListFolders(ctx context.Context) ([]*entity.Folder, error) { | |
| 158 | 226 | return request[[]*entity.Folder](c, ctx, "folder_list", struct { | |
| 159 | 227 | RequestID string `json:"requestId"` | |
| @@ -154,6 +154,74 @@ func (c *Client) List(ctx context.Context) ([]*entity.Favorite, error) { | |||||
| 154 | }{RequestID: nextRequestID()}) | 154 | }{RequestID: nextRequestID()}) | ||
| 155 | } | 155 | } | ||
| 156 | 156 | ||||
| 157 | + | func (c *Client) CreateFavorite(ctx context.Context, input port.FavoriteCreateInput) (*entity.Favorite, error) { | |||
| 158 | + | return request[*entity.Favorite](c, ctx, "favorite_create", favoriteCreatePayload(input)) | |||
| 159 | + | } | |||
| 160 | + | ||||
| 161 | + | func (c *Client) UpdateFavorite(ctx context.Context, input port.FavoriteUpdateInput) (*entity.Favorite, error) { | |||
| 162 | + | return request[*entity.Favorite](c, ctx, "favorite_update", favoriteUpdatePayload(input)) | |||
| 163 | + | } | |||
| 164 | + | ||||
| 165 | + | func (c *Client) DeleteFavorite(ctx context.Context, id int64) error { | |||
| 166 | + | _, err := request[struct{}](c, ctx, "favorite_delete", struct { | |||
| 167 | + | RequestID string `json:"requestId"` | |||
| 168 | + | ID int64 `json:"id"` | |||
| 169 | + | }{RequestID: nextRequestID(), ID: id}) | |||
| 170 | + | return err | |||
| 171 | + | } | |||
| 172 | + | ||||
| 173 | + | func favoriteCreatePayload(input port.FavoriteCreateInput) any { | |||
| 174 | + | return struct { | |||
| 175 | + | RequestID string `json:"requestId"` | |||
| 176 | + | URL string `json:"url"` | |||
| 177 | + | Title string `json:"title"` | |||
| 178 | + | FaviconURL string `json:"favicon_url"` | |||
| 179 | + | FolderID *int64 `json:"folder_id"` | |||
| 180 | + | Tags []int64 `json:"tags"` | |||
| 181 | + | }{ | |||
| 182 | + | RequestID: nextRequestID(), | |||
| 183 | + | URL: input.URL, | |||
| 184 | + | Title: input.Title, | |||
| 185 | + | FaviconURL: input.FaviconURL, | |||
| 186 | + | FolderID: folderIDPayload(input.FolderID), | |||
| 187 | + | Tags: tagIDPayloads(input.Tags), | |||
| 188 | + | } | |||
| 189 | + | } | |||
| 190 | + | ||||
| 191 | + | func favoriteUpdatePayload(input port.FavoriteUpdateInput) any { | |||
| 192 | + | return struct { | |||
| 193 | + | RequestID string `json:"requestId"` | |||
| 194 | + | ID int64 `json:"id"` | |||
| 195 | + | Title string `json:"title"` | |||
| 196 | + | FaviconURL string `json:"favicon_url"` | |||
| 197 | + | FolderID *int64 `json:"folder_id"` | |||
| 198 | + | ShortcutKey *int `json:"shortcut_key"` | |||
| 199 | + | }{ | |||
| 200 | + | RequestID: nextRequestID(), | |||
| 201 | + | ID: int64(input.ID), | |||
| 202 | + | Title: input.Title, | |||
| 203 | + | FaviconURL: input.FaviconURL, | |||
| 204 | + | FolderID: folderIDPayload(input.FolderID), | |||
| 205 | + | ShortcutKey: input.ShortcutKey, | |||
| 206 | + | } | |||
| 207 | + | } | |||
| 208 | + | ||||
| 209 | + | func folderIDPayload(id *entity.FolderID) *int64 { | |||
| 210 | + | if id == nil { | |||
| 211 | + | return nil | |||
| 212 | + | } | |||
| 213 | + | value := int64(*id) | |||
| 214 | + | return &value | |||
| 215 | + | } | |||
| 216 | + | ||||
| 217 | + | func tagIDPayloads(ids []entity.TagID) []int64 { | |||
| 218 | + | out := make([]int64, 0, len(ids)) | |||
| 219 | + | for _, id := range ids { | |||
| 220 | + | out = append(out, int64(id)) | |||
| 221 | + | } | |||
| 222 | + | return out | |||
| 223 | + | } | |||
| 224 | + | ||||
| 157 | func (c *Client) ListFolders(ctx context.Context) ([]*entity.Folder, error) { | 225 | func (c *Client) ListFolders(ctx context.Context) ([]*entity.Folder, error) { | ||
| 158 | return request[[]*entity.Folder](c, ctx, "folder_list", struct { | 226 | return request[[]*entity.Folder](c, ctx, "folder_list", struct { | ||
| 159 | RequestID string `json:"requestId"` | 227 | RequestID string `json:"requestId"` | ||
M internal/ui/systemviews/app.go +35 -18
| @@ -18,23 +18,27 @@ type Dependencies struct { | |||
| 18 | 18 | } | |
| 19 | 19 | ||
| 20 | 20 | type App struct { | |
| 21 | - | deps Dependencies | |
| 22 | - | currentRoute Route | |
| 23 | - | shellTheme shellTheme | |
| 24 | - | historyEntries []*entity.HistoryEntry | |
| 25 | - | historyAnalytics *entity.HistoryAnalytics | |
| 26 | - | historyDomainStats []*entity.DomainStat | |
| 27 | - | historyQuery string | |
| 28 | - | historyDomainFilter string | |
| 29 | - | historyOffset int | |
| 30 | - | historyNotice string | |
| 31 | - | historyError string | |
| 32 | - | favorites []*entity.Favorite | |
| 33 | - | folders []*entity.Folder | |
| 34 | - | tags []*entity.Tag | |
| 35 | - | config *port.SystemviewConfigPayload | |
| 36 | - | keybindings any | |
| 37 | - | renderedHTML string | |
| 21 | + | deps Dependencies | |
| 22 | + | currentRoute Route | |
| 23 | + | shellTheme shellTheme | |
| 24 | + | historyEntries []*entity.HistoryEntry | |
| 25 | + | historyAnalytics *entity.HistoryAnalytics | |
| 26 | + | historyDomainStats []*entity.DomainStat | |
| 27 | + | historyQuery string | |
| 28 | + | historyDomainFilter string | |
| 29 | + | historyOffset int | |
| 30 | + | historyNotice string | |
| 31 | + | historyError string | |
| 32 | + | favorites []*entity.Favorite | |
| 33 | + | folders []*entity.Folder | |
| 34 | + | tags []*entity.Tag | |
| 35 | + | favoriteFolderFilter *entity.FolderID | |
| 36 | + | favoriteTagFilter *entity.TagID | |
| 37 | + | favoritesNotice string | |
| 38 | + | favoritesError string | |
| 39 | + | config *port.SystemviewConfigPayload | |
| 40 | + | keybindings any | |
| 41 | + | renderedHTML string | |
| 38 | 42 | } | |
| 39 | 43 | ||
| 40 | 44 | const historyTimelineLimit = 25 | |
| @@ -208,13 +212,22 @@ func (a *App) loadFavoritesRoute(ctx context.Context) error { | |||
| 208 | 212 | } | |
| 209 | 213 | ||
| 210 | 214 | a.historyEntries = nil | |
| 215 | + | favorites = filterFavorites(favorites, a.favoriteFolderFilter, a.favoriteTagFilter) | |
| 211 | 216 | a.favorites = favorites | |
| 212 | 217 | a.folders = folders | |
| 213 | 218 | a.tags = tags | |
| 214 | 219 | a.renderedHTML = renderAppFrame(renderedPage{ | |
| 215 | 220 | route: RouteFavorites, | |
| 216 | 221 | subtitle: "Saved bookmarks", | |
| 217 | - | body: favoritesHTML(favorites, folders, tags), | |
| 222 | + | body: favoritesHTML(favoritesRenderData{ | |
| 223 | + | Favorites: favorites, | |
| 224 | + | Folders: folders, | |
| 225 | + | Tags: tags, | |
| 226 | + | FolderFilter: a.favoriteFolderFilter, | |
| 227 | + | TagFilter: a.favoriteTagFilter, | |
| 228 | + | Notice: a.favoritesNotice, | |
| 229 | + | Error: a.favoritesError, | |
| 230 | + | }), | |
| 218 | 231 | }, a.shellTheme) | |
| 219 | 232 | return nil | |
| 220 | 233 | } | |
| @@ -281,6 +294,10 @@ func (a *App) resetRouteState() { | |||
| 281 | 294 | a.favorites = nil | |
| 282 | 295 | a.folders = nil | |
| 283 | 296 | a.tags = nil | |
| 297 | + | a.favoriteFolderFilter = nil | |
| 298 | + | a.favoriteTagFilter = nil | |
| 299 | + | a.favoritesNotice = "" | |
| 300 | + | a.favoritesError = "" | |
| 284 | 301 | a.config = nil | |
| 285 | 302 | a.keybindings = nil | |
| 286 | 303 | } | |
| @@ -18,23 +18,27 @@ type Dependencies struct { | |||||
| 18 | } | 18 | } | ||
| 19 | 19 | ||||
| 20 | type App struct { | 20 | type App struct { | ||
| 21 | - | deps Dependencies | 21 | + | deps Dependencies |
| 22 | - | currentRoute Route | 22 | + | currentRoute Route |
| 23 | - | shellTheme shellTheme | 23 | + | shellTheme shellTheme |
| 24 | - | historyEntries []*entity.HistoryEntry | 24 | + | historyEntries []*entity.HistoryEntry |
| 25 | - | historyAnalytics *entity.HistoryAnalytics | 25 | + | historyAnalytics *entity.HistoryAnalytics |
| 26 | - | historyDomainStats []*entity.DomainStat | 26 | + | historyDomainStats []*entity.DomainStat |
| 27 | - | historyQuery string | 27 | + | historyQuery string |
| 28 | - | historyDomainFilter string | 28 | + | historyDomainFilter string |
| 29 | - | historyOffset int | 29 | + | historyOffset int |
| 30 | - | historyNotice string | 30 | + | historyNotice string |
| 31 | - | historyError string | 31 | + | historyError string |
| 32 | - | favorites []*entity.Favorite | 32 | + | favorites []*entity.Favorite |
| 33 | - | folders []*entity.Folder | 33 | + | folders []*entity.Folder |
| 34 | - | tags []*entity.Tag | 34 | + | tags []*entity.Tag |
| 35 | - | config *port.SystemviewConfigPayload | 35 | + | favoriteFolderFilter *entity.FolderID |
| 36 | - | keybindings any | 36 | + | favoriteTagFilter *entity.TagID |
| 37 | - | renderedHTML string | 37 | + | favoritesNotice string |
| 38 | + | favoritesError string | |||
| 39 | + | config *port.SystemviewConfigPayload | |||
| 40 | + | keybindings any | |||
| 41 | + | renderedHTML string | |||
| 38 | } | 42 | } | ||
| 39 | 43 | ||||
| 40 | const historyTimelineLimit = 25 | 44 | const historyTimelineLimit = 25 | ||
| @@ -208,13 +212,22 @@ func (a *App) loadFavoritesRoute(ctx context.Context) error { | |||||
| 208 | } | 212 | } | ||
| 209 | 213 | ||||
| 210 | a.historyEntries = nil | 214 | a.historyEntries = nil | ||
| 215 | + | favorites = filterFavorites(favorites, a.favoriteFolderFilter, a.favoriteTagFilter) | |||
| 211 | a.favorites = favorites | 216 | a.favorites = favorites | ||
| 212 | a.folders = folders | 217 | a.folders = folders | ||
| 213 | a.tags = tags | 218 | a.tags = tags | ||
| 214 | a.renderedHTML = renderAppFrame(renderedPage{ | 219 | a.renderedHTML = renderAppFrame(renderedPage{ | ||
| 215 | route: RouteFavorites, | 220 | route: RouteFavorites, | ||
| 216 | subtitle: "Saved bookmarks", | 221 | subtitle: "Saved bookmarks", | ||
| 217 | - | body: favoritesHTML(favorites, folders, tags), | 222 | + | body: favoritesHTML(favoritesRenderData{ |
| 223 | + | Favorites: favorites, | |||
| 224 | + | Folders: folders, | |||
| 225 | + | Tags: tags, | |||
| 226 | + | FolderFilter: a.favoriteFolderFilter, | |||
| 227 | + | TagFilter: a.favoriteTagFilter, | |||
| 228 | + | Notice: a.favoritesNotice, | |||
| 229 | + | Error: a.favoritesError, | |||
| 230 | + | }), | |||
| 218 | }, a.shellTheme) | 231 | }, a.shellTheme) | ||
| 219 | return nil | 232 | return nil | ||
| 220 | } | 233 | } | ||
| @@ -281,6 +294,10 @@ func (a *App) resetRouteState() { | |||||
| 281 | a.favorites = nil | 294 | a.favorites = nil | ||
| 282 | a.folders = nil | 295 | a.folders = nil | ||
| 283 | a.tags = nil | 296 | a.tags = nil | ||
| 297 | + | a.favoriteFolderFilter = nil | |||
| 298 | + | a.favoriteTagFilter = nil | |||
| 299 | + | a.favoritesNotice = "" | |||
| 300 | + | a.favoritesError = "" | |||
| 284 | a.config = nil | 301 | a.config = nil | ||
| 285 | a.keybindings = nil | 302 | a.keybindings = nil | ||
| 286 | } | 303 | } | ||
M internal/ui/systemviews/app_test.go +133 -10
| @@ -254,6 +254,78 @@ func TestAppLoadInitialFavoritesRouteRendersData(t *testing.T) { | |||
| 254 | 254 | assert.Contains(t, app.renderedHTML, `class="sv-meta"`) | |
| 255 | 255 | } | |
| 256 | 256 | ||
| 257 | + | func TestAppLoadInitialFavoritesRouteRendersCRUDControls(t *testing.T) { | |
| 258 | + | t.Parallel() | |
| 259 | + | ||
| 260 | + | folderID := entity.FolderID(1) | |
| 261 | + | shortcut := 3 | |
| 262 | + | favorites := &fakeFavoritesService{ | |
| 263 | + | favorites: []*entity.Favorite{{ID: 42, URL: "https://example.com", Title: "Example", FolderID: &folderID, ShortcutKey: &shortcut}}, | |
| 264 | + | folders: []*entity.Folder{{ID: folderID, Name: "Read Later", Icon: "📚"}}, | |
| 265 | + | tags: []*entity.Tag{{ID: 7, Name: "Go", Color: "#00add8"}}, | |
| 266 | + | } | |
| 267 | + | ||
| 268 | + | app := NewApp(Dependencies{Favorites: favorites, LocationURI: "dumb://favorites"}) | |
| 269 | + | ||
| 270 | + | require.NoError(t, app.LoadInitial(context.Background())) | |
| 271 | + | assert.Contains(t, app.renderedHTML, `data-sv-action="favorite.create"`) | |
| 272 | + | assert.Contains(t, app.renderedHTML, `data-sv-action="favorite.update"`) | |
| 273 | + | assert.Contains(t, app.renderedHTML, `data-sv-action="favorite.delete"`) | |
| 274 | + | assert.Contains(t, app.renderedHTML, `data-sv-action="folder.create"`) | |
| 275 | + | assert.Contains(t, app.renderedHTML, `data-sv-action="folder.update"`) | |
| 276 | + | assert.Contains(t, app.renderedHTML, `data-sv-action="folder.delete"`) | |
| 277 | + | assert.Contains(t, app.renderedHTML, `data-sv-action="tag.create"`) | |
| 278 | + | assert.Contains(t, app.renderedHTML, `data-sv-action="tag.update"`) | |
| 279 | + | assert.Contains(t, app.renderedHTML, `data-sv-action="tag.delete"`) | |
| 280 | + | assert.Contains(t, app.renderedHTML, `data-sv-action="tag.assign"`) | |
| 281 | + | assert.Contains(t, app.renderedHTML, "Read Later") | |
| 282 | + | assert.Contains(t, app.renderedHTML, "Go") | |
| 283 | + | assert.Contains(t, app.renderedHTML, "Shortcut 3") | |
| 284 | + | } | |
| 285 | + | ||
| 286 | + | func TestAppHandleFavoriteActionsRefreshesDOM(t *testing.T) { | |
| 287 | + | dom := &fakeDOM{} | |
| 288 | + | favorites := &fakeFavoritesService{ | |
| 289 | + | favorites: []*entity.Favorite{{ID: 42, URL: "https://example.com", Title: "Example"}}, | |
| 290 | + | folders: []*entity.Folder{{ID: 5, Name: "Read Later"}}, | |
| 291 | + | tags: []*entity.Tag{{ID: 7, Name: "Go", Color: "#00add8"}}, | |
| 292 | + | } | |
| 293 | + | app := NewApp(Dependencies{DOM: dom, Favorites: favorites, LocationURI: "dumb://favorites"}) | |
| 294 | + | ||
| 295 | + | require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{ | |
| 296 | + | Action: favoriteActionCreate, | |
| 297 | + | Data: map[string]string{"url": "https://new.example", "title": "New", "folder_id": "5"}, | |
| 298 | + | })) | |
| 299 | + | require.NotNil(t, favorites.createdFavorite.FolderID) | |
| 300 | + | assert.Equal(t, "https://new.example", favorites.createdFavorite.URL) | |
| 301 | + | assert.Equal(t, entity.FolderID(5), *favorites.createdFavorite.FolderID) | |
| 302 | + | assert.Contains(t, dom.html, "Added favorite New") | |
| 303 | + | ||
| 304 | + | require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{ | |
| 305 | + | Action: favoriteActionUpdate, | |
| 306 | + | Data: map[string]string{"id": "42", "title": "Updated", "favicon_url": "https://icon.example/favicon.ico", "shortcut_key": "4"}, | |
| 307 | + | })) | |
| 308 | + | assert.Equal(t, entity.FavoriteID(42), favorites.updatedFavorite.ID) | |
| 309 | + | assert.Equal(t, "Updated", favorites.updatedFavorite.Title) | |
| 310 | + | require.NotNil(t, favorites.updatedFavorite.ShortcutKey) | |
| 311 | + | assert.Equal(t, 4, *favorites.updatedFavorite.ShortcutKey) | |
| 312 | + | assert.Contains(t, dom.html, "Saved favorite Updated") | |
| 313 | + | ||
| 314 | + | require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{ | |
| 315 | + | Action: tagActionAssign, | |
| 316 | + | Data: map[string]string{"favoriteId": "42", "tagId": "7"}, | |
| 317 | + | })) | |
| 318 | + | assert.Equal(t, int64(42), favorites.assignedFavoriteID) | |
| 319 | + | assert.Equal(t, int64(7), favorites.assignedTagID) | |
| 320 | + | ||
| 321 | + | require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{ | |
| 322 | + | Action: favoriteActionDelete, | |
| 323 | + | Data: map[string]string{"id": "42"}, | |
| 324 | + | })) | |
| 325 | + | assert.Equal(t, int64(42), favorites.deletedFavorite) | |
| 326 | + | assert.Contains(t, dom.html, "Deleted favorite") | |
| 327 | + | } | |
| 328 | + | ||
| 257 | 329 | func TestAppLoadInitialConfigRouteRendersData(t *testing.T) { | |
| 258 | 330 | t.Parallel() | |
| 259 | 331 | ||
| @@ -465,6 +537,20 @@ type fakeFavoritesService struct { | |||
| 465 | 537 | favorites []*entity.Favorite | |
| 466 | 538 | folders []*entity.Folder | |
| 467 | 539 | tags []*entity.Tag | |
| 540 | + | ||
| 541 | + | createdFavorite port.FavoriteCreateInput | |
| 542 | + | updatedFavorite port.FavoriteUpdateInput | |
| 543 | + | deletedFavorite int64 | |
| 544 | + | createdFolder string | |
| 545 | + | updatedFolderID int64 | |
| 546 | + | deletedFolderID int64 | |
| 547 | + | createdTag string | |
| 548 | + | updatedTagID int64 | |
| 549 | + | deletedTagID int64 | |
| 550 | + | assignedFavoriteID int64 | |
| 551 | + | assignedTagID int64 | |
| 552 | + | removedFavoriteID int64 | |
| 553 | + | removedTagID int64 | |
| 468 | 554 | } | |
| 469 | 555 | ||
| 470 | 556 | func (s *fakeFavoritesService) List(context.Context) ([]*entity.Favorite, error) { | |
| @@ -482,29 +568,66 @@ func (s *fakeFavoritesService) ListTags(context.Context) ([]*entity.Tag, error) | |||
| 482 | 568 | return s.tags, nil | |
| 483 | 569 | } | |
| 484 | 570 | ||
| 571 | + | func (s *fakeFavoritesService) CreateFavorite(_ context.Context, input port.FavoriteCreateInput) (*entity.Favorite, error) { | |
| 572 | + | s.createdFavorite = input | |
| 573 | + | return &entity.Favorite{ID: 99, URL: input.URL, Title: input.Title, FolderID: input.FolderID}, nil | |
| 574 | + | } | |
| 575 | + | ||
| 576 | + | func (s *fakeFavoritesService) UpdateFavorite(_ context.Context, input port.FavoriteUpdateInput) (*entity.Favorite, error) { | |
| 577 | + | s.updatedFavorite = input | |
| 578 | + | return &entity.Favorite{ID: input.ID, URL: "https://example.com", Title: input.Title, FaviconURL: input.FaviconURL, FolderID: input.FolderID, ShortcutKey: input.ShortcutKey}, nil | |
| 579 | + | } | |
| 580 | + | ||
| 581 | + | func (s *fakeFavoritesService) DeleteFavorite(_ context.Context, id int64) error { | |
| 582 | + | s.deletedFavorite = id | |
| 583 | + | return nil | |
| 584 | + | } | |
| 585 | + | ||
| 485 | 586 | func (s *fakeFavoritesService) SetShortcut(context.Context, int64, *int) error { return nil } | |
| 486 | 587 | ||
| 487 | 588 | func (s *fakeFavoritesService) SetFolder(context.Context, int64, *int64) error { return nil } | |
| 488 | 589 | ||
| 489 | - | func (s *fakeFavoritesService) CreateFolder(context.Context, string, *int64) (*entity.Folder, error) { | |
| 490 | - | return nil, nil | |
| 590 | + | func (s *fakeFavoritesService) CreateFolder(_ context.Context, name string, _ *int64) (*entity.Folder, error) { | |
| 591 | + | s.createdFolder = name | |
| 592 | + | return &entity.Folder{ID: 77, Name: name}, nil | |
| 491 | 593 | } | |
| 492 | 594 | ||
| 493 | - | func (s *fakeFavoritesService) UpdateFolder(context.Context, int64, string, string) error { return nil } | |
| 595 | + | func (s *fakeFavoritesService) UpdateFolder(_ context.Context, id int64, _, _ string) error { | |
| 596 | + | s.updatedFolderID = id | |
| 597 | + | return nil | |
| 598 | + | } | |
| 494 | 599 | ||
| 495 | - | func (s *fakeFavoritesService) DeleteFolder(context.Context, int64) error { return nil } | |
| 600 | + | func (s *fakeFavoritesService) DeleteFolder(_ context.Context, id int64) error { | |
| 601 | + | s.deletedFolderID = id | |
| 602 | + | return nil | |
| 603 | + | } | |
| 496 | 604 | ||
| 497 | - | func (s *fakeFavoritesService) CreateTag(context.Context, string, string) (*entity.Tag, error) { | |
| 498 | - | return nil, nil | |
| 605 | + | func (s *fakeFavoritesService) CreateTag(_ context.Context, name, color string) (*entity.Tag, error) { | |
| 606 | + | s.createdTag = name | |
| 607 | + | return &entity.Tag{ID: 55, Name: name, Color: color}, nil | |
| 499 | 608 | } | |
| 500 | 609 | ||
| 501 | - | func (s *fakeFavoritesService) UpdateTag(context.Context, int64, string, string) error { return nil } | |
| 610 | + | func (s *fakeFavoritesService) UpdateTag(_ context.Context, id int64, _, _ string) error { | |
| 611 | + | s.updatedTagID = id | |
| 612 | + | return nil | |
| 613 | + | } | |
| 502 | 614 | ||
| 503 | - | func (s *fakeFavoritesService) DeleteTag(context.Context, int64) error { return nil } | |
| 615 | + | func (s *fakeFavoritesService) DeleteTag(_ context.Context, id int64) error { | |
| 616 | + | s.deletedTagID = id | |
| 617 | + | return nil | |
| 618 | + | } | |
| 504 | 619 | ||
| 505 | - | func (s *fakeFavoritesService) AssignTag(context.Context, int64, int64) error { return nil } | |
| 620 | + | func (s *fakeFavoritesService) AssignTag(_ context.Context, favoriteID, tagID int64) error { | |
| 621 | + | s.assignedFavoriteID = favoriteID | |
| 622 | + | s.assignedTagID = tagID | |
| 623 | + | return nil | |
| 624 | + | } | |
| 506 | 625 | ||
| 507 | - | func (s *fakeFavoritesService) RemoveTag(context.Context, int64, int64) error { return nil } | |
| 626 | + | func (s *fakeFavoritesService) RemoveTag(_ context.Context, favoriteID, tagID int64) error { | |
| 627 | + | s.removedFavoriteID = favoriteID | |
| 628 | + | s.removedTagID = tagID | |
| 629 | + | return nil | |
| 630 | + | } | |
| 508 | 631 | ||
| 509 | 632 | type fakeConfigService struct { | |
| 510 | 633 | calledCurrent bool | |
| @@ -254,6 +254,78 @@ func TestAppLoadInitialFavoritesRouteRendersData(t *testing.T) { | |||||
| 254 | assert.Contains(t, app.renderedHTML, `class="sv-meta"`) | 254 | assert.Contains(t, app.renderedHTML, `class="sv-meta"`) | ||
| 255 | } | 255 | } | ||
| 256 | 256 | ||||
| 257 | + | func TestAppLoadInitialFavoritesRouteRendersCRUDControls(t *testing.T) { | |||
| 258 | + | t.Parallel() | |||
| 259 | + | ||||
| 260 | + | folderID := entity.FolderID(1) | |||
| 261 | + | shortcut := 3 | |||
| 262 | + | favorites := &fakeFavoritesService{ | |||
| 263 | + | favorites: []*entity.Favorite{{ID: 42, URL: "https://example.com", Title: "Example", FolderID: &folderID, ShortcutKey: &shortcut}}, | |||
| 264 | + | folders: []*entity.Folder{{ID: folderID, Name: "Read Later", Icon: "📚"}}, | |||
| 265 | + | tags: []*entity.Tag{{ID: 7, Name: "Go", Color: "#00add8"}}, | |||
| 266 | + | } | |||
| 267 | + | ||||
| 268 | + | app := NewApp(Dependencies{Favorites: favorites, LocationURI: "dumb://favorites"}) | |||
| 269 | + | ||||
| 270 | + | require.NoError(t, app.LoadInitial(context.Background())) | |||
| 271 | + | assert.Contains(t, app.renderedHTML, `data-sv-action="favorite.create"`) | |||
| 272 | + | assert.Contains(t, app.renderedHTML, `data-sv-action="favorite.update"`) | |||
| 273 | + | assert.Contains(t, app.renderedHTML, `data-sv-action="favorite.delete"`) | |||
| 274 | + | assert.Contains(t, app.renderedHTML, `data-sv-action="folder.create"`) | |||
| 275 | + | assert.Contains(t, app.renderedHTML, `data-sv-action="folder.update"`) | |||
| 276 | + | assert.Contains(t, app.renderedHTML, `data-sv-action="folder.delete"`) | |||
| 277 | + | assert.Contains(t, app.renderedHTML, `data-sv-action="tag.create"`) | |||
| 278 | + | assert.Contains(t, app.renderedHTML, `data-sv-action="tag.update"`) | |||
| 279 | + | assert.Contains(t, app.renderedHTML, `data-sv-action="tag.delete"`) | |||
| 280 | + | assert.Contains(t, app.renderedHTML, `data-sv-action="tag.assign"`) | |||
| 281 | + | assert.Contains(t, app.renderedHTML, "Read Later") | |||
| 282 | + | assert.Contains(t, app.renderedHTML, "Go") | |||
| 283 | + | assert.Contains(t, app.renderedHTML, "Shortcut 3") | |||
| 284 | + | } | |||
| 285 | + | ||||
| 286 | + | func TestAppHandleFavoriteActionsRefreshesDOM(t *testing.T) { | |||
| 287 | + | dom := &fakeDOM{} | |||
| 288 | + | favorites := &fakeFavoritesService{ | |||
| 289 | + | favorites: []*entity.Favorite{{ID: 42, URL: "https://example.com", Title: "Example"}}, | |||
| 290 | + | folders: []*entity.Folder{{ID: 5, Name: "Read Later"}}, | |||
| 291 | + | tags: []*entity.Tag{{ID: 7, Name: "Go", Color: "#00add8"}}, | |||
| 292 | + | } | |||
| 293 | + | app := NewApp(Dependencies{DOM: dom, Favorites: favorites, LocationURI: "dumb://favorites"}) | |||
| 294 | + | ||||
| 295 | + | require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{ | |||
| 296 | + | Action: favoriteActionCreate, | |||
| 297 | + | Data: map[string]string{"url": "https://new.example", "title": "New", "folder_id": "5"}, | |||
| 298 | + | })) | |||
| 299 | + | require.NotNil(t, favorites.createdFavorite.FolderID) | |||
| 300 | + | assert.Equal(t, "https://new.example", favorites.createdFavorite.URL) | |||
| 301 | + | assert.Equal(t, entity.FolderID(5), *favorites.createdFavorite.FolderID) | |||
| 302 | + | assert.Contains(t, dom.html, "Added favorite New") | |||
| 303 | + | ||||
| 304 | + | require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{ | |||
| 305 | + | Action: favoriteActionUpdate, | |||
| 306 | + | Data: map[string]string{"id": "42", "title": "Updated", "favicon_url": "https://icon.example/favicon.ico", "shortcut_key": "4"}, | |||
| 307 | + | })) | |||
| 308 | + | assert.Equal(t, entity.FavoriteID(42), favorites.updatedFavorite.ID) | |||
| 309 | + | assert.Equal(t, "Updated", favorites.updatedFavorite.Title) | |||
| 310 | + | require.NotNil(t, favorites.updatedFavorite.ShortcutKey) | |||
| 311 | + | assert.Equal(t, 4, *favorites.updatedFavorite.ShortcutKey) | |||
| 312 | + | assert.Contains(t, dom.html, "Saved favorite Updated") | |||
| 313 | + | ||||
| 314 | + | require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{ | |||
| 315 | + | Action: tagActionAssign, | |||
| 316 | + | Data: map[string]string{"favoriteId": "42", "tagId": "7"}, | |||
| 317 | + | })) | |||
| 318 | + | assert.Equal(t, int64(42), favorites.assignedFavoriteID) | |||
| 319 | + | assert.Equal(t, int64(7), favorites.assignedTagID) | |||
| 320 | + | ||||
| 321 | + | require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{ | |||
| 322 | + | Action: favoriteActionDelete, | |||
| 323 | + | Data: map[string]string{"id": "42"}, | |||
| 324 | + | })) | |||
| 325 | + | assert.Equal(t, int64(42), favorites.deletedFavorite) | |||
| 326 | + | assert.Contains(t, dom.html, "Deleted favorite") | |||
| 327 | + | } | |||
| 328 | + | ||||
| 257 | func TestAppLoadInitialConfigRouteRendersData(t *testing.T) { | 329 | func TestAppLoadInitialConfigRouteRendersData(t *testing.T) { | ||
| 258 | t.Parallel() | 330 | t.Parallel() | ||
| 259 | 331 | ||||
| @@ -465,6 +537,20 @@ type fakeFavoritesService struct { | |||||
| 465 | favorites []*entity.Favorite | 537 | favorites []*entity.Favorite | ||
| 466 | folders []*entity.Folder | 538 | folders []*entity.Folder | ||
| 467 | tags []*entity.Tag | 539 | tags []*entity.Tag | ||
| 540 | + | ||||
| 541 | + | createdFavorite port.FavoriteCreateInput | |||
| 542 | + | updatedFavorite port.FavoriteUpdateInput | |||
| 543 | + | deletedFavorite int64 | |||
| 544 | + | createdFolder string | |||
| 545 | + | updatedFolderID int64 | |||
| 546 | + | deletedFolderID int64 | |||
| 547 | + | createdTag string | |||
| 548 | + | updatedTagID int64 | |||
| 549 | + | deletedTagID int64 | |||
| 550 | + | assignedFavoriteID int64 | |||
| 551 | + | assignedTagID int64 | |||
| 552 | + | removedFavoriteID int64 | |||
| 553 | + | removedTagID int64 | |||
| 468 | } | 554 | } | ||
| 469 | 555 | ||||
| 470 | func (s *fakeFavoritesService) List(context.Context) ([]*entity.Favorite, error) { | 556 | func (s *fakeFavoritesService) List(context.Context) ([]*entity.Favorite, error) { | ||
| @@ -482,29 +568,66 @@ func (s *fakeFavoritesService) ListTags(context.Context) ([]*entity.Tag, error) | |||||
| 482 | return s.tags, nil | 568 | return s.tags, nil | ||
| 483 | } | 569 | } | ||
| 484 | 570 | ||||
| 571 | + | func (s *fakeFavoritesService) CreateFavorite(_ context.Context, input port.FavoriteCreateInput) (*entity.Favorite, error) { | |||
| 572 | + | s.createdFavorite = input | |||
| 573 | + | return &entity.Favorite{ID: 99, URL: input.URL, Title: input.Title, FolderID: input.FolderID}, nil | |||
| 574 | + | } | |||
| 575 | + | ||||
| 576 | + | func (s *fakeFavoritesService) UpdateFavorite(_ context.Context, input port.FavoriteUpdateInput) (*entity.Favorite, error) { | |||
| 577 | + | s.updatedFavorite = input | |||
| 578 | + | return &entity.Favorite{ID: input.ID, URL: "https://example.com", Title: input.Title, FaviconURL: input.FaviconURL, FolderID: input.FolderID, ShortcutKey: input.ShortcutKey}, nil | |||
| 579 | + | } | |||
| 580 | + | ||||
| 581 | + | func (s *fakeFavoritesService) DeleteFavorite(_ context.Context, id int64) error { | |||
| 582 | + | s.deletedFavorite = id | |||
| 583 | + | return nil | |||
| 584 | + | } | |||
| 585 | + | ||||
| 485 | func (s *fakeFavoritesService) SetShortcut(context.Context, int64, *int) error { return nil } | 586 | func (s *fakeFavoritesService) SetShortcut(context.Context, int64, *int) error { return nil } | ||
| 486 | 587 | ||||
| 487 | func (s *fakeFavoritesService) SetFolder(context.Context, int64, *int64) error { return nil } | 588 | func (s *fakeFavoritesService) SetFolder(context.Context, int64, *int64) error { return nil } | ||
| 488 | 589 | ||||
| 489 | - | func (s *fakeFavoritesService) CreateFolder(context.Context, string, *int64) (*entity.Folder, error) { | 590 | + | func (s *fakeFavoritesService) CreateFolder(_ context.Context, name string, _ *int64) (*entity.Folder, error) { |
| 490 | - | return nil, nil | 591 | + | s.createdFolder = name |
| 592 | + | return &entity.Folder{ID: 77, Name: name}, nil | |||
| 491 | } | 593 | } | ||
| 492 | 594 | ||||
| 493 | - | func (s *fakeFavoritesService) UpdateFolder(context.Context, int64, string, string) error { return nil } | 595 | + | func (s *fakeFavoritesService) UpdateFolder(_ context.Context, id int64, _, _ string) error { |
| 596 | + | s.updatedFolderID = id | |||
| 597 | + | return nil | |||
| 598 | + | } | |||
| 494 | 599 | ||||
| 495 | - | func (s *fakeFavoritesService) DeleteFolder(context.Context, int64) error { return nil } | 600 | + | func (s *fakeFavoritesService) DeleteFolder(_ context.Context, id int64) error { |
| 601 | + | s.deletedFolderID = id | |||
| 602 | + | return nil | |||
| 603 | + | } | |||
| 496 | 604 | ||||
| 497 | - | func (s *fakeFavoritesService) CreateTag(context.Context, string, string) (*entity.Tag, error) { | 605 | + | func (s *fakeFavoritesService) CreateTag(_ context.Context, name, color string) (*entity.Tag, error) { |
| 498 | - | return nil, nil | 606 | + | s.createdTag = name |
| 607 | + | return &entity.Tag{ID: 55, Name: name, Color: color}, nil | |||
| 499 | } | 608 | } | ||
| 500 | 609 | ||||
| 501 | - | func (s *fakeFavoritesService) UpdateTag(context.Context, int64, string, string) error { return nil } | 610 | + | func (s *fakeFavoritesService) UpdateTag(_ context.Context, id int64, _, _ string) error { |
| 611 | + | s.updatedTagID = id | |||
| 612 | + | return nil | |||
| 613 | + | } | |||
| 502 | 614 | ||||
| 503 | - | func (s *fakeFavoritesService) DeleteTag(context.Context, int64) error { return nil } | 615 | + | func (s *fakeFavoritesService) DeleteTag(_ context.Context, id int64) error { |
| 616 | + | s.deletedTagID = id | |||
| 617 | + | return nil | |||
| 618 | + | } | |||
| 504 | 619 | ||||
| 505 | - | func (s *fakeFavoritesService) AssignTag(context.Context, int64, int64) error { return nil } | 620 | + | func (s *fakeFavoritesService) AssignTag(_ context.Context, favoriteID, tagID int64) error { |
| 621 | + | s.assignedFavoriteID = favoriteID | |||
| 622 | + | s.assignedTagID = tagID | |||
| 623 | + | return nil | |||
| 624 | + | } | |||
| 506 | 625 | ||||
| 507 | - | func (s *fakeFavoritesService) RemoveTag(context.Context, int64, int64) error { return nil } | 626 | + | func (s *fakeFavoritesService) RemoveTag(_ context.Context, favoriteID, tagID int64) error { |
| 627 | + | s.removedFavoriteID = favoriteID | |||
| 628 | + | s.removedTagID = tagID | |||
| 629 | + | return nil | |||
| 630 | + | } | |||
| 508 | 631 | ||||
| 509 | type fakeConfigService struct { | 632 | type fakeConfigService struct { | ||
| 510 | calledCurrent bool | 633 | calledCurrent bool | ||
M internal/ui/systemviews/dom_js.go +131 -4
| @@ -80,8 +80,8 @@ func (d *browserDOM) BindActions(handler DOMActionHandler) error { | |||
| 80 | 80 | } | |
| 81 | 81 | event.Call("preventDefault") | |
| 82 | 82 | data := collectActionData(form) | |
| 83 | - | if queryInput := form.Call("querySelector", `[name="query"]`); queryInput.Truthy() { | |
| 84 | - | data["query"] = queryInput.Get("value").String() | |
| 83 | + | for key, value := range collectFormData(form) { | |
| 84 | + | data[key] = value | |
| 85 | 85 | } | |
| 86 | 86 | handler(DOMAction{Action: action, Data: data}) | |
| 87 | 87 | return nil | |
| @@ -90,7 +90,7 @@ func (d *browserDOM) BindActions(handler DOMActionHandler) error { | |||
| 90 | 90 | d.bindings = append(d.bindings, submitHandler) | |
| 91 | 91 | ||
| 92 | 92 | keydownHandler := js.FuncOf(func(_ js.Value, args []js.Value) any { | |
| 93 | - | d.handleHistoryKeyboard(firstJSArg(args)) | |
| 93 | + | d.handleKeyboard(firstJSArg(args)) | |
| 94 | 94 | return nil | |
| 95 | 95 | }) | |
| 96 | 96 | d.document.Call("addEventListener", "keydown", keydownHandler) | |
| @@ -134,6 +134,23 @@ func collectActionData(element js.Value) map[string]string { | |||
| 134 | 134 | return data | |
| 135 | 135 | } | |
| 136 | 136 | ||
| 137 | + | func collectFormData(form js.Value) map[string]string { | |
| 138 | + | data := map[string]string{} | |
| 139 | + | if !form.Truthy() || !js.Global().Get("FormData").Truthy() { | |
| 140 | + | return data | |
| 141 | + | } | |
| 142 | + | formData := js.Global().Get("FormData").New(form) | |
| 143 | + | entries := js.Global().Get("Array").Call("from", formData.Call("entries")) | |
| 144 | + | for i := 0; i < entries.Get("length").Int(); i++ { | |
| 145 | + | pair := entries.Index(i) | |
| 146 | + | if pair.Get("length").Int() < 2 { | |
| 147 | + | continue | |
| 148 | + | } | |
| 149 | + | data[pair.Index(0).String()] = pair.Index(1).String() | |
| 150 | + | } | |
| 151 | + | return data | |
| 152 | + | } | |
| 153 | + | ||
| 137 | 154 | func confirmAction(element js.Value) bool { | |
| 138 | 155 | message := element.Call("getAttribute", "data-sv-confirm").String() | |
| 139 | 156 | if message == "" { | |
| @@ -146,8 +163,21 @@ func confirmAction(element js.Value) bool { | |||
| 146 | 163 | return confirm.Invoke(message).Bool() | |
| 147 | 164 | } | |
| 148 | 165 | ||
| 166 | + | func (d *browserDOM) handleKeyboard(event js.Value) { | |
| 167 | + | if d == nil || !d.target.Truthy() || !event.Truthy() { | |
| 168 | + | return | |
| 169 | + | } | |
| 170 | + | if d.isHistoryRoute() { | |
| 171 | + | d.handleHistoryKeyboard(event) | |
| 172 | + | return | |
| 173 | + | } | |
| 174 | + | if d.isFavoritesRoute() { | |
| 175 | + | d.handleFavoritesKeyboard(event) | |
| 176 | + | } | |
| 177 | + | } | |
| 178 | + | ||
| 149 | 179 | func (d *browserDOM) handleHistoryKeyboard(event js.Value) { | |
| 150 | - | if d == nil || !d.target.Truthy() || !event.Truthy() || !d.isHistoryRoute() { | |
| 180 | + | if d == nil || !d.target.Truthy() || !event.Truthy() { | |
| 151 | 181 | return | |
| 152 | 182 | } | |
| 153 | 183 | key := strings.ToLower(event.Get("key").String()) | |
| @@ -193,6 +223,52 @@ func (d *browserDOM) isHistoryRoute() bool { | |||
| 193 | 223 | return d.target.Call("querySelector", `[data-route="history"]`).Truthy() | |
| 194 | 224 | } | |
| 195 | 225 | ||
| 226 | + | func (d *browserDOM) isFavoritesRoute() bool { | |
| 227 | + | return d.target.Call("querySelector", `[data-route="favorites"]`).Truthy() | |
| 228 | + | } | |
| 229 | + | ||
| 230 | + | func (d *browserDOM) handleFavoritesKeyboard(event js.Value) { | |
| 231 | + | key := strings.ToLower(event.Get("key").String()) | |
| 232 | + | target := event.Get("target") | |
| 233 | + | if isEditableTarget(target) && key != "escape" { | |
| 234 | + | return | |
| 235 | + | } | |
| 236 | + | ||
| 237 | + | switch key { | |
| 238 | + | case "j", "arrowdown": | |
| 239 | + | event.Call("preventDefault") | |
| 240 | + | d.focusFavoriteRow(1) | |
| 241 | + | case "k", "arrowup": | |
| 242 | + | event.Call("preventDefault") | |
| 243 | + | d.focusFavoriteRow(-1) | |
| 244 | + | case "enter": | |
| 245 | + | if row := d.activeFavoriteRow(); row.Truthy() { | |
| 246 | + | event.Call("preventDefault") | |
| 247 | + | if link := row.Call("querySelector", ".sv-favorite-open"); link.Truthy() { | |
| 248 | + | link.Call("click") | |
| 249 | + | } | |
| 250 | + | } | |
| 251 | + | case "e": | |
| 252 | + | if row := d.activeFavoriteRow(); row.Truthy() { | |
| 253 | + | event.Call("preventDefault") | |
| 254 | + | if input := row.Call("querySelector", `[name="title"]`); input.Truthy() { | |
| 255 | + | input.Call("focus") | |
| 256 | + | } | |
| 257 | + | } | |
| 258 | + | case "d", "delete", "backspace": | |
| 259 | + | if row := d.activeFavoriteRow(); row.Truthy() { | |
| 260 | + | event.Call("preventDefault") | |
| 261 | + | if button := row.Call("querySelector", `[data-sv-action="favorite.delete"]`); button.Truthy() { | |
| 262 | + | button.Call("click") | |
| 263 | + | } | |
| 264 | + | } | |
| 265 | + | case "escape": | |
| 266 | + | if target.Truthy() && isEditableTarget(target) { | |
| 267 | + | target.Call("blur") | |
| 268 | + | } | |
| 269 | + | } | |
| 270 | + | } | |
| 271 | + | ||
| 196 | 272 | func isEditableTarget(target js.Value) bool { | |
| 197 | 273 | if !target.Truthy() { | |
| 198 | 274 | return false | |
| @@ -251,3 +327,54 @@ func (d *browserDOM) activeHistoryIndex() int { | |||
| 251 | 327 | } | |
| 252 | 328 | return idx | |
| 253 | 329 | } | |
| 330 | + | ||
| 331 | + | func (d *browserDOM) focusFavoriteRow(delta int) { | |
| 332 | + | rows := d.target.Call("querySelectorAll", "[data-sv-favorite-row]") | |
| 333 | + | length := rows.Get("length").Int() | |
| 334 | + | if length == 0 { | |
| 335 | + | return | |
| 336 | + | } | |
| 337 | + | idx := d.activeFavoriteIndex() | |
| 338 | + | if idx < 0 { | |
| 339 | + | idx = 0 | |
| 340 | + | } else { | |
| 341 | + | idx += delta | |
| 342 | + | } | |
| 343 | + | if idx < 0 { | |
| 344 | + | idx = 0 | |
| 345 | + | } | |
| 346 | + | if idx >= length { | |
| 347 | + | idx = length - 1 | |
| 348 | + | } | |
| 349 | + | for i := 0; i < length; i++ { | |
| 350 | + | rows.Index(i).Get("classList").Call("remove", "sv-focused") | |
| 351 | + | } | |
| 352 | + | row := rows.Index(idx) | |
| 353 | + | row.Get("classList").Call("add", "sv-focused") | |
| 354 | + | d.target.Get("dataset").Set("svActiveFavoriteIndex", strconv.Itoa(idx)) | |
| 355 | + | row.Call("scrollIntoView", map[string]any{"block": "nearest"}) | |
| 356 | + | } | |
| 357 | + | ||
| 358 | + | func (d *browserDOM) activeFavoriteRow() js.Value { | |
| 359 | + | rows := d.target.Call("querySelectorAll", "[data-sv-favorite-row]") | |
| 360 | + | length := rows.Get("length").Int() | |
| 361 | + | if length == 0 { | |
| 362 | + | return js.Value{} | |
| 363 | + | } | |
| 364 | + | idx := d.activeFavoriteIndex() | |
| 365 | + | if idx < 0 || idx >= length { | |
| 366 | + | idx = 0 | |
| 367 | + | d.target.Get("dataset").Set("svActiveFavoriteIndex", "0") | |
| 368 | + | rows.Index(0).Get("classList").Call("add", "sv-focused") | |
| 369 | + | } | |
| 370 | + | return rows.Index(idx) | |
| 371 | + | } | |
| 372 | + | ||
| 373 | + | func (d *browserDOM) activeFavoriteIndex() int { | |
| 374 | + | value := d.target.Get("dataset").Get("svActiveFavoriteIndex").String() | |
| 375 | + | idx, err := strconv.Atoi(value) | |
| 376 | + | if err != nil { | |
| 377 | + | return -1 | |
| 378 | + | } | |
| 379 | + | return idx | |
| 380 | + | } | |
| @@ -80,8 +80,8 @@ func (d *browserDOM) BindActions(handler DOMActionHandler) error { | |||||
| 80 | } | 80 | } | ||
| 81 | event.Call("preventDefault") | 81 | event.Call("preventDefault") | ||
| 82 | data := collectActionData(form) | 82 | data := collectActionData(form) | ||
| 83 | - | if queryInput := form.Call("querySelector", `[name="query"]`); queryInput.Truthy() { | 83 | + | for key, value := range collectFormData(form) { |
| 84 | - | data["query"] = queryInput.Get("value").String() | 84 | + | data[key] = value |
| 85 | } | 85 | } | ||
| 86 | handler(DOMAction{Action: action, Data: data}) | 86 | handler(DOMAction{Action: action, Data: data}) | ||
| 87 | return nil | 87 | return nil | ||
| @@ -90,7 +90,7 @@ func (d *browserDOM) BindActions(handler DOMActionHandler) error { | |||||
| 90 | d.bindings = append(d.bindings, submitHandler) | 90 | d.bindings = append(d.bindings, submitHandler) | ||
| 91 | 91 | ||||
| 92 | keydownHandler := js.FuncOf(func(_ js.Value, args []js.Value) any { | 92 | keydownHandler := js.FuncOf(func(_ js.Value, args []js.Value) any { | ||
| 93 | - | d.handleHistoryKeyboard(firstJSArg(args)) | 93 | + | d.handleKeyboard(firstJSArg(args)) |
| 94 | return nil | 94 | return nil | ||
| 95 | }) | 95 | }) | ||
| 96 | d.document.Call("addEventListener", "keydown", keydownHandler) | 96 | d.document.Call("addEventListener", "keydown", keydownHandler) | ||
| @@ -134,6 +134,23 @@ func collectActionData(element js.Value) map[string]string { | |||||
| 134 | return data | 134 | return data | ||
| 135 | } | 135 | } | ||
| 136 | 136 | ||||
| 137 | + | func collectFormData(form js.Value) map[string]string { | |||
| 138 | + | data := map[string]string{} | |||
| 139 | + | if !form.Truthy() || !js.Global().Get("FormData").Truthy() { | |||
| 140 | + | return data | |||
| 141 | + | } | |||
| 142 | + | formData := js.Global().Get("FormData").New(form) | |||
| 143 | + | entries := js.Global().Get("Array").Call("from", formData.Call("entries")) | |||
| 144 | + | for i := 0; i < entries.Get("length").Int(); i++ { | |||
| 145 | + | pair := entries.Index(i) | |||
| 146 | + | if pair.Get("length").Int() < 2 { | |||
| 147 | + | continue | |||
| 148 | + | } | |||
| 149 | + | data[pair.Index(0).String()] = pair.Index(1).String() | |||
| 150 | + | } | |||
| 151 | + | return data | |||
| 152 | + | } | |||
| 153 | + | ||||
| 137 | func confirmAction(element js.Value) bool { | 154 | func confirmAction(element js.Value) bool { | ||
| 138 | message := element.Call("getAttribute", "data-sv-confirm").String() | 155 | message := element.Call("getAttribute", "data-sv-confirm").String() | ||
| 139 | if message == "" { | 156 | if message == "" { | ||
| @@ -146,8 +163,21 @@ func confirmAction(element js.Value) bool { | |||||
| 146 | return confirm.Invoke(message).Bool() | 163 | return confirm.Invoke(message).Bool() | ||
| 147 | } | 164 | } | ||
| 148 | 165 | ||||
| 166 | + | func (d *browserDOM) handleKeyboard(event js.Value) { | |||
| 167 | + | if d == nil || !d.target.Truthy() || !event.Truthy() { | |||
| 168 | + | return | |||
| 169 | + | } | |||
| 170 | + | if d.isHistoryRoute() { | |||
| 171 | + | d.handleHistoryKeyboard(event) | |||
| 172 | + | return | |||
| 173 | + | } | |||
| 174 | + | if d.isFavoritesRoute() { | |||
| 175 | + | d.handleFavoritesKeyboard(event) | |||
| 176 | + | } | |||
| 177 | + | } | |||
| 178 | + | ||||
| 149 | func (d *browserDOM) handleHistoryKeyboard(event js.Value) { | 179 | func (d *browserDOM) handleHistoryKeyboard(event js.Value) { | ||
| 150 | - | if d == nil || !d.target.Truthy() || !event.Truthy() || !d.isHistoryRoute() { | 180 | + | if d == nil || !d.target.Truthy() || !event.Truthy() { |
| 151 | return | 181 | return | ||
| 152 | } | 182 | } | ||
| 153 | key := strings.ToLower(event.Get("key").String()) | 183 | key := strings.ToLower(event.Get("key").String()) | ||
| @@ -193,6 +223,52 @@ func (d *browserDOM) isHistoryRoute() bool { | |||||
| 193 | return d.target.Call("querySelector", `[data-route="history"]`).Truthy() | 223 | return d.target.Call("querySelector", `[data-route="history"]`).Truthy() | ||
| 194 | } | 224 | } | ||
| 195 | 225 | ||||
| 226 | + | func (d *browserDOM) isFavoritesRoute() bool { | |||
| 227 | + | return d.target.Call("querySelector", `[data-route="favorites"]`).Truthy() | |||
| 228 | + | } | |||
| 229 | + | ||||
| 230 | + | func (d *browserDOM) handleFavoritesKeyboard(event js.Value) { | |||
| 231 | + | key := strings.ToLower(event.Get("key").String()) | |||
| 232 | + | target := event.Get("target") | |||
| 233 | + | if isEditableTarget(target) && key != "escape" { | |||
| 234 | + | return | |||
| 235 | + | } | |||
| 236 | + | ||||
| 237 | + | switch key { | |||
| 238 | + | case "j", "arrowdown": | |||
| 239 | + | event.Call("preventDefault") | |||
| 240 | + | d.focusFavoriteRow(1) | |||
| 241 | + | case "k", "arrowup": | |||
| 242 | + | event.Call("preventDefault") | |||
| 243 | + | d.focusFavoriteRow(-1) | |||
| 244 | + | case "enter": | |||
| 245 | + | if row := d.activeFavoriteRow(); row.Truthy() { | |||
| 246 | + | event.Call("preventDefault") | |||
| 247 | + | if link := row.Call("querySelector", ".sv-favorite-open"); link.Truthy() { | |||
| 248 | + | link.Call("click") | |||
| 249 | + | } | |||
| 250 | + | } | |||
| 251 | + | case "e": | |||
| 252 | + | if row := d.activeFavoriteRow(); row.Truthy() { | |||
| 253 | + | event.Call("preventDefault") | |||
| 254 | + | if input := row.Call("querySelector", `[name="title"]`); input.Truthy() { | |||
| 255 | + | input.Call("focus") | |||
| 256 | + | } | |||
| 257 | + | } | |||
| 258 | + | case "d", "delete", "backspace": | |||
| 259 | + | if row := d.activeFavoriteRow(); row.Truthy() { | |||
| 260 | + | event.Call("preventDefault") | |||
| 261 | + | if button := row.Call("querySelector", `[data-sv-action="favorite.delete"]`); button.Truthy() { | |||
| 262 | + | button.Call("click") | |||
| 263 | + | } | |||
| 264 | + | } | |||
| 265 | + | case "escape": | |||
| 266 | + | if target.Truthy() && isEditableTarget(target) { | |||
| 267 | + | target.Call("blur") | |||
| 268 | + | } | |||
| 269 | + | } | |||
| 270 | + | } | |||
| 271 | + | ||||
| 196 | func isEditableTarget(target js.Value) bool { | 272 | func isEditableTarget(target js.Value) bool { | ||
| 197 | if !target.Truthy() { | 273 | if !target.Truthy() { | ||
| 198 | return false | 274 | return false | ||
| @@ -251,3 +327,54 @@ func (d *browserDOM) activeHistoryIndex() int { | |||||
| 251 | } | 327 | } | ||
| 252 | return idx | 328 | return idx | ||
| 253 | } | 329 | } | ||
| 330 | + | ||||
| 331 | + | func (d *browserDOM) focusFavoriteRow(delta int) { | |||
| 332 | + | rows := d.target.Call("querySelectorAll", "[data-sv-favorite-row]") | |||
| 333 | + | length := rows.Get("length").Int() | |||
| 334 | + | if length == 0 { | |||
| 335 | + | return | |||
| 336 | + | } | |||
| 337 | + | idx := d.activeFavoriteIndex() | |||
| 338 | + | if idx < 0 { | |||
| 339 | + | idx = 0 | |||
| 340 | + | } else { | |||
| 341 | + | idx += delta | |||
| 342 | + | } | |||
| 343 | + | if idx < 0 { | |||
| 344 | + | idx = 0 | |||
| 345 | + | } | |||
| 346 | + | if idx >= length { | |||
| 347 | + | idx = length - 1 | |||
| 348 | + | } | |||
| 349 | + | for i := 0; i < length; i++ { | |||
| 350 | + | rows.Index(i).Get("classList").Call("remove", "sv-focused") | |||
| 351 | + | } | |||
| 352 | + | row := rows.Index(idx) | |||
| 353 | + | row.Get("classList").Call("add", "sv-focused") | |||
| 354 | + | d.target.Get("dataset").Set("svActiveFavoriteIndex", strconv.Itoa(idx)) | |||
| 355 | + | row.Call("scrollIntoView", map[string]any{"block": "nearest"}) | |||
| 356 | + | } | |||
| 357 | + | ||||
| 358 | + | func (d *browserDOM) activeFavoriteRow() js.Value { | |||
| 359 | + | rows := d.target.Call("querySelectorAll", "[data-sv-favorite-row]") | |||
| 360 | + | length := rows.Get("length").Int() | |||
| 361 | + | if length == 0 { | |||
| 362 | + | return js.Value{} | |||
| 363 | + | } | |||
| 364 | + | idx := d.activeFavoriteIndex() | |||
| 365 | + | if idx < 0 || idx >= length { | |||
| 366 | + | idx = 0 | |||
| 367 | + | d.target.Get("dataset").Set("svActiveFavoriteIndex", "0") | |||
| 368 | + | rows.Index(0).Get("classList").Call("add", "sv-focused") | |||
| 369 | + | } | |||
| 370 | + | return rows.Index(idx) | |||
| 371 | + | } | |||
| 372 | + | ||||
| 373 | + | func (d *browserDOM) activeFavoriteIndex() int { | |||
| 374 | + | value := d.target.Get("dataset").Get("svActiveFavoriteIndex").String() | |||
| 375 | + | idx, err := strconv.Atoi(value) | |||
| 376 | + | if err != nil { | |||
| 377 | + | return -1 | |||
| 378 | + | } | |||
| 379 | + | return idx | |||
| 380 | + | } | |||
A internal/ui/systemviews/favorite_actions.go +243 -0
| @@ -0,0 +1,243 @@ | |||
| 1 | + | package systemviews | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "fmt" | |
| 6 | + | "strconv" | |
| 7 | + | "strings" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/dumber/internal/application/port" | |
| 10 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | const ( | |
| 14 | + | favoriteActionCreate = "favorite.create" | |
| 15 | + | favoriteActionUpdate = "favorite.update" | |
| 16 | + | favoriteActionDelete = "favorite.delete" | |
| 17 | + | favoriteActionFilterFolder = "favorite.filterFolder" | |
| 18 | + | favoriteActionFilterTag = "favorite.filterTag" | |
| 19 | + | favoriteActionClearFilters = "favorite.clearFilters" | |
| 20 | + | folderActionCreate = "folder.create" | |
| 21 | + | folderActionUpdate = "folder.update" | |
| 22 | + | folderActionDelete = "folder.delete" | |
| 23 | + | tagActionCreate = "tag.create" | |
| 24 | + | tagActionUpdate = "tag.update" | |
| 25 | + | tagActionDelete = "tag.delete" | |
| 26 | + | tagActionAssign = "tag.assign" | |
| 27 | + | tagActionRemove = "tag.remove" | |
| 28 | + | ) | |
| 29 | + | ||
| 30 | + | func (a *App) handleFavoriteAction(ctx context.Context, event DOMAction) error { | |
| 31 | + | if a.deps.Favorites == nil { | |
| 32 | + | return fmt.Errorf("favorites service not configured") | |
| 33 | + | } | |
| 34 | + | data := event.Data | |
| 35 | + | switch event.Action { | |
| 36 | + | case favoriteActionCreate: | |
| 37 | + | folderID, err := optionalFolderID(data["folder_id"]) | |
| 38 | + | if err != nil { | |
| 39 | + | return err | |
| 40 | + | } | |
| 41 | + | favorite, err := a.deps.Favorites.CreateFavorite(ctx, port.FavoriteCreateInput{ | |
| 42 | + | URL: strings.TrimSpace(data["url"]), | |
| 43 | + | Title: strings.TrimSpace(data["title"]), | |
| 44 | + | FolderID: folderID, | |
| 45 | + | }) | |
| 46 | + | if err != nil { | |
| 47 | + | return err | |
| 48 | + | } | |
| 49 | + | a.favoritesNotice = "Added favorite " + favoriteDisplayTitle(favorite) | |
| 50 | + | case favoriteActionUpdate: | |
| 51 | + | id, err := parsePositiveInt64(data["id"], "favorite id") | |
| 52 | + | if err != nil { | |
| 53 | + | return err | |
| 54 | + | } | |
| 55 | + | folderID, err := optionalFolderID(data["folder_id"]) | |
| 56 | + | if err != nil { | |
| 57 | + | return err | |
| 58 | + | } | |
| 59 | + | shortcut, err := optionalShortcut(data["shortcut_key"]) | |
| 60 | + | if err != nil { | |
| 61 | + | return err | |
| 62 | + | } | |
| 63 | + | favorite, err := a.deps.Favorites.UpdateFavorite(ctx, port.FavoriteUpdateInput{ | |
| 64 | + | ID: entity.FavoriteID(id), | |
| 65 | + | Title: strings.TrimSpace(data["title"]), | |
| 66 | + | FaviconURL: strings.TrimSpace(data["favicon_url"]), | |
| 67 | + | FolderID: folderID, | |
| 68 | + | ShortcutKey: shortcut, | |
| 69 | + | }) | |
| 70 | + | if err != nil { | |
| 71 | + | return err | |
| 72 | + | } | |
| 73 | + | a.favoritesNotice = "Saved favorite " + favoriteDisplayTitle(favorite) | |
| 74 | + | case favoriteActionDelete: | |
| 75 | + | id, err := parsePositiveInt64(data["id"], "favorite id") | |
| 76 | + | if err != nil { | |
| 77 | + | return err | |
| 78 | + | } | |
| 79 | + | if err := a.deps.Favorites.DeleteFavorite(ctx, id); err != nil { | |
| 80 | + | return err | |
| 81 | + | } | |
| 82 | + | a.favoritesNotice = "Deleted favorite" | |
| 83 | + | case favoriteActionFilterFolder: | |
| 84 | + | folderID, err := filterFolderID(data["folder_id"]) | |
| 85 | + | if err != nil { | |
| 86 | + | return err | |
| 87 | + | } | |
| 88 | + | a.favoriteFolderFilter = folderID | |
| 89 | + | a.favoriteTagFilter = nil | |
| 90 | + | a.favoritesNotice = "" | |
| 91 | + | case favoriteActionFilterTag: | |
| 92 | + | tagID, err := parsePositiveInt64(data["tag_id"], "tag id") | |
| 93 | + | if err != nil { | |
| 94 | + | return err | |
| 95 | + | } | |
| 96 | + | id := entity.TagID(tagID) | |
| 97 | + | a.favoriteTagFilter = &id | |
| 98 | + | a.favoriteFolderFilter = nil | |
| 99 | + | a.favoritesNotice = "" | |
| 100 | + | case favoriteActionClearFilters: | |
| 101 | + | a.favoriteFolderFilter = nil | |
| 102 | + | a.favoriteTagFilter = nil | |
| 103 | + | a.favoritesNotice = "" | |
| 104 | + | case folderActionCreate: | |
| 105 | + | name := strings.TrimSpace(data["name"]) | |
| 106 | + | if name == "" { | |
| 107 | + | return fmt.Errorf("folder name is required") | |
| 108 | + | } | |
| 109 | + | folder, err := a.deps.Favorites.CreateFolder(ctx, name, nil) | |
| 110 | + | if err != nil { | |
| 111 | + | return err | |
| 112 | + | } | |
| 113 | + | a.favoritesNotice = "Created folder " + folderDisplayName(folder) | |
| 114 | + | case folderActionUpdate: | |
| 115 | + | id, err := parsePositiveInt64(data["id"], "folder id") | |
| 116 | + | if err != nil { | |
| 117 | + | return err | |
| 118 | + | } | |
| 119 | + | name := strings.TrimSpace(data["name"]) | |
| 120 | + | if name == "" { | |
| 121 | + | return fmt.Errorf("folder name is required") | |
| 122 | + | } | |
| 123 | + | if err := a.deps.Favorites.UpdateFolder(ctx, id, name, strings.TrimSpace(data["icon"])); err != nil { | |
| 124 | + | return err | |
| 125 | + | } | |
| 126 | + | a.favoritesNotice = "Saved folder " + name | |
| 127 | + | case folderActionDelete: | |
| 128 | + | id, err := parsePositiveInt64(data["id"], "folder id") | |
| 129 | + | if err != nil { | |
| 130 | + | return err | |
| 131 | + | } | |
| 132 | + | if err := a.deps.Favorites.DeleteFolder(ctx, id); err != nil { | |
| 133 | + | return err | |
| 134 | + | } | |
| 135 | + | a.favoriteFolderFilter = nil | |
| 136 | + | a.favoritesNotice = "Deleted folder" | |
| 137 | + | case tagActionCreate: | |
| 138 | + | name := strings.TrimSpace(data["name"]) | |
| 139 | + | if name == "" { | |
| 140 | + | return fmt.Errorf("tag name is required") | |
| 141 | + | } | |
| 142 | + | tag, err := a.deps.Favorites.CreateTag(ctx, name, strings.TrimSpace(data["color"])) | |
| 143 | + | if err != nil { | |
| 144 | + | return err | |
| 145 | + | } | |
| 146 | + | a.favoritesNotice = "Created tag " + tag.Name | |
| 147 | + | case tagActionUpdate: | |
| 148 | + | id, err := parsePositiveInt64(data["id"], "tag id") | |
| 149 | + | if err != nil { | |
| 150 | + | return err | |
| 151 | + | } | |
| 152 | + | name := strings.TrimSpace(data["name"]) | |
| 153 | + | if name == "" { | |
| 154 | + | return fmt.Errorf("tag name is required") | |
| 155 | + | } | |
| 156 | + | if err := a.deps.Favorites.UpdateTag(ctx, id, name, strings.TrimSpace(data["color"])); err != nil { | |
| 157 | + | return err | |
| 158 | + | } | |
| 159 | + | a.favoritesNotice = "Saved tag " + name | |
| 160 | + | case tagActionDelete: | |
| 161 | + | id, err := parsePositiveInt64(data["id"], "tag id") | |
| 162 | + | if err != nil { | |
| 163 | + | return err | |
| 164 | + | } | |
| 165 | + | if err := a.deps.Favorites.DeleteTag(ctx, id); err != nil { | |
| 166 | + | return err | |
| 167 | + | } | |
| 168 | + | a.favoriteTagFilter = nil | |
| 169 | + | a.favoritesNotice = "Deleted tag" | |
| 170 | + | case tagActionAssign, tagActionRemove: | |
| 171 | + | favoriteID, err := parsePositiveInt64(data["favoriteId"], "favorite id") | |
| 172 | + | if err != nil { | |
| 173 | + | return err | |
| 174 | + | } | |
| 175 | + | tagID, err := parsePositiveInt64(data["tagId"], "tag id") | |
| 176 | + | if err != nil { | |
| 177 | + | return err | |
| 178 | + | } | |
| 179 | + | if event.Action == tagActionAssign { | |
| 180 | + | if err := a.deps.Favorites.AssignTag(ctx, favoriteID, tagID); err != nil { | |
| 181 | + | return err | |
| 182 | + | } | |
| 183 | + | a.favoritesNotice = "Assigned tag" | |
| 184 | + | } else { | |
| 185 | + | if err := a.deps.Favorites.RemoveTag(ctx, favoriteID, tagID); err != nil { | |
| 186 | + | return err | |
| 187 | + | } | |
| 188 | + | a.favoritesNotice = "Removed tag" | |
| 189 | + | } | |
| 190 | + | } | |
| 191 | + | return nil | |
| 192 | + | } | |
| 193 | + | ||
| 194 | + | func parsePositiveInt64(raw, label string) (int64, error) { | |
| 195 | + | id, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64) | |
| 196 | + | if err != nil || id <= 0 { | |
| 197 | + | return 0, fmt.Errorf("invalid %s", label) | |
| 198 | + | } | |
| 199 | + | return id, nil | |
| 200 | + | } | |
| 201 | + | ||
| 202 | + | func optionalFolderID(raw string) (*entity.FolderID, error) { | |
| 203 | + | raw = strings.TrimSpace(raw) | |
| 204 | + | if raw == "" || raw == "root" { | |
| 205 | + | return nil, nil | |
| 206 | + | } | |
| 207 | + | id, err := parsePositiveInt64(raw, "folder id") | |
| 208 | + | if err != nil { | |
| 209 | + | return nil, err | |
| 210 | + | } | |
| 211 | + | folderID := entity.FolderID(id) | |
| 212 | + | return &folderID, nil | |
| 213 | + | } | |
| 214 | + | ||
| 215 | + | func filterFolderID(raw string) (*entity.FolderID, error) { | |
| 216 | + | if strings.TrimSpace(raw) == "root" { | |
| 217 | + | root := entity.FolderID(0) | |
| 218 | + | return &root, nil | |
| 219 | + | } | |
| 220 | + | return optionalFolderID(raw) | |
| 221 | + | } | |
| 222 | + | ||
| 223 | + | func optionalShortcut(raw string) (*int, error) { | |
| 224 | + | raw = strings.TrimSpace(raw) | |
| 225 | + | if raw == "" { | |
| 226 | + | return nil, nil | |
| 227 | + | } | |
| 228 | + | value, err := strconv.Atoi(raw) | |
| 229 | + | if err != nil || value < 1 || value > 9 { | |
| 230 | + | return nil, fmt.Errorf("shortcut key must be 1-9") | |
| 231 | + | } | |
| 232 | + | return &value, nil | |
| 233 | + | } | |
| 234 | + | ||
| 235 | + | func favoriteDisplayTitle(favorite *entity.Favorite) string { | |
| 236 | + | if favorite == nil { | |
| 237 | + | return "" | |
| 238 | + | } | |
| 239 | + | if strings.TrimSpace(favorite.Title) != "" { | |
| 240 | + | return favorite.Title | |
| 241 | + | } | |
| 242 | + | return favorite.URL | |
| 243 | + | } | |
| @@ -0,0 +1,243 @@ | |||||
| 1 | + | package systemviews | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "fmt" | |||
| 6 | + | "strconv" | |||
| 7 | + | "strings" | |||
| 8 | + | ||||
| 9 | + | "github.com/bnema/dumber/internal/application/port" | |||
| 10 | + | "github.com/bnema/dumber/internal/domain/entity" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | const ( | |||
| 14 | + | favoriteActionCreate = "favorite.create" | |||
| 15 | + | favoriteActionUpdate = "favorite.update" | |||
| 16 | + | favoriteActionDelete = "favorite.delete" | |||
| 17 | + | favoriteActionFilterFolder = "favorite.filterFolder" | |||
| 18 | + | favoriteActionFilterTag = "favorite.filterTag" | |||
| 19 | + | favoriteActionClearFilters = "favorite.clearFilters" | |||
| 20 | + | folderActionCreate = "folder.create" | |||
| 21 | + | folderActionUpdate = "folder.update" | |||
| 22 | + | folderActionDelete = "folder.delete" | |||
| 23 | + | tagActionCreate = "tag.create" | |||
| 24 | + | tagActionUpdate = "tag.update" | |||
| 25 | + | tagActionDelete = "tag.delete" | |||
| 26 | + | tagActionAssign = "tag.assign" | |||
| 27 | + | tagActionRemove = "tag.remove" | |||
| 28 | + | ) | |||
| 29 | + | ||||
| 30 | + | func (a *App) handleFavoriteAction(ctx context.Context, event DOMAction) error { | |||
| 31 | + | if a.deps.Favorites == nil { | |||
| 32 | + | return fmt.Errorf("favorites service not configured") | |||
| 33 | + | } | |||
| 34 | + | data := event.Data | |||
| 35 | + | switch event.Action { | |||
| 36 | + | case favoriteActionCreate: | |||
| 37 | + | folderID, err := optionalFolderID(data["folder_id"]) | |||
| 38 | + | if err != nil { | |||
| 39 | + | return err | |||
| 40 | + | } | |||
| 41 | + | favorite, err := a.deps.Favorites.CreateFavorite(ctx, port.FavoriteCreateInput{ | |||
| 42 | + | URL: strings.TrimSpace(data["url"]), | |||
| 43 | + | Title: strings.TrimSpace(data["title"]), | |||
| 44 | + | FolderID: folderID, | |||
| 45 | + | }) | |||
| 46 | + | if err != nil { | |||
| 47 | + | return err | |||
| 48 | + | } | |||
| 49 | + | a.favoritesNotice = "Added favorite " + favoriteDisplayTitle(favorite) | |||
| 50 | + | case favoriteActionUpdate: | |||
| 51 | + | id, err := parsePositiveInt64(data["id"], "favorite id") | |||
| 52 | + | if err != nil { | |||
| 53 | + | return err | |||
| 54 | + | } | |||
| 55 | + | folderID, err := optionalFolderID(data["folder_id"]) | |||
| 56 | + | if err != nil { | |||
| 57 | + | return err | |||
| 58 | + | } | |||
| 59 | + | shortcut, err := optionalShortcut(data["shortcut_key"]) | |||
| 60 | + | if err != nil { | |||
| 61 | + | return err | |||
| 62 | + | } | |||
| 63 | + | favorite, err := a.deps.Favorites.UpdateFavorite(ctx, port.FavoriteUpdateInput{ | |||
| 64 | + | ID: entity.FavoriteID(id), | |||
| 65 | + | Title: strings.TrimSpace(data["title"]), | |||
| 66 | + | FaviconURL: strings.TrimSpace(data["favicon_url"]), | |||
| 67 | + | FolderID: folderID, | |||
| 68 | + | ShortcutKey: shortcut, | |||
| 69 | + | }) | |||
| 70 | + | if err != nil { | |||
| 71 | + | return err | |||
| 72 | + | } | |||
| 73 | + | a.favoritesNotice = "Saved favorite " + favoriteDisplayTitle(favorite) | |||
| 74 | + | case favoriteActionDelete: | |||
| 75 | + | id, err := parsePositiveInt64(data["id"], "favorite id") | |||
| 76 | + | if err != nil { | |||
| 77 | + | return err | |||
| 78 | + | } | |||
| 79 | + | if err := a.deps.Favorites.DeleteFavorite(ctx, id); err != nil { | |||
| 80 | + | return err | |||
| 81 | + | } | |||
| 82 | + | a.favoritesNotice = "Deleted favorite" | |||
| 83 | + | case favoriteActionFilterFolder: | |||
| 84 | + | folderID, err := filterFolderID(data["folder_id"]) | |||
| 85 | + | if err != nil { | |||
| 86 | + | return err | |||
| 87 | + | } | |||
| 88 | + | a.favoriteFolderFilter = folderID | |||
| 89 | + | a.favoriteTagFilter = nil | |||
| 90 | + | a.favoritesNotice = "" | |||
| 91 | + | case favoriteActionFilterTag: | |||
| 92 | + | tagID, err := parsePositiveInt64(data["tag_id"], "tag id") | |||
| 93 | + | if err != nil { | |||
| 94 | + | return err | |||
| 95 | + | } | |||
| 96 | + | id := entity.TagID(tagID) | |||
| 97 | + | a.favoriteTagFilter = &id | |||
| 98 | + | a.favoriteFolderFilter = nil | |||
| 99 | + | a.favoritesNotice = "" | |||
| 100 | + | case favoriteActionClearFilters: | |||
| 101 | + | a.favoriteFolderFilter = nil | |||
| 102 | + | a.favoriteTagFilter = nil | |||
| 103 | + | a.favoritesNotice = "" | |||
| 104 | + | case folderActionCreate: | |||
| 105 | + | name := strings.TrimSpace(data["name"]) | |||
| 106 | + | if name == "" { | |||
| 107 | + | return fmt.Errorf("folder name is required") | |||
| 108 | + | } | |||
| 109 | + | folder, err := a.deps.Favorites.CreateFolder(ctx, name, nil) | |||
| 110 | + | if err != nil { | |||
| 111 | + | return err | |||
| 112 | + | } | |||
| 113 | + | a.favoritesNotice = "Created folder " + folderDisplayName(folder) | |||
| 114 | + | case folderActionUpdate: | |||
| 115 | + | id, err := parsePositiveInt64(data["id"], "folder id") | |||
| 116 | + | if err != nil { | |||
| 117 | + | return err | |||
| 118 | + | } | |||
| 119 | + | name := strings.TrimSpace(data["name"]) | |||
| 120 | + | if name == "" { | |||
| 121 | + | return fmt.Errorf("folder name is required") | |||
| 122 | + | } | |||
| 123 | + | if err := a.deps.Favorites.UpdateFolder(ctx, id, name, strings.TrimSpace(data["icon"])); err != nil { | |||
| 124 | + | return err | |||
| 125 | + | } | |||
| 126 | + | a.favoritesNotice = "Saved folder " + name | |||
| 127 | + | case folderActionDelete: | |||
| 128 | + | id, err := parsePositiveInt64(data["id"], "folder id") | |||
| 129 | + | if err != nil { | |||
| 130 | + | return err | |||
| 131 | + | } | |||
| 132 | + | if err := a.deps.Favorites.DeleteFolder(ctx, id); err != nil { | |||
| 133 | + | return err | |||
| 134 | + | } | |||
| 135 | + | a.favoriteFolderFilter = nil | |||
| 136 | + | a.favoritesNotice = "Deleted folder" | |||
| 137 | + | case tagActionCreate: | |||
| 138 | + | name := strings.TrimSpace(data["name"]) | |||
| 139 | + | if name == "" { | |||
| 140 | + | return fmt.Errorf("tag name is required") | |||
| 141 | + | } | |||
| 142 | + | tag, err := a.deps.Favorites.CreateTag(ctx, name, strings.TrimSpace(data["color"])) | |||
| 143 | + | if err != nil { | |||
| 144 | + | return err | |||
| 145 | + | } | |||
| 146 | + | a.favoritesNotice = "Created tag " + tag.Name | |||
| 147 | + | case tagActionUpdate: | |||
| 148 | + | id, err := parsePositiveInt64(data["id"], "tag id") | |||
| 149 | + | if err != nil { | |||
| 150 | + | return err | |||
| 151 | + | } | |||
| 152 | + | name := strings.TrimSpace(data["name"]) | |||
| 153 | + | if name == "" { | |||
| 154 | + | return fmt.Errorf("tag name is required") | |||
| 155 | + | } | |||
| 156 | + | if err := a.deps.Favorites.UpdateTag(ctx, id, name, strings.TrimSpace(data["color"])); err != nil { | |||
| 157 | + | return err | |||
| 158 | + | } | |||
| 159 | + | a.favoritesNotice = "Saved tag " + name | |||
| 160 | + | case tagActionDelete: | |||
| 161 | + | id, err := parsePositiveInt64(data["id"], "tag id") | |||
| 162 | + | if err != nil { | |||
| 163 | + | return err | |||
| 164 | + | } | |||
| 165 | + | if err := a.deps.Favorites.DeleteTag(ctx, id); err != nil { | |||
| 166 | + | return err | |||
| 167 | + | } | |||
| 168 | + | a.favoriteTagFilter = nil | |||
| 169 | + | a.favoritesNotice = "Deleted tag" | |||
| 170 | + | case tagActionAssign, tagActionRemove: | |||
| 171 | + | favoriteID, err := parsePositiveInt64(data["favoriteId"], "favorite id") | |||
| 172 | + | if err != nil { | |||
| 173 | + | return err | |||
| 174 | + | } | |||
| 175 | + | tagID, err := parsePositiveInt64(data["tagId"], "tag id") | |||
| 176 | + | if err != nil { | |||
| 177 | + | return err | |||
| 178 | + | } | |||
| 179 | + | if event.Action == tagActionAssign { | |||
| 180 | + | if err := a.deps.Favorites.AssignTag(ctx, favoriteID, tagID); err != nil { | |||
| 181 | + | return err | |||
| 182 | + | } | |||
| 183 | + | a.favoritesNotice = "Assigned tag" | |||
| 184 | + | } else { | |||
| 185 | + | if err := a.deps.Favorites.RemoveTag(ctx, favoriteID, tagID); err != nil { | |||
| 186 | + | return err | |||
| 187 | + | } | |||
| 188 | + | a.favoritesNotice = "Removed tag" | |||
| 189 | + | } | |||
| 190 | + | } | |||
| 191 | + | return nil | |||
| 192 | + | } | |||
| 193 | + | ||||
| 194 | + | func parsePositiveInt64(raw, label string) (int64, error) { | |||
| 195 | + | id, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64) | |||
| 196 | + | if err != nil || id <= 0 { | |||
| 197 | + | return 0, fmt.Errorf("invalid %s", label) | |||
| 198 | + | } | |||
| 199 | + | return id, nil | |||
| 200 | + | } | |||
| 201 | + | ||||
| 202 | + | func optionalFolderID(raw string) (*entity.FolderID, error) { | |||
| 203 | + | raw = strings.TrimSpace(raw) | |||
| 204 | + | if raw == "" || raw == "root" { | |||
| 205 | + | return nil, nil | |||
| 206 | + | } | |||
| 207 | + | id, err := parsePositiveInt64(raw, "folder id") | |||
| 208 | + | if err != nil { | |||
| 209 | + | return nil, err | |||
| 210 | + | } | |||
| 211 | + | folderID := entity.FolderID(id) | |||
| 212 | + | return &folderID, nil | |||
| 213 | + | } | |||
| 214 | + | ||||
| 215 | + | func filterFolderID(raw string) (*entity.FolderID, error) { | |||
| 216 | + | if strings.TrimSpace(raw) == "root" { | |||
| 217 | + | root := entity.FolderID(0) | |||
| 218 | + | return &root, nil | |||
| 219 | + | } | |||
| 220 | + | return optionalFolderID(raw) | |||
| 221 | + | } | |||
| 222 | + | ||||
| 223 | + | func optionalShortcut(raw string) (*int, error) { | |||
| 224 | + | raw = strings.TrimSpace(raw) | |||
| 225 | + | if raw == "" { | |||
| 226 | + | return nil, nil | |||
| 227 | + | } | |||
| 228 | + | value, err := strconv.Atoi(raw) | |||
| 229 | + | if err != nil || value < 1 || value > 9 { | |||
| 230 | + | return nil, fmt.Errorf("shortcut key must be 1-9") | |||
| 231 | + | } | |||
| 232 | + | return &value, nil | |||
| 233 | + | } | |||
| 234 | + | ||||
| 235 | + | func favoriteDisplayTitle(favorite *entity.Favorite) string { | |||
| 236 | + | if favorite == nil { | |||
| 237 | + | return "" | |||
| 238 | + | } | |||
| 239 | + | if strings.TrimSpace(favorite.Title) != "" { | |||
| 240 | + | return favorite.Title | |||
| 241 | + | } | |||
| 242 | + | return favorite.URL | |||
| 243 | + | } | |||
M internal/ui/systemviews/favorites_view.go +265 -15
| @@ -8,51 +8,301 @@ import ( | |||
| 8 | 8 | "github.com/bnema/dumber/internal/domain/entity" | |
| 9 | 9 | ) | |
| 10 | 10 | ||
| 11 | - | func favoritesHTML(favorites []*entity.Favorite, folders []*entity.Folder, tags []*entity.Tag) string { | |
| 12 | - | favoritesSummary := fmt.Sprintf("%d favorites · %d folders · %d tags", len(favorites), len(folders), len(tags)) | |
| 13 | - | foldersSummary := fmt.Sprintf("%d folders", len(folders)) | |
| 14 | - | tagsSummary := fmt.Sprintf("%d tags", len(tags)) | |
| 11 | + | type favoritesRenderData struct { | |
| 12 | + | Favorites []*entity.Favorite | |
| 13 | + | Folders []*entity.Folder | |
| 14 | + | Tags []*entity.Tag | |
| 15 | + | FolderFilter *entity.FolderID | |
| 16 | + | TagFilter *entity.TagID | |
| 17 | + | Notice string | |
| 18 | + | Error string | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | func favoritesHTML(data favoritesRenderData) string { | |
| 22 | + | favoritesSummary := fmt.Sprintf("%d favorites · %d folders · %d tags", len(data.Favorites), len(data.Folders), len(data.Tags)) | |
| 23 | + | foldersSummary := fmt.Sprintf("%d folders", len(data.Folders)) | |
| 24 | + | tagsSummary := fmt.Sprintf("%d tags", len(data.Tags)) | |
| 15 | 25 | sections := []string{ | |
| 16 | - | sectionHTML("", "Favorites", metaHTML(favoritesSummary)+listHTML(favoriteItemsHTML(favorites), "No favorites")), | |
| 17 | - | sectionHTML("", "Folders", metaHTML(foldersSummary)+listHTML(folderItemsHTML(folders), "No folders")), | |
| 18 | - | sectionHTML("", "Tags", metaHTML(tagsSummary)+listHTML(tagItemsHTML(tags), "No tags")), | |
| 26 | + | favoritesStatusHTML(data), | |
| 27 | + | sectionHTML("sv-favorites-controls", "Manage favorites", favoritesControlsHTML(data)), | |
| 28 | + | sectionHTML("sv-favorites-list", "Favorites", metaHTML(favoritesSummary)+listHTML(favoriteItemsHTML(data.Favorites, data.Folders, data.Tags), "No favorites")), | |
| 29 | + | sectionHTML("sv-folders-list", "Folders", metaHTML(foldersSummary)+folderCreateHTML()+listHTML(folderItemsHTML(data.Folders), "No folders")), | |
| 30 | + | sectionHTML("sv-tags-list", "Tags", metaHTML(tagsSummary)+tagCreateHTML()+listHTML(tagItemsHTML(data.Tags), "No tags")), | |
| 19 | 31 | } | |
| 20 | 32 | return strings.Join(sections, "") | |
| 21 | 33 | } | |
| 22 | 34 | ||
| 23 | - | func favoriteItemsHTML(favorites []*entity.Favorite) string { | |
| 35 | + | func favoritesStatusHTML(data favoritesRenderData) string { | |
| 36 | + | var out strings.Builder | |
| 37 | + | if strings.TrimSpace(data.Notice) != "" { | |
| 38 | + | out.WriteString(fmt.Sprintf(`<div class="sv-alert sv-alert-success" role="status">%s</div>`, html.EscapeString(data.Notice))) | |
| 39 | + | } | |
| 40 | + | if strings.TrimSpace(data.Error) != "" { | |
| 41 | + | out.WriteString(fmt.Sprintf(`<div class="sv-alert sv-alert-error" role="alert">%s</div>`, html.EscapeString(data.Error))) | |
| 42 | + | } | |
| 43 | + | return out.String() | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | func favoritesControlsHTML(data favoritesRenderData) string { | |
| 47 | + | var out strings.Builder | |
| 48 | + | out.WriteString(`<form class="sv-card-form" data-sv-action="favorite.create">`) | |
| 49 | + | out.WriteString(`<label><span>URL</span><input name="url" type="url" placeholder="https://example.com" required></label>`) | |
| 50 | + | out.WriteString(`<label><span>Title</span><input name="title" type="text" placeholder="Optional title"></label>`) | |
| 51 | + | out.WriteString(folderSelectHTML("folder_id", data.Folders, nil, "Folder")) | |
| 52 | + | out.WriteString(`<button class="sv-button" type="submit">Add favorite</button>`) | |
| 53 | + | out.WriteString(`</form>`) | |
| 54 | + | ||
| 55 | + | out.WriteString(`<div class="sv-filter-panel">`) | |
| 56 | + | out.WriteString(`<div class="sv-button-row"><span class="sv-meta">Folders</span>`) | |
| 57 | + | out.WriteString(favoriteFilterButton("All", "favorite.clearFilters", nil, "", data.FolderFilter == nil && data.TagFilter == nil)) | |
| 58 | + | out.WriteString(favoriteFilterButton("Unfiled", "favorite.filterFolder", map[string]string{"folder_id": "root"}, "", data.FolderFilter != nil && *data.FolderFilter == 0)) | |
| 59 | + | for _, folder := range data.Folders { | |
| 60 | + | if folder == nil { | |
| 61 | + | continue | |
| 62 | + | } | |
| 63 | + | active := data.FolderFilter != nil && *data.FolderFilter == folder.ID | |
| 64 | + | out.WriteString(favoriteFilterButton(folderDisplayName(folder), "favorite.filterFolder", map[string]string{"folder_id": fmt.Sprintf("%d", folder.ID)}, "", active)) | |
| 65 | + | } | |
| 66 | + | out.WriteString(`</div>`) | |
| 67 | + | ||
| 68 | + | out.WriteString(`<div class="sv-button-row"><span class="sv-meta">Tags</span>`) | |
| 69 | + | for _, tag := range data.Tags { | |
| 70 | + | if tag == nil { | |
| 71 | + | continue | |
| 72 | + | } | |
| 73 | + | active := data.TagFilter != nil && *data.TagFilter == tag.ID | |
| 74 | + | out.WriteString(favoriteFilterButton(tag.Name, "favorite.filterTag", map[string]string{"tag_id": fmt.Sprintf("%d", tag.ID)}, tag.Color, active)) | |
| 75 | + | } | |
| 76 | + | out.WriteString(`</div></div>`) | |
| 77 | + | out.WriteString(`<p class="sv-meta sv-key-hints">Keys: <kbd>j</kbd>/<kbd>k</kbd> move, <kbd>Enter</kbd> open, <kbd>e</kbd> edit title, <kbd>d</kbd> delete focused favorite.</p>`) | |
| 78 | + | return out.String() | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | func favoriteFilterButton(label, action string, data map[string]string, color string, active bool) string { | |
| 82 | + | classes := "sv-button sv-button-secondary" | |
| 83 | + | if active { | |
| 84 | + | classes += " sv-button-active" | |
| 85 | + | } | |
| 86 | + | attrs := []string{`type="button"`, fmt.Sprintf(`class="%s"`, html.EscapeString(classes)), fmt.Sprintf(`data-sv-action="%s"`, html.EscapeString(action))} | |
| 87 | + | for key, value := range data { | |
| 88 | + | attrs = append(attrs, fmt.Sprintf(`data-%s="%s"`, html.EscapeString(kebabDataKey(key)), html.EscapeString(value))) | |
| 89 | + | } | |
| 90 | + | style := "" | |
| 91 | + | if strings.TrimSpace(color) != "" { | |
| 92 | + | style = fmt.Sprintf(`<span class="sv-tag-dot" style="background:%s"></span>`, html.EscapeString(color)) | |
| 93 | + | } | |
| 94 | + | return fmt.Sprintf(`<button %s>%s%s</button>`, strings.Join(attrs, " "), style, html.EscapeString(label)) | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | func favoriteItemsHTML(favorites []*entity.Favorite, folders []*entity.Folder, tags []*entity.Tag) string { | |
| 24 | 98 | var items strings.Builder | |
| 25 | 99 | for _, favorite := range favorites { | |
| 26 | 100 | if favorite == nil { | |
| 27 | 101 | continue | |
| 28 | 102 | } | |
| 29 | - | label := favorite.Title | |
| 30 | - | if label == "" { | |
| 31 | - | label = favorite.URL | |
| 32 | - | } | |
| 33 | - | items.WriteString(listRowHTML(linkHTML(favorite.URL, label))) | |
| 103 | + | items.WriteString(listRowHTML(favoriteItemHTML(favorite, folders, tags))) | |
| 34 | 104 | } | |
| 35 | 105 | return items.String() | |
| 36 | 106 | } | |
| 37 | 107 | ||
| 108 | + | func favoriteItemHTML(favorite *entity.Favorite, folders []*entity.Folder, tags []*entity.Tag) string { | |
| 109 | + | label := strings.TrimSpace(favorite.Title) | |
| 110 | + | if label == "" { | |
| 111 | + | label = favorite.URL | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | return fmt.Sprintf(`<article class="sv-favorite-item" data-sv-favorite-row data-favorite-id="%d"><div class="sv-favorite-header"><div><a class="sv-link sv-favorite-open" href="%s">%s</a><p class="sv-history-url">%s</p>%s</div><div class="sv-history-row-actions"><a class="sv-button sv-button-secondary" href="%s">Open</a><button type="button" class="sv-button sv-button-secondary sv-button-danger" data-sv-action="favorite.delete" data-id="%d" data-sv-confirm="Delete this favorite?">Delete</button></div></div>%s%s</article>`, | |
| 115 | + | favorite.ID, | |
| 116 | + | html.EscapeString(sanitizeHref(favorite.URL)), | |
| 117 | + | html.EscapeString(label), | |
| 118 | + | html.EscapeString(favorite.URL), | |
| 119 | + | favoriteMetaHTML(favorite, folders), | |
| 120 | + | html.EscapeString(sanitizeHref(favorite.URL)), | |
| 121 | + | favorite.ID, | |
| 122 | + | favoriteEditHTML(favorite, folders), | |
| 123 | + | favoriteTagControlsHTML(favorite, tags), | |
| 124 | + | ) | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | func favoriteMetaHTML(favorite *entity.Favorite, folders []*entity.Folder) string { | |
| 128 | + | parts := []string{} | |
| 129 | + | if favorite.ShortcutKey != nil { | |
| 130 | + | parts = append(parts, fmt.Sprintf("Shortcut %d", *favorite.ShortcutKey)) | |
| 131 | + | } | |
| 132 | + | if name := folderNameForID(folders, favorite.FolderID); name != "" { | |
| 133 | + | parts = append(parts, "Folder "+name) | |
| 134 | + | } | |
| 135 | + | if len(parts) == 0 { | |
| 136 | + | return "" | |
| 137 | + | } | |
| 138 | + | return `<p class="sv-meta">` + html.EscapeString(strings.Join(parts, " · ")) + `</p>` | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | func favoriteEditHTML(favorite *entity.Favorite, folders []*entity.Folder) string { | |
| 142 | + | return fmt.Sprintf(`<form class="sv-inline-form" data-sv-action="favorite.update"><input type="hidden" name="id" value="%d"><label><span>Title</span><input name="title" type="text" value="%s"></label><label><span>Favicon</span><input name="favicon_url" type="url" value="%s"></label>%s%s<button class="sv-button sv-button-secondary" type="submit">Save</button></form>`, | |
| 143 | + | favorite.ID, | |
| 144 | + | html.EscapeString(favorite.Title), | |
| 145 | + | html.EscapeString(favorite.FaviconURL), | |
| 146 | + | folderSelectHTML("folder_id", folders, favorite.FolderID, "Folder"), | |
| 147 | + | shortcutSelectHTML(favorite.ShortcutKey), | |
| 148 | + | ) | |
| 149 | + | } | |
| 150 | + | ||
| 151 | + | func favoriteTagControlsHTML(favorite *entity.Favorite, tags []*entity.Tag) string { | |
| 152 | + | if len(tags) == 0 { | |
| 153 | + | return "" | |
| 154 | + | } | |
| 155 | + | var out strings.Builder | |
| 156 | + | out.WriteString(`<div class="sv-tag-controls"><span class="sv-meta">Tags</span>`) | |
| 157 | + | for _, tag := range tags { | |
| 158 | + | if tag == nil { | |
| 159 | + | continue | |
| 160 | + | } | |
| 161 | + | action := "tag.assign" | |
| 162 | + | label := "+ " + tag.Name | |
| 163 | + | classes := "sv-button sv-button-secondary" | |
| 164 | + | if favorite.HasTag(tag.ID) { | |
| 165 | + | action = "tag.remove" | |
| 166 | + | label = "✓ " + tag.Name | |
| 167 | + | classes += " sv-button-active" | |
| 168 | + | } | |
| 169 | + | out.WriteString(fmt.Sprintf(`<button type="button" class="%s" data-sv-action="%s" data-favorite-id="%d" data-tag-id="%d"><span class="sv-tag-dot" style="background:%s"></span>%s</button>`, | |
| 170 | + | html.EscapeString(classes), | |
| 171 | + | html.EscapeString(action), | |
| 172 | + | favorite.ID, | |
| 173 | + | tag.ID, | |
| 174 | + | html.EscapeString(tag.Color), | |
| 175 | + | html.EscapeString(label), | |
| 176 | + | )) | |
| 177 | + | } | |
| 178 | + | out.WriteString(`</div>`) | |
| 179 | + | return out.String() | |
| 180 | + | } | |
| 181 | + | ||
| 182 | + | func folderCreateHTML() string { | |
| 183 | + | return `<form class="sv-inline-form" data-sv-action="folder.create"><label><span>Name</span><input name="name" type="text" required></label><label><span>Icon</span><input name="icon" type="text" placeholder="Optional"></label><button class="sv-button sv-button-secondary" type="submit">Create folder</button></form>` | |
| 184 | + | } | |
| 185 | + | ||
| 38 | 186 | func folderItemsHTML(folders []*entity.Folder) string { | |
| 39 | 187 | var items strings.Builder | |
| 40 | 188 | for _, folder := range folders { | |
| 41 | 189 | if folder == nil { | |
| 42 | 190 | continue | |
| 43 | 191 | } | |
| 44 | - | items.WriteString(listRowHTML(html.EscapeString(folder.Name))) | |
| 192 | + | items.WriteString(listRowHTML(folderItemHTML(folder))) | |
| 45 | 193 | } | |
| 46 | 194 | return items.String() | |
| 47 | 195 | } | |
| 48 | 196 | ||
| 197 | + | func folderItemHTML(folder *entity.Folder) string { | |
| 198 | + | return fmt.Sprintf(`<form class="sv-inline-form" data-sv-action="folder.update"><input type="hidden" name="id" value="%d"><label><span>Name</span><input name="name" type="text" value="%s" required></label><label><span>Icon</span><input name="icon" type="text" value="%s"></label><button class="sv-button sv-button-secondary" type="submit">Save</button><button type="button" class="sv-button sv-button-secondary sv-button-danger" data-sv-action="folder.delete" data-id="%d" data-sv-confirm="Delete this folder? Favorites will move out of it.">Delete</button></form>`, | |
| 199 | + | folder.ID, | |
| 200 | + | html.EscapeString(folder.Name), | |
| 201 | + | html.EscapeString(folder.Icon), | |
| 202 | + | folder.ID, | |
| 203 | + | ) | |
| 204 | + | } | |
| 205 | + | ||
| 206 | + | func tagCreateHTML() string { | |
| 207 | + | return `<form class="sv-inline-form" data-sv-action="tag.create"><label><span>Name</span><input name="name" type="text" required></label><label><span>Color</span><input name="color" type="text" placeholder="#808080"></label><button class="sv-button sv-button-secondary" type="submit">Create tag</button></form>` | |
| 208 | + | } | |
| 209 | + | ||
| 49 | 210 | func tagItemsHTML(tags []*entity.Tag) string { | |
| 50 | 211 | var items strings.Builder | |
| 51 | 212 | for _, tag := range tags { | |
| 52 | 213 | if tag == nil { | |
| 53 | 214 | continue | |
| 54 | 215 | } | |
| 55 | - | items.WriteString(listRowHTML(html.EscapeString(tag.Name))) | |
| 216 | + | items.WriteString(listRowHTML(tagItemHTML(tag))) | |
| 56 | 217 | } | |
| 57 | 218 | return items.String() | |
| 58 | 219 | } | |
| 220 | + | ||
| 221 | + | func tagItemHTML(tag *entity.Tag) string { | |
| 222 | + | return fmt.Sprintf(`<form class="sv-inline-form" data-sv-action="tag.update"><input type="hidden" name="id" value="%d"><span class="sv-tag-dot" style="background:%s"></span><label><span>Name</span><input name="name" type="text" value="%s" required></label><label><span>Color</span><input name="color" type="text" value="%s"></label><button class="sv-button sv-button-secondary" type="submit">Save</button><button type="button" class="sv-button sv-button-secondary sv-button-danger" data-sv-action="tag.delete" data-id="%d" data-sv-confirm="Delete this tag?">Delete</button></form>`, | |
| 223 | + | tag.ID, | |
| 224 | + | html.EscapeString(tag.Color), | |
| 225 | + | html.EscapeString(tag.Name), | |
| 226 | + | html.EscapeString(tag.Color), | |
| 227 | + | tag.ID, | |
| 228 | + | ) | |
| 229 | + | } | |
| 230 | + | ||
| 231 | + | func folderSelectHTML(name string, folders []*entity.Folder, selected *entity.FolderID, label string) string { | |
| 232 | + | var out strings.Builder | |
| 233 | + | out.WriteString(fmt.Sprintf(`<label><span>%s</span><select name="%s"><option value="">Unfiled</option>`, html.EscapeString(label), html.EscapeString(name))) | |
| 234 | + | for _, folder := range folders { | |
| 235 | + | if folder == nil { | |
| 236 | + | continue | |
| 237 | + | } | |
| 238 | + | selectedAttr := "" | |
| 239 | + | if selected != nil && *selected == folder.ID { | |
| 240 | + | selectedAttr = ` selected` | |
| 241 | + | } | |
| 242 | + | out.WriteString(fmt.Sprintf(`<option value="%d"%s>%s</option>`, folder.ID, selectedAttr, html.EscapeString(folderDisplayName(folder)))) | |
| 243 | + | } | |
| 244 | + | out.WriteString(`</select></label>`) | |
| 245 | + | return out.String() | |
| 246 | + | } | |
| 247 | + | ||
| 248 | + | func shortcutSelectHTML(selected *int) string { | |
| 249 | + | var out strings.Builder | |
| 250 | + | out.WriteString(`<label><span>Shortcut</span><select name="shortcut_key"><option value="">None</option>`) | |
| 251 | + | for i := 1; i <= 9; i++ { | |
| 252 | + | selectedAttr := "" | |
| 253 | + | if selected != nil && *selected == i { | |
| 254 | + | selectedAttr = ` selected` | |
| 255 | + | } | |
| 256 | + | out.WriteString(fmt.Sprintf(`<option value="%d"%s>%d</option>`, i, selectedAttr, i)) | |
| 257 | + | } | |
| 258 | + | out.WriteString(`</select></label>`) | |
| 259 | + | return out.String() | |
| 260 | + | } | |
| 261 | + | ||
| 262 | + | func folderDisplayName(folder *entity.Folder) string { | |
| 263 | + | if folder == nil { | |
| 264 | + | return "" | |
| 265 | + | } | |
| 266 | + | if strings.TrimSpace(folder.Icon) != "" { | |
| 267 | + | return folder.Icon + " " + folder.Name | |
| 268 | + | } | |
| 269 | + | return folder.Name | |
| 270 | + | } | |
| 271 | + | ||
| 272 | + | func folderNameForID(folders []*entity.Folder, id *entity.FolderID) string { | |
| 273 | + | if id == nil { | |
| 274 | + | return "Unfiled" | |
| 275 | + | } | |
| 276 | + | for _, folder := range folders { | |
| 277 | + | if folder != nil && folder.ID == *id { | |
| 278 | + | return folderDisplayName(folder) | |
| 279 | + | } | |
| 280 | + | } | |
| 281 | + | return "Unknown" | |
| 282 | + | } | |
| 283 | + | ||
| 284 | + | func filterFavorites(favorites []*entity.Favorite, folderID *entity.FolderID, tagID *entity.TagID) []*entity.Favorite { | |
| 285 | + | if folderID == nil && tagID == nil { | |
| 286 | + | return favorites | |
| 287 | + | } | |
| 288 | + | out := make([]*entity.Favorite, 0, len(favorites)) | |
| 289 | + | for _, favorite := range favorites { | |
| 290 | + | if favorite == nil { | |
| 291 | + | continue | |
| 292 | + | } | |
| 293 | + | if folderID != nil { | |
| 294 | + | if *folderID == 0 { | |
| 295 | + | if favorite.FolderID != nil { | |
| 296 | + | continue | |
| 297 | + | } | |
| 298 | + | } else if favorite.FolderID == nil || *favorite.FolderID != *folderID { | |
| 299 | + | continue | |
| 300 | + | } | |
| 301 | + | } | |
| 302 | + | if tagID != nil && !favorite.HasTag(*tagID) { | |
| 303 | + | continue | |
| 304 | + | } | |
| 305 | + | out = append(out, favorite) | |
| 306 | + | } | |
| 307 | + | return out | |
| 308 | + | } | |
| @@ -8,51 +8,301 @@ import ( | |||||
| 8 | "github.com/bnema/dumber/internal/domain/entity" | 8 | "github.com/bnema/dumber/internal/domain/entity" | ||
| 9 | ) | 9 | ) | ||
| 10 | 10 | ||||
| 11 | - | func favoritesHTML(favorites []*entity.Favorite, folders []*entity.Folder, tags []*entity.Tag) string { | 11 | + | type favoritesRenderData struct { |
| 12 | - | favoritesSummary := fmt.Sprintf("%d favorites · %d folders · %d tags", len(favorites), len(folders), len(tags)) | 12 | + | Favorites []*entity.Favorite |
| 13 | - | foldersSummary := fmt.Sprintf("%d folders", len(folders)) | 13 | + | Folders []*entity.Folder |
| 14 | - | tagsSummary := fmt.Sprintf("%d tags", len(tags)) | 14 | + | Tags []*entity.Tag |
| 15 | + | FolderFilter *entity.FolderID | |||
| 16 | + | TagFilter *entity.TagID | |||
| 17 | + | Notice string | |||
| 18 | + | Error string | |||
| 19 | + | } | |||
| 20 | + | ||||
| 21 | + | func favoritesHTML(data favoritesRenderData) string { | |||
| 22 | + | favoritesSummary := fmt.Sprintf("%d favorites · %d folders · %d tags", len(data.Favorites), len(data.Folders), len(data.Tags)) | |||
| 23 | + | foldersSummary := fmt.Sprintf("%d folders", len(data.Folders)) | |||
| 24 | + | tagsSummary := fmt.Sprintf("%d tags", len(data.Tags)) | |||
| 15 | sections := []string{ | 25 | sections := []string{ | ||
| 16 | - | sectionHTML("", "Favorites", metaHTML(favoritesSummary)+listHTML(favoriteItemsHTML(favorites), "No favorites")), | 26 | + | favoritesStatusHTML(data), |
| 17 | - | sectionHTML("", "Folders", metaHTML(foldersSummary)+listHTML(folderItemsHTML(folders), "No folders")), | 27 | + | sectionHTML("sv-favorites-controls", "Manage favorites", favoritesControlsHTML(data)), |
| 18 | - | sectionHTML("", "Tags", metaHTML(tagsSummary)+listHTML(tagItemsHTML(tags), "No tags")), | 28 | + | sectionHTML("sv-favorites-list", "Favorites", metaHTML(favoritesSummary)+listHTML(favoriteItemsHTML(data.Favorites, data.Folders, data.Tags), "No favorites")), |
| 29 | + | sectionHTML("sv-folders-list", "Folders", metaHTML(foldersSummary)+folderCreateHTML()+listHTML(folderItemsHTML(data.Folders), "No folders")), | |||
| 30 | + | sectionHTML("sv-tags-list", "Tags", metaHTML(tagsSummary)+tagCreateHTML()+listHTML(tagItemsHTML(data.Tags), "No tags")), | |||
| 19 | } | 31 | } | ||
| 20 | return strings.Join(sections, "") | 32 | return strings.Join(sections, "") | ||
| 21 | } | 33 | } | ||
| 22 | 34 | ||||
| 23 | - | func favoriteItemsHTML(favorites []*entity.Favorite) string { | 35 | + | func favoritesStatusHTML(data favoritesRenderData) string { |
| 36 | + | var out strings.Builder | |||
| 37 | + | if strings.TrimSpace(data.Notice) != "" { | |||
| 38 | + | out.WriteString(fmt.Sprintf(`<div class="sv-alert sv-alert-success" role="status">%s</div>`, html.EscapeString(data.Notice))) | |||
| 39 | + | } | |||
| 40 | + | if strings.TrimSpace(data.Error) != "" { | |||
| 41 | + | out.WriteString(fmt.Sprintf(`<div class="sv-alert sv-alert-error" role="alert">%s</div>`, html.EscapeString(data.Error))) | |||
| 42 | + | } | |||
| 43 | + | return out.String() | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | func favoritesControlsHTML(data favoritesRenderData) string { | |||
| 47 | + | var out strings.Builder | |||
| 48 | + | out.WriteString(`<form class="sv-card-form" data-sv-action="favorite.create">`) | |||
| 49 | + | out.WriteString(`<label><span>URL</span><input name="url" type="url" placeholder="https://example.com" required></label>`) | |||
| 50 | + | out.WriteString(`<label><span>Title</span><input name="title" type="text" placeholder="Optional title"></label>`) | |||
| 51 | + | out.WriteString(folderSelectHTML("folder_id", data.Folders, nil, "Folder")) | |||
| 52 | + | out.WriteString(`<button class="sv-button" type="submit">Add favorite</button>`) | |||
| 53 | + | out.WriteString(`</form>`) | |||
| 54 | + | ||||
| 55 | + | out.WriteString(`<div class="sv-filter-panel">`) | |||
| 56 | + | out.WriteString(`<div class="sv-button-row"><span class="sv-meta">Folders</span>`) | |||
| 57 | + | out.WriteString(favoriteFilterButton("All", "favorite.clearFilters", nil, "", data.FolderFilter == nil && data.TagFilter == nil)) | |||
| 58 | + | out.WriteString(favoriteFilterButton("Unfiled", "favorite.filterFolder", map[string]string{"folder_id": "root"}, "", data.FolderFilter != nil && *data.FolderFilter == 0)) | |||
| 59 | + | for _, folder := range data.Folders { | |||
| 60 | + | if folder == nil { | |||
| 61 | + | continue | |||
| 62 | + | } | |||
| 63 | + | active := data.FolderFilter != nil && *data.FolderFilter == folder.ID | |||
| 64 | + | out.WriteString(favoriteFilterButton(folderDisplayName(folder), "favorite.filterFolder", map[string]string{"folder_id": fmt.Sprintf("%d", folder.ID)}, "", active)) | |||
| 65 | + | } | |||
| 66 | + | out.WriteString(`</div>`) | |||
| 67 | + | ||||
| 68 | + | out.WriteString(`<div class="sv-button-row"><span class="sv-meta">Tags</span>`) | |||
| 69 | + | for _, tag := range data.Tags { | |||
| 70 | + | if tag == nil { | |||
| 71 | + | continue | |||
| 72 | + | } | |||
| 73 | + | active := data.TagFilter != nil && *data.TagFilter == tag.ID | |||
| 74 | + | out.WriteString(favoriteFilterButton(tag.Name, "favorite.filterTag", map[string]string{"tag_id": fmt.Sprintf("%d", tag.ID)}, tag.Color, active)) | |||
| 75 | + | } | |||
| 76 | + | out.WriteString(`</div></div>`) | |||
| 77 | + | out.WriteString(`<p class="sv-meta sv-key-hints">Keys: <kbd>j</kbd>/<kbd>k</kbd> move, <kbd>Enter</kbd> open, <kbd>e</kbd> edit title, <kbd>d</kbd> delete focused favorite.</p>`) | |||
| 78 | + | return out.String() | |||
| 79 | + | } | |||
| 80 | + | ||||
| 81 | + | func favoriteFilterButton(label, action string, data map[string]string, color string, active bool) string { | |||
| 82 | + | classes := "sv-button sv-button-secondary" | |||
| 83 | + | if active { | |||
| 84 | + | classes += " sv-button-active" | |||
| 85 | + | } | |||
| 86 | + | attrs := []string{`type="button"`, fmt.Sprintf(`class="%s"`, html.EscapeString(classes)), fmt.Sprintf(`data-sv-action="%s"`, html.EscapeString(action))} | |||
| 87 | + | for key, value := range data { | |||
| 88 | + | attrs = append(attrs, fmt.Sprintf(`data-%s="%s"`, html.EscapeString(kebabDataKey(key)), html.EscapeString(value))) | |||
| 89 | + | } | |||
| 90 | + | style := "" | |||
| 91 | + | if strings.TrimSpace(color) != "" { | |||
| 92 | + | style = fmt.Sprintf(`<span class="sv-tag-dot" style="background:%s"></span>`, html.EscapeString(color)) | |||
| 93 | + | } | |||
| 94 | + | return fmt.Sprintf(`<button %s>%s%s</button>`, strings.Join(attrs, " "), style, html.EscapeString(label)) | |||
| 95 | + | } | |||
| 96 | + | ||||
| 97 | + | func favoriteItemsHTML(favorites []*entity.Favorite, folders []*entity.Folder, tags []*entity.Tag) string { | |||
| 24 | var items strings.Builder | 98 | var items strings.Builder | ||
| 25 | for _, favorite := range favorites { | 99 | for _, favorite := range favorites { | ||
| 26 | if favorite == nil { | 100 | if favorite == nil { | ||
| 27 | continue | 101 | continue | ||
| 28 | } | 102 | } | ||
| 29 | - | label := favorite.Title | 103 | + | items.WriteString(listRowHTML(favoriteItemHTML(favorite, folders, tags))) |
| 30 | - | if label == "" { | |||
| 31 | - | label = favorite.URL | |||
| 32 | - | } | |||
| 33 | - | items.WriteString(listRowHTML(linkHTML(favorite.URL, label))) | |||
| 34 | } | 104 | } | ||
| 35 | return items.String() | 105 | return items.String() | ||
| 36 | } | 106 | } | ||
| 37 | 107 | ||||
| 108 | + | func favoriteItemHTML(favorite *entity.Favorite, folders []*entity.Folder, tags []*entity.Tag) string { | |||
| 109 | + | label := strings.TrimSpace(favorite.Title) | |||
| 110 | + | if label == "" { | |||
| 111 | + | label = favorite.URL | |||
| 112 | + | } | |||
| 113 | + | ||||
| 114 | + | return fmt.Sprintf(`<article class="sv-favorite-item" data-sv-favorite-row data-favorite-id="%d"><div class="sv-favorite-header"><div><a class="sv-link sv-favorite-open" href="%s">%s</a><p class="sv-history-url">%s</p>%s</div><div class="sv-history-row-actions"><a class="sv-button sv-button-secondary" href="%s">Open</a><button type="button" class="sv-button sv-button-secondary sv-button-danger" data-sv-action="favorite.delete" data-id="%d" data-sv-confirm="Delete this favorite?">Delete</button></div></div>%s%s</article>`, | |||
| 115 | + | favorite.ID, | |||
| 116 | + | html.EscapeString(sanitizeHref(favorite.URL)), | |||
| 117 | + | html.EscapeString(label), | |||
| 118 | + | html.EscapeString(favorite.URL), | |||
| 119 | + | favoriteMetaHTML(favorite, folders), | |||
| 120 | + | html.EscapeString(sanitizeHref(favorite.URL)), | |||
| 121 | + | favorite.ID, | |||
| 122 | + | favoriteEditHTML(favorite, folders), | |||
| 123 | + | favoriteTagControlsHTML(favorite, tags), | |||
| 124 | + | ) | |||
| 125 | + | } | |||
| 126 | + | ||||
| 127 | + | func favoriteMetaHTML(favorite *entity.Favorite, folders []*entity.Folder) string { | |||
| 128 | + | parts := []string{} | |||
| 129 | + | if favorite.ShortcutKey != nil { | |||
| 130 | + | parts = append(parts, fmt.Sprintf("Shortcut %d", *favorite.ShortcutKey)) | |||
| 131 | + | } | |||
| 132 | + | if name := folderNameForID(folders, favorite.FolderID); name != "" { | |||
| 133 | + | parts = append(parts, "Folder "+name) | |||
| 134 | + | } | |||
| 135 | + | if len(parts) == 0 { | |||
| 136 | + | return "" | |||
| 137 | + | } | |||
| 138 | + | return `<p class="sv-meta">` + html.EscapeString(strings.Join(parts, " · ")) + `</p>` | |||
| 139 | + | } | |||
| 140 | + | ||||
| 141 | + | func favoriteEditHTML(favorite *entity.Favorite, folders []*entity.Folder) string { | |||
| 142 | + | return fmt.Sprintf(`<form class="sv-inline-form" data-sv-action="favorite.update"><input type="hidden" name="id" value="%d"><label><span>Title</span><input name="title" type="text" value="%s"></label><label><span>Favicon</span><input name="favicon_url" type="url" value="%s"></label>%s%s<button class="sv-button sv-button-secondary" type="submit">Save</button></form>`, | |||
| 143 | + | favorite.ID, | |||
| 144 | + | html.EscapeString(favorite.Title), | |||
| 145 | + | html.EscapeString(favorite.FaviconURL), | |||
| 146 | + | folderSelectHTML("folder_id", folders, favorite.FolderID, "Folder"), | |||
| 147 | + | shortcutSelectHTML(favorite.ShortcutKey), | |||
| 148 | + | ) | |||
| 149 | + | } | |||
| 150 | + | ||||
| 151 | + | func favoriteTagControlsHTML(favorite *entity.Favorite, tags []*entity.Tag) string { | |||
| 152 | + | if len(tags) == 0 { | |||
| 153 | + | return "" | |||
| 154 | + | } | |||
| 155 | + | var out strings.Builder | |||
| 156 | + | out.WriteString(`<div class="sv-tag-controls"><span class="sv-meta">Tags</span>`) | |||
| 157 | + | for _, tag := range tags { | |||
| 158 | + | if tag == nil { | |||
| 159 | + | continue | |||
| 160 | + | } | |||
| 161 | + | action := "tag.assign" | |||
| 162 | + | label := "+ " + tag.Name | |||
| 163 | + | classes := "sv-button sv-button-secondary" | |||
| 164 | + | if favorite.HasTag(tag.ID) { | |||
| 165 | + | action = "tag.remove" | |||
| 166 | + | label = "✓ " + tag.Name | |||
| 167 | + | classes += " sv-button-active" | |||
| 168 | + | } | |||
| 169 | + | out.WriteString(fmt.Sprintf(`<button type="button" class="%s" data-sv-action="%s" data-favorite-id="%d" data-tag-id="%d"><span class="sv-tag-dot" style="background:%s"></span>%s</button>`, | |||
| 170 | + | html.EscapeString(classes), | |||
| 171 | + | html.EscapeString(action), | |||
| 172 | + | favorite.ID, | |||
| 173 | + | tag.ID, | |||
| 174 | + | html.EscapeString(tag.Color), | |||
| 175 | + | html.EscapeString(label), | |||
| 176 | + | )) | |||
| 177 | + | } | |||
| 178 | + | out.WriteString(`</div>`) | |||
| 179 | + | return out.String() | |||
| 180 | + | } | |||
| 181 | + | ||||
| 182 | + | func folderCreateHTML() string { | |||
| 183 | + | return `<form class="sv-inline-form" data-sv-action="folder.create"><label><span>Name</span><input name="name" type="text" required></label><label><span>Icon</span><input name="icon" type="text" placeholder="Optional"></label><button class="sv-button sv-button-secondary" type="submit">Create folder</button></form>` | |||
| 184 | + | } | |||
| 185 | + | ||||
| 38 | func folderItemsHTML(folders []*entity.Folder) string { | 186 | func folderItemsHTML(folders []*entity.Folder) string { | ||
| 39 | var items strings.Builder | 187 | var items strings.Builder | ||
| 40 | for _, folder := range folders { | 188 | for _, folder := range folders { | ||
| 41 | if folder == nil { | 189 | if folder == nil { | ||
| 42 | continue | 190 | continue | ||
| 43 | } | 191 | } | ||
| 44 | - | items.WriteString(listRowHTML(html.EscapeString(folder.Name))) | 192 | + | items.WriteString(listRowHTML(folderItemHTML(folder))) |
| 45 | } | 193 | } | ||
| 46 | return items.String() | 194 | return items.String() | ||
| 47 | } | 195 | } | ||
| 48 | 196 | ||||
| 197 | + | func folderItemHTML(folder *entity.Folder) string { | |||
| 198 | + | return fmt.Sprintf(`<form class="sv-inline-form" data-sv-action="folder.update"><input type="hidden" name="id" value="%d"><label><span>Name</span><input name="name" type="text" value="%s" required></label><label><span>Icon</span><input name="icon" type="text" value="%s"></label><button class="sv-button sv-button-secondary" type="submit">Save</button><button type="button" class="sv-button sv-button-secondary sv-button-danger" data-sv-action="folder.delete" data-id="%d" data-sv-confirm="Delete this folder? Favorites will move out of it.">Delete</button></form>`, | |||
| 199 | + | folder.ID, | |||
| 200 | + | html.EscapeString(folder.Name), | |||
| 201 | + | html.EscapeString(folder.Icon), | |||
| 202 | + | folder.ID, | |||
| 203 | + | ) | |||
| 204 | + | } | |||
| 205 | + | ||||
| 206 | + | func tagCreateHTML() string { | |||
| 207 | + | return `<form class="sv-inline-form" data-sv-action="tag.create"><label><span>Name</span><input name="name" type="text" required></label><label><span>Color</span><input name="color" type="text" placeholder="#808080"></label><button class="sv-button sv-button-secondary" type="submit">Create tag</button></form>` | |||
| 208 | + | } | |||
| 209 | + | ||||
| 49 | func tagItemsHTML(tags []*entity.Tag) string { | 210 | func tagItemsHTML(tags []*entity.Tag) string { | ||
| 50 | var items strings.Builder | 211 | var items strings.Builder | ||
| 51 | for _, tag := range tags { | 212 | for _, tag := range tags { | ||
| 52 | if tag == nil { | 213 | if tag == nil { | ||
| 53 | continue | 214 | continue | ||
| 54 | } | 215 | } | ||
| 55 | - | items.WriteString(listRowHTML(html.EscapeString(tag.Name))) | 216 | + | items.WriteString(listRowHTML(tagItemHTML(tag))) |
| 56 | } | 217 | } | ||
| 57 | return items.String() | 218 | return items.String() | ||
| 58 | } | 219 | } | ||
| 220 | + | ||||
| 221 | + | func tagItemHTML(tag *entity.Tag) string { | |||
| 222 | + | return fmt.Sprintf(`<form class="sv-inline-form" data-sv-action="tag.update"><input type="hidden" name="id" value="%d"><span class="sv-tag-dot" style="background:%s"></span><label><span>Name</span><input name="name" type="text" value="%s" required></label><label><span>Color</span><input name="color" type="text" value="%s"></label><button class="sv-button sv-button-secondary" type="submit">Save</button><button type="button" class="sv-button sv-button-secondary sv-button-danger" data-sv-action="tag.delete" data-id="%d" data-sv-confirm="Delete this tag?">Delete</button></form>`, | |||
| 223 | + | tag.ID, | |||
| 224 | + | html.EscapeString(tag.Color), | |||
| 225 | + | html.EscapeString(tag.Name), | |||
| 226 | + | html.EscapeString(tag.Color), | |||
| 227 | + | tag.ID, | |||
| 228 | + | ) | |||
| 229 | + | } | |||
| 230 | + | ||||
| 231 | + | func folderSelectHTML(name string, folders []*entity.Folder, selected *entity.FolderID, label string) string { | |||
| 232 | + | var out strings.Builder | |||
| 233 | + | out.WriteString(fmt.Sprintf(`<label><span>%s</span><select name="%s"><option value="">Unfiled</option>`, html.EscapeString(label), html.EscapeString(name))) | |||
| 234 | + | for _, folder := range folders { | |||
| 235 | + | if folder == nil { | |||
| 236 | + | continue | |||
| 237 | + | } | |||
| 238 | + | selectedAttr := "" | |||
| 239 | + | if selected != nil && *selected == folder.ID { | |||
| 240 | + | selectedAttr = ` selected` | |||
| 241 | + | } | |||
| 242 | + | out.WriteString(fmt.Sprintf(`<option value="%d"%s>%s</option>`, folder.ID, selectedAttr, html.EscapeString(folderDisplayName(folder)))) | |||
| 243 | + | } | |||
| 244 | + | out.WriteString(`</select></label>`) | |||
| 245 | + | return out.String() | |||
| 246 | + | } | |||
| 247 | + | ||||
| 248 | + | func shortcutSelectHTML(selected *int) string { | |||
| 249 | + | var out strings.Builder | |||
| 250 | + | out.WriteString(`<label><span>Shortcut</span><select name="shortcut_key"><option value="">None</option>`) | |||
| 251 | + | for i := 1; i <= 9; i++ { | |||
| 252 | + | selectedAttr := "" | |||
| 253 | + | if selected != nil && *selected == i { | |||
| 254 | + | selectedAttr = ` selected` | |||
| 255 | + | } | |||
| 256 | + | out.WriteString(fmt.Sprintf(`<option value="%d"%s>%d</option>`, i, selectedAttr, i)) | |||
| 257 | + | } | |||
| 258 | + | out.WriteString(`</select></label>`) | |||
| 259 | + | return out.String() | |||
| 260 | + | } | |||
| 261 | + | ||||
| 262 | + | func folderDisplayName(folder *entity.Folder) string { | |||
| 263 | + | if folder == nil { | |||
| 264 | + | return "" | |||
| 265 | + | } | |||
| 266 | + | if strings.TrimSpace(folder.Icon) != "" { | |||
| 267 | + | return folder.Icon + " " + folder.Name | |||
| 268 | + | } | |||
| 269 | + | return folder.Name | |||
| 270 | + | } | |||
| 271 | + | ||||
| 272 | + | func folderNameForID(folders []*entity.Folder, id *entity.FolderID) string { | |||
| 273 | + | if id == nil { | |||
| 274 | + | return "Unfiled" | |||
| 275 | + | } | |||
| 276 | + | for _, folder := range folders { | |||
| 277 | + | if folder != nil && folder.ID == *id { | |||
| 278 | + | return folderDisplayName(folder) | |||
| 279 | + | } | |||
| 280 | + | } | |||
| 281 | + | return "Unknown" | |||
| 282 | + | } | |||
| 283 | + | ||||
| 284 | + | func filterFavorites(favorites []*entity.Favorite, folderID *entity.FolderID, tagID *entity.TagID) []*entity.Favorite { | |||
| 285 | + | if folderID == nil && tagID == nil { | |||
| 286 | + | return favorites | |||
| 287 | + | } | |||
| 288 | + | out := make([]*entity.Favorite, 0, len(favorites)) | |||
| 289 | + | for _, favorite := range favorites { | |||
| 290 | + | if favorite == nil { | |||
| 291 | + | continue | |||
| 292 | + | } | |||
| 293 | + | if folderID != nil { | |||
| 294 | + | if *folderID == 0 { | |||
| 295 | + | if favorite.FolderID != nil { | |||
| 296 | + | continue | |||
| 297 | + | } | |||
| 298 | + | } else if favorite.FolderID == nil || *favorite.FolderID != *folderID { | |||
| 299 | + | continue | |||
| 300 | + | } | |||
| 301 | + | } | |||
| 302 | + | if tagID != nil && !favorite.HasTag(*tagID) { | |||
| 303 | + | continue | |||
| 304 | + | } | |||
| 305 | + | out = append(out, favorite) | |||
| 306 | + | } | |||
| 307 | + | return out | |||
| 308 | + | } | |||
M internal/ui/systemviews/history_actions.go +10 -0
| @@ -40,6 +40,16 @@ func (a *App) HandleDOMAction(ctx context.Context, event DOMAction) error { | |||
| 40 | 40 | a.renderRouteError(err) | |
| 41 | 41 | } | |
| 42 | 42 | return a.mountRenderedHTML() | |
| 43 | + | case RouteFavorites: | |
| 44 | + | a.favoritesError = "" | |
| 45 | + | if err := a.handleFavoriteAction(ctx, event); err != nil { | |
| 46 | + | a.favoritesNotice = "" | |
| 47 | + | a.favoritesError = err.Error() | |
| 48 | + | } | |
| 49 | + | if err := a.loadFavoritesRoute(ctx); err != nil { | |
| 50 | + | a.renderRouteError(err) | |
| 51 | + | } | |
| 52 | + | return a.mountRenderedHTML() | |
| 43 | 53 | default: | |
| 44 | 54 | return nil | |
| 45 | 55 | } | |
| @@ -40,6 +40,16 @@ func (a *App) HandleDOMAction(ctx context.Context, event DOMAction) error { | |||||
| 40 | a.renderRouteError(err) | 40 | a.renderRouteError(err) | ||
| 41 | } | 41 | } | ||
| 42 | return a.mountRenderedHTML() | 42 | return a.mountRenderedHTML() | ||
| 43 | + | case RouteFavorites: | |||
| 44 | + | a.favoritesError = "" | |||
| 45 | + | if err := a.handleFavoriteAction(ctx, event); err != nil { | |||
| 46 | + | a.favoritesNotice = "" | |||
| 47 | + | a.favoritesError = err.Error() | |||
| 48 | + | } | |||
| 49 | + | if err := a.loadFavoritesRoute(ctx); err != nil { | |||
| 50 | + | a.renderRouteError(err) | |||
| 51 | + | } | |||
| 52 | + | return a.mountRenderedHTML() | |||
| 43 | default: | 53 | default: | ||
| 44 | return nil | 54 | return nil | ||
| 45 | } | 55 | } | ||
M internal/ui/systemviews/href_test.go +1 -1
| @@ -27,7 +27,7 @@ func TestFavoriteItemsHTMLSanitizesHrefSchemes(t *testing.T) { | |||
| 27 | 27 | html := favoriteItemsHTML([]*entity.Favorite{ | |
| 28 | 28 | {URL: "javascript:alert(1)", Title: "Bad"}, | |
| 29 | 29 | {URL: "https://example.com", Title: "Good"}, | |
| 30 | - | }) | |
| 30 | + | }, nil, nil) | |
| 31 | 31 | ||
| 32 | 32 | require.NotContains(t, html, `href="javascript:alert(1)"`) | |
| 33 | 33 | assert.Contains(t, html, `href="#"`) | |
| @@ -27,7 +27,7 @@ func TestFavoriteItemsHTMLSanitizesHrefSchemes(t *testing.T) { | |||||
| 27 | html := favoriteItemsHTML([]*entity.Favorite{ | 27 | html := favoriteItemsHTML([]*entity.Favorite{ | ||
| 28 | {URL: "javascript:alert(1)", Title: "Bad"}, | 28 | {URL: "javascript:alert(1)", Title: "Bad"}, | ||
| 29 | {URL: "https://example.com", Title: "Good"}, | 29 | {URL: "https://example.com", Title: "Good"}, | ||
| 30 | - | }) | 30 | + | }, nil, nil) |
| 31 | 31 | ||||
| 32 | require.NotContains(t, html, `href="javascript:alert(1)"`) | 32 | require.NotContains(t, html, `href="javascript:alert(1)"`) | ||
| 33 | assert.Contains(t, html, `href="#"`) | 33 | assert.Contains(t, html, `href="#"`) | ||