detached 40aec41

feat(systemviews): add favorites CRUD controls

commit
40aec41a2a689bf5d7d32d89112b776a70b98107
author
brice · · 2026-04-24 02:33 UTC
parent
db23629
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
16 files · +1228 -52
M assets/systemviews/systemviews.css +89 -0
@@ -393,3 +393,92 @@ body {
393393 background: var(--sv-surface-variant, #1d2129);
394394 font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
395395 }
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 {
3131 DeleteByDomain(ctx context.Context, domain string) error
3232 }
3333
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+
3452 // HomepageFavorites provides favorites/folders/tags operations
3553 // needed by the WebUI homepage handlers.
3654 type HomepageFavorites interface {
3755 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
3859 SetShortcut(ctx context.Context, id entity.FavoriteID, key *int) error
3960 GetByShortcut(ctx context.Context, key int) (*entity.Favorite, error)
4061 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) error31 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 operations52 // 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) error59 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) error61 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 {
2020 // SystemviewFavoritesService exposes favorite, folder, and tag operations.
2121 type SystemviewFavoritesService interface {
2222 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
2326 ListFolders(ctx context.Context) ([]*entity.Folder, error)
2427 ListTags(ctx context.Context) ([]*entity.Tag, error)
2528 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) error28 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
9393 return fav, nil
9494 }
9595
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+
96149 // Remove deletes a favorite by ID.
97150 func (uc *ManageFavoritesUseCase) Remove(ctx context.Context, id entity.FavoriteID) error {
98151 log := logging.FromContext(ctx)
@@ -574,6 +627,7 @@ func (uc *ManageFavoritesUseCase) DeleteTag(ctx context.Context, id entity.TagID
574627 }
575628
576629 log.Info().Int64("id", int64(id)).Msg("tag deleted")
630+ uc.invalidateCache()
577631 return nil
578632 }
579633
@@ -596,6 +650,7 @@ func (uc *ManageFavoritesUseCase) TagFavorite(ctx context.Context, favID entity.
596650 if err := uc.tagRepo.AssignToFavorite(ctx, tagID, favID); err != nil {
597651 return fmt.Errorf("failed to tag favorite: %w", err)
598652 }
653+ uc.invalidateCache()
599654
600655 return nil
601656 }
@@ -611,6 +666,7 @@ func (uc *ManageFavoritesUseCase) UntagFavorite(ctx context.Context, favID entit
611666 if err := uc.tagRepo.RemoveFromFavorite(ctx, tagID, favID); err != nil {
612667 return fmt.Errorf("failed to untag favorite: %w", err)
613668 }
669+ uc.invalidateCache()
614670
615671 return nil
616672 }
@@ -674,5 +730,6 @@ func (uc *ManageFavoritesUseCase) UpdateTag(ctx context.Context, id entity.TagID
674730 }
675731
676732 log.Info().Int64("id", int64(id)).Msg("tag updated")
733+ uc.invalidateCache()
677734 return nil
678735 }
@@ -93,6 +93,59 @@ func (uc *ManageFavoritesUseCase) Add(ctx context.Context, input AddFavoriteInpu
93 return fav, nil93 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 nil631 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 nil655 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 nil671 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 nil734 return nil
678 }735 }
M internal/infrastructure/handlers/homepage/favorites.go +125 -0
@@ -39,6 +39,113 @@ func (h *FavoritesHandlers) HandleList() port.WebUIMessageHandler {
3939 })
4040 }
4141
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+
42149 // setShortcutRequest is the payload for favorite_set_shortcut messages.
43150 type setShortcutRequest struct {
44151 RequestID string `json:"requestId"`
@@ -107,6 +214,24 @@ type setFolderRequest struct {
107214 }
108215
109216 // 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+
110235 func (h *FavoritesHandlers) HandleSetFolder() port.WebUIMessageHandler {
111236 return port.WebUIMessageHandlerFunc(func(ctx context.Context, _ port.WebViewID, payload json.RawMessage) (any, error) {
112237 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
4040 // Favorites handlers
4141 favoritesHandlers := NewFavoritesHandlers(cfg.FavoritesUC)
4242 handlers["favorite_list"] = favoritesHandlers.HandleList()
43+ handlers["favorite_create"] = favoritesHandlers.HandleCreate()
44+ handlers["favorite_update"] = favoritesHandlers.HandleUpdate()
45+ handlers["favorite_delete"] = favoritesHandlers.HandleDelete()
4346 handlers["favorite_set_shortcut"] = favoritesHandlers.HandleSetShortcut()
4447 handlers["favorite_get_by_shortcut"] = favoritesHandlers.HandleGetByShortcut()
4548 handlers["favorite_set_folder"] = favoritesHandlers.HandleSetFolder()
@@ -40,6 +40,9 @@ func RegisterHandlers(ctx context.Context, router port.WebUIHandlerRouter, cfg C
40 // Favorites handlers40 // 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 {
2424
2525 func (r *favoriteRepo) Save(ctx context.Context, fav *entity.Favorite) error {
2626 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")
2828
2929 var folderID sql.NullInt64
3030 if fav.FolderID != nil {
3131 folderID = sql.NullInt64{Int64: int64(*fav.FolderID), Valid: true}
3232 }
3333
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+
3451 row, err := r.queries.CreateFavorite(ctx, sqlc.CreateFavoriteParams{
3552 Url: fav.URL,
3653 Title: sql.NullString{String: fav.Title, Valid: fav.Title != ""},
@@ -44,6 +61,13 @@ func (r *favoriteRepo) Save(ctx context.Context, fav *entity.Favorite) error {
4461 return nil
4562 }
4663
64+func int64Value(value *int) int64 {
65+ if value == nil {
66+ return 0
67+ }
68+ return int64(*value)
69+}
70+
4771 func (r *favoriteRepo) FindByID(ctx context.Context, id entity.FavoriteID) (*entity.Favorite, error) {
4872 row, err := r.queries.GetFavoriteByID(ctx, int64(id))
4973 if err != nil {
@@ -175,13 +199,21 @@ func NewFolderRepository(db *sql.DB) repository.FolderRepository {
175199
176200 func (r *folderRepo) Save(ctx context.Context, folder *entity.Folder) error {
177201 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")
179203
180204 var parentID sql.NullInt64
181205 if folder.ParentID != nil {
182206 parentID = sql.NullInt64{Int64: int64(*folder.ParentID), Valid: true}
183207 }
184208
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+
185217 row, err := r.queries.CreateFolder(ctx, sqlc.CreateFolderParams{
186218 Name: folder.Name,
187219 Icon: sql.NullString{String: folder.Icon, Valid: folder.Icon != ""},
@@ -275,7 +307,15 @@ func NewTagRepository(db *sql.DB) repository.TagRepository {
275307
276308 func (r *tagRepo) Save(ctx context.Context, tag *entity.Tag) error {
277309 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+ }
279319
280320 row, err := r.queries.CreateTag(ctx, sqlc.CreateTagParams{
281321 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.NullInt6429 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 nil61 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.NullInt64204 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 {
2424 func callbackPlanForMessage(msgType string) (callbackPlan, bool) {
2525 switch msgType {
2626 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",
2828 "favorite_set_shortcut", "favorite_set_folder", "folder_create", "folder_update", "folder_delete",
2929 "tag_create", "tag_update", "tag_delete", "tag_assign", "tag_remove":
3030 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"}, true30 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) {
154154 }{RequestID: nextRequestID()})
155155 }
156156
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+
157225 func (c *Client) ListFolders(ctx context.Context) ([]*entity.Folder, error) {
158226 return request[[]*entity.Folder](c, ctx, "folder_list", struct {
159227 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 {
1818 }
1919
2020 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
3842 }
3943
4044 const historyTimelineLimit = 25
@@ -208,13 +212,22 @@ func (a *App) loadFavoritesRoute(ctx context.Context) error {
208212 }
209213
210214 a.historyEntries = nil
215+ favorites = filterFavorites(favorites, a.favoriteFolderFilter, a.favoriteTagFilter)
211216 a.favorites = favorites
212217 a.folders = folders
213218 a.tags = tags
214219 a.renderedHTML = renderAppFrame(renderedPage{
215220 route: RouteFavorites,
216221 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+ }),
218231 }, a.shellTheme)
219232 return nil
220233 }
@@ -281,6 +294,10 @@ func (a *App) resetRouteState() {
281294 a.favorites = nil
282295 a.folders = nil
283296 a.tags = nil
297+ a.favoriteFolderFilter = nil
298+ a.favoriteTagFilter = nil
299+ a.favoritesNotice = ""
300+ a.favoritesError = ""
284301 a.config = nil
285302 a.keybindings = nil
286303 }
@@ -18,23 +18,27 @@ type Dependencies struct {
18 }18 }
19 19
20 type App struct {20 type App struct {
21- deps Dependencies21+ deps Dependencies
22- currentRoute Route22+ currentRoute Route
23- shellTheme shellTheme23+ shellTheme shellTheme
24- historyEntries []*entity.HistoryEntry24+ historyEntries []*entity.HistoryEntry
25- historyAnalytics *entity.HistoryAnalytics25+ historyAnalytics *entity.HistoryAnalytics
26- historyDomainStats []*entity.DomainStat26+ historyDomainStats []*entity.DomainStat
27- historyQuery string27+ historyQuery string
28- historyDomainFilter string28+ historyDomainFilter string
29- historyOffset int29+ historyOffset int
30- historyNotice string30+ historyNotice string
31- historyError string31+ historyError string
32- favorites []*entity.Favorite32+ favorites []*entity.Favorite
33- folders []*entity.Folder33+ folders []*entity.Folder
34- tags []*entity.Tag34+ tags []*entity.Tag
35- config *port.SystemviewConfigPayload35+ favoriteFolderFilter *entity.FolderID
36- keybindings any36+ favoriteTagFilter *entity.TagID
37- renderedHTML string37+ favoritesNotice string
38+ favoritesError string
39+ config *port.SystemviewConfigPayload
40+ keybindings any
41+ renderedHTML string
38 }42 }
39 43
40 const historyTimelineLimit = 2544 const historyTimelineLimit = 25
@@ -208,13 +212,22 @@ func (a *App) loadFavoritesRoute(ctx context.Context) error {
208 }212 }
209 213
210 a.historyEntries = nil214 a.historyEntries = nil
215+ favorites = filterFavorites(favorites, a.favoriteFolderFilter, a.favoriteTagFilter)
211 a.favorites = favorites216 a.favorites = favorites
212 a.folders = folders217 a.folders = folders
213 a.tags = tags218 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 nil232 return nil
220 }233 }
@@ -281,6 +294,10 @@ func (a *App) resetRouteState() {
281 a.favorites = nil294 a.favorites = nil
282 a.folders = nil295 a.folders = nil
283 a.tags = nil296 a.tags = nil
297+ a.favoriteFolderFilter = nil
298+ a.favoriteTagFilter = nil
299+ a.favoritesNotice = ""
300+ a.favoritesError = ""
284 a.config = nil301 a.config = nil
285 a.keybindings = nil302 a.keybindings = nil
286 }303 }
M internal/ui/systemviews/app_test.go +133 -10
@@ -254,6 +254,78 @@ func TestAppLoadInitialFavoritesRouteRendersData(t *testing.T) {
254254 assert.Contains(t, app.renderedHTML, `class="sv-meta"`)
255255 }
256256
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+
257329 func TestAppLoadInitialConfigRouteRendersData(t *testing.T) {
258330 t.Parallel()
259331
@@ -465,6 +537,20 @@ type fakeFavoritesService struct {
465537 favorites []*entity.Favorite
466538 folders []*entity.Folder
467539 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
468554 }
469555
470556 func (s *fakeFavoritesService) List(context.Context) ([]*entity.Favorite, error) {
@@ -482,29 +568,66 @@ func (s *fakeFavoritesService) ListTags(context.Context) ([]*entity.Tag, error)
482568 return s.tags, nil
483569 }
484570
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+
485586 func (s *fakeFavoritesService) SetShortcut(context.Context, int64, *int) error { return nil }
486587
487588 func (s *fakeFavoritesService) SetFolder(context.Context, int64, *int64) error { return nil }
488589
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
491593 }
492594
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+}
494599
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+}
496604
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
499608 }
500609
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+}
502614
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+}
504619
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+}
506625
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+}
508631
509632 type fakeConfigService struct {
510633 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.Favorite537 favorites []*entity.Favorite
466 folders []*entity.Folder538 folders []*entity.Folder
467 tags []*entity.Tag539 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, nil568 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, nil591+ 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, nil606+ 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 bool633 calledCurrent bool
M internal/ui/systemviews/dom_js.go +131 -4
@@ -80,8 +80,8 @@ func (d *browserDOM) BindActions(handler DOMActionHandler) error {
8080 }
8181 event.Call("preventDefault")
8282 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
8585 }
8686 handler(DOMAction{Action: action, Data: data})
8787 return nil
@@ -90,7 +90,7 @@ func (d *browserDOM) BindActions(handler DOMActionHandler) error {
9090 d.bindings = append(d.bindings, submitHandler)
9191
9292 keydownHandler := js.FuncOf(func(_ js.Value, args []js.Value) any {
93- d.handleHistoryKeyboard(firstJSArg(args))
93+ d.handleKeyboard(firstJSArg(args))
9494 return nil
9595 })
9696 d.document.Call("addEventListener", "keydown", keydownHandler)
@@ -134,6 +134,23 @@ func collectActionData(element js.Value) map[string]string {
134134 return data
135135 }
136136
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+
137154 func confirmAction(element js.Value) bool {
138155 message := element.Call("getAttribute", "data-sv-confirm").String()
139156 if message == "" {
@@ -146,8 +163,21 @@ func confirmAction(element js.Value) bool {
146163 return confirm.Invoke(message).Bool()
147164 }
148165
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+
149179 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() {
151181 return
152182 }
153183 key := strings.ToLower(event.Get("key").String())
@@ -193,6 +223,52 @@ func (d *browserDOM) isHistoryRoute() bool {
193223 return d.target.Call("querySelector", `[data-route="history"]`).Truthy()
194224 }
195225
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+
196272 func isEditableTarget(target js.Value) bool {
197273 if !target.Truthy() {
198274 return false
@@ -251,3 +327,54 @@ func (d *browserDOM) activeHistoryIndex() int {
251327 }
252328 return idx
253329 }
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 nil87 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 nil94 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 data134 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 return181 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 false274 return false
@@ -251,3 +327,54 @@ func (d *browserDOM) activeHistoryIndex() int {
251 }327 }
252 return idx328 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 (
88 "github.com/bnema/dumber/internal/domain/entity"
99 )
1010
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))
1525 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")),
1931 }
2032 return strings.Join(sections, "")
2133 }
2234
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 {
2498 var items strings.Builder
2599 for _, favorite := range favorites {
26100 if favorite == nil {
27101 continue
28102 }
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)))
34104 }
35105 return items.String()
36106 }
37107
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+
38186 func folderItemsHTML(folders []*entity.Folder) string {
39187 var items strings.Builder
40188 for _, folder := range folders {
41189 if folder == nil {
42190 continue
43191 }
44- items.WriteString(listRowHTML(html.EscapeString(folder.Name)))
192+ items.WriteString(listRowHTML(folderItemHTML(folder)))
45193 }
46194 return items.String()
47195 }
48196
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+
49210 func tagItemsHTML(tags []*entity.Tag) string {
50211 var items strings.Builder
51212 for _, tag := range tags {
52213 if tag == nil {
53214 continue
54215 }
55- items.WriteString(listRowHTML(html.EscapeString(tag.Name)))
216+ items.WriteString(listRowHTML(tagItemHTML(tag)))
56217 }
57218 return items.String()
58219 }
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.Builder98 var items strings.Builder
25 for _, favorite := range favorites {99 for _, favorite := range favorites {
26 if favorite == nil {100 if favorite == nil {
27 continue101 continue
28 }102 }
29- label := favorite.Title103+ 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.Builder187 var items strings.Builder
40 for _, folder := range folders {188 for _, folder := range folders {
41 if folder == nil {189 if folder == nil {
42 continue190 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.Builder211 var items strings.Builder
51 for _, tag := range tags {212 for _, tag := range tags {
52 if tag == nil {213 if tag == nil {
53 continue214 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 {
4040 a.renderRouteError(err)
4141 }
4242 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()
4353 default:
4454 return nil
4555 }
@@ -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 nil54 return nil
45 }55 }
M internal/ui/systemviews/href_test.go +1 -1
@@ -27,7 +27,7 @@ func TestFavoriteItemsHTMLSanitizesHrefSchemes(t *testing.T) {
2727 html := favoriteItemsHTML([]*entity.Favorite{
2828 {URL: "javascript:alert(1)", Title: "Bad"},
2929 {URL: "https://example.com", Title: "Good"},
30- })
30+ }, nil, nil)
3131
3232 require.NotContains(t, html, `href="javascript:alert(1)"`)
3333 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="#"`)

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close