fix(systemviews): polish and harden config views
- commit
- d944cf143852a011393b1ced3da06da4b84eef4c
- author
- brice · · 2026-04-24 03:17 UTC
- parent
- 9da5cc3
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
9 files · +190 -32
M assets/systemviews/systemviews.css +108 -11
| @@ -8,10 +8,10 @@ | |||
| 8 | 8 | ||
| 9 | 9 | body { | |
| 10 | 10 | margin: 0; | |
| 11 | - | font-family: Inter, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; | |
| 12 | - | font-size: 14px; | |
| 13 | - | background: var(--sv-bg, #0f1115); | |
| 14 | - | color: var(--sv-fg, #e8ebf0); | |
| 11 | + | font-family: var(--sv-font-sans, Inter), system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; | |
| 12 | + | font-size: var(--sv-font-size, 14px); | |
| 13 | + | background: var(--sv-background, #0f1115); | |
| 14 | + | color: var(--sv-text, #e8ebf0); | |
| 15 | 15 | } | |
| 16 | 16 | ||
| 17 | 17 | .sv-app { | |
| @@ -24,6 +24,60 @@ body { | |||
| 24 | 24 | padding: 1rem; | |
| 25 | 25 | } | |
| 26 | 26 | ||
| 27 | + | .sv-title { | |
| 28 | + | margin: 0; | |
| 29 | + | font-size: clamp(1.35rem, 2.4vw, 2rem); | |
| 30 | + | line-height: 1.15; | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | .sv-content { | |
| 34 | + | display: grid; | |
| 35 | + | gap: 1rem; | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | .sv-section > h2 { | |
| 39 | + | margin: 0 0 0.75rem; | |
| 40 | + | font-size: 1rem; | |
| 41 | + | letter-spacing: 0.02em; | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | .sv-list { | |
| 45 | + | display: grid; | |
| 46 | + | gap: 0.6rem; | |
| 47 | + | margin: 0.75rem 0 0; | |
| 48 | + | padding: 0; | |
| 49 | + | list-style: none; | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | .sv-list-row { | |
| 53 | + | padding: 0.75rem; | |
| 54 | + | border: 1px solid var(--sv-border, #2a313d); | |
| 55 | + | border-radius: 0.65rem; | |
| 56 | + | background: color-mix(in srgb, var(--sv-surface-variant, #1d2129) 72%, transparent); | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | .sv-link { | |
| 60 | + | color: var(--sv-accent, #8ab4ff); | |
| 61 | + | font-weight: 700; | |
| 62 | + | text-decoration: none; | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | .sv-link:hover { | |
| 66 | + | text-decoration: underline; | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | .sv-badge { | |
| 70 | + | display: inline-flex; | |
| 71 | + | align-items: center; | |
| 72 | + | border: 1px solid var(--sv-border, #2a313d); | |
| 73 | + | border-radius: 999px; | |
| 74 | + | padding: 0.15rem 0.45rem; | |
| 75 | + | background: var(--sv-surface-variant, #1d2129); | |
| 76 | + | color: var(--sv-muted, #9aa3b2); | |
| 77 | + | font-size: 12px; | |
| 78 | + | font-weight: 700; | |
| 79 | + | } | |
| 80 | + | ||
| 27 | 81 | .sv-header { | |
| 28 | 82 | background: var(--sv-surface-variant, #1d2129); | |
| 29 | 83 | border: 1px solid var(--sv-border, #2a313d); | |
| @@ -209,7 +263,7 @@ body { | |||
| 209 | 263 | border: 1px solid color-mix(in srgb, #ef4444 65%, var(--sv-border, #2a313d)); | |
| 210 | 264 | background: color-mix(in srgb, #ef4444 12%, var(--sv-surface, #171a21)); | |
| 211 | 265 | border-radius: 0.75rem; | |
| 212 | - | color: var(--sv-fg, #e8ebf0); | |
| 266 | + | color: var(--sv-text, #e8ebf0); | |
| 213 | 267 | padding: 1rem; | |
| 214 | 268 | } | |
| 215 | 269 | ||
| @@ -298,7 +352,7 @@ body { | |||
| 298 | 352 | border-radius: 0.55rem; | |
| 299 | 353 | padding: 0.55rem 0.65rem; | |
| 300 | 354 | background: var(--sv-surface-variant, #1d2129); | |
| 301 | - | color: var(--sv-fg, #e8ebf0); | |
| 355 | + | color: var(--sv-text, #e8ebf0); | |
| 302 | 356 | font: inherit; | |
| 303 | 357 | } | |
| 304 | 358 | ||
| @@ -321,14 +375,35 @@ body { | |||
| 321 | 375 | .sv-button-secondary { | |
| 322 | 376 | border-color: var(--sv-border, #2a313d); | |
| 323 | 377 | background: var(--sv-surface-variant, #1d2129); | |
| 324 | - | color: var(--sv-fg, #e8ebf0); | |
| 378 | + | color: var(--sv-text, #e8ebf0); | |
| 325 | 379 | } | |
| 326 | 380 | ||
| 327 | 381 | .sv-button-danger { | |
| 328 | - | border-color: color-mix(in srgb, #ef4444 70%, var(--sv-border, #2a313d)); | |
| 382 | + | border-color: color-mix(in srgb, #ef4444 78%, var(--sv-border, #2a313d)); | |
| 383 | + | background: color-mix(in srgb, #ef4444 14%, var(--sv-surface-variant, #1d2129)); | |
| 329 | 384 | color: #fecaca; | |
| 330 | 385 | } | |
| 331 | 386 | ||
| 387 | + | .sv-button-danger:hover { | |
| 388 | + | background: color-mix(in srgb, #ef4444 24%, var(--sv-surface-variant, #1d2129)); | |
| 389 | + | } | |
| 390 | + | ||
| 391 | + | .sv-button:focus-visible, | |
| 392 | + | .sv-icon-button:focus-visible, | |
| 393 | + | .sv-domain-filter:focus-visible, | |
| 394 | + | .sv-filter-chip button:focus-visible, | |
| 395 | + | .sv-link:focus-visible, | |
| 396 | + | .sv-search-label input:focus-visible, | |
| 397 | + | .sv-card-form input:focus-visible, | |
| 398 | + | .sv-card-form select:focus-visible, | |
| 399 | + | .sv-inline-form input:focus-visible, | |
| 400 | + | .sv-inline-form select:focus-visible, | |
| 401 | + | .sv-fieldset input:focus-visible, | |
| 402 | + | .sv-fieldset select:focus-visible { | |
| 403 | + | outline: 2px solid var(--sv-accent, #8ab4ff); | |
| 404 | + | outline-offset: 2px; | |
| 405 | + | } | |
| 406 | + | ||
| 332 | 407 | .sv-button:disabled { | |
| 333 | 408 | cursor: not-allowed; | |
| 334 | 409 | opacity: 0.45; | |
| @@ -355,7 +430,7 @@ body { | |||
| 355 | 430 | } | |
| 356 | 431 | ||
| 357 | 432 | .sv-domain-filter { | |
| 358 | - | color: var(--sv-fg, #e8ebf0); | |
| 433 | + | color: var(--sv-text, #e8ebf0); | |
| 359 | 434 | font-weight: 700; | |
| 360 | 435 | padding: 0; | |
| 361 | 436 | text-align: left; | |
| @@ -431,7 +506,7 @@ body { | |||
| 431 | 506 | border-radius: 0.55rem; | |
| 432 | 507 | padding: 0.5rem 0.6rem; | |
| 433 | 508 | background: var(--sv-surface-variant, #1d2129); | |
| 434 | - | color: var(--sv-fg, #e8ebf0); | |
| 509 | + | color: var(--sv-text, #e8ebf0); | |
| 435 | 510 | font: inherit; | |
| 436 | 511 | } | |
| 437 | 512 | ||
| @@ -511,8 +586,30 @@ body { | |||
| 511 | 586 | ||
| 512 | 587 | @media (max-width: 720px) { | |
| 513 | 588 | .sv-favorite-header, | |
| 514 | - | .sv-keybinding-row { | |
| 589 | + | .sv-keybinding-row, | |
| 590 | + | .sv-history-item, | |
| 591 | + | .sv-history-actions-panel, | |
| 592 | + | .sv-toolbar { | |
| 515 | 593 | display: grid; | |
| 516 | 594 | grid-template-columns: 1fr; | |
| 517 | 595 | } | |
| 596 | + | ||
| 597 | + | .sv-kv-row { | |
| 598 | + | grid-template-columns: 1fr; | |
| 599 | + | } | |
| 600 | + | ||
| 601 | + | .sv-card-form, | |
| 602 | + | .sv-inline-form, | |
| 603 | + | .sv-fieldset, | |
| 604 | + | .sv-button-row { | |
| 605 | + | align-items: stretch; | |
| 606 | + | } | |
| 607 | + | ||
| 608 | + | .sv-card-form label, | |
| 609 | + | .sv-inline-form label, | |
| 610 | + | .sv-fieldset label, | |
| 611 | + | .sv-button, | |
| 612 | + | .sv-button-secondary { | |
| 613 | + | width: 100%; | |
| 614 | + | } | |
| 518 | 615 | } | |
| @@ -8,10 +8,10 @@ | |||||
| 8 | 8 | ||||
| 9 | body { | 9 | body { | ||
| 10 | margin: 0; | 10 | margin: 0; | ||
| 11 | - | font-family: Inter, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; | 11 | + | font-family: var(--sv-font-sans, Inter), system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; |
| 12 | - | font-size: 14px; | 12 | + | font-size: var(--sv-font-size, 14px); |
| 13 | - | background: var(--sv-bg, #0f1115); | 13 | + | background: var(--sv-background, #0f1115); |
| 14 | - | color: var(--sv-fg, #e8ebf0); | 14 | + | color: var(--sv-text, #e8ebf0); |
| 15 | } | 15 | } | ||
| 16 | 16 | ||||
| 17 | .sv-app { | 17 | .sv-app { | ||
| @@ -24,6 +24,60 @@ body { | |||||
| 24 | padding: 1rem; | 24 | padding: 1rem; | ||
| 25 | } | 25 | } | ||
| 26 | 26 | ||||
| 27 | + | .sv-title { | |||
| 28 | + | margin: 0; | |||
| 29 | + | font-size: clamp(1.35rem, 2.4vw, 2rem); | |||
| 30 | + | line-height: 1.15; | |||
| 31 | + | } | |||
| 32 | + | ||||
| 33 | + | .sv-content { | |||
| 34 | + | display: grid; | |||
| 35 | + | gap: 1rem; | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | .sv-section > h2 { | |||
| 39 | + | margin: 0 0 0.75rem; | |||
| 40 | + | font-size: 1rem; | |||
| 41 | + | letter-spacing: 0.02em; | |||
| 42 | + | } | |||
| 43 | + | ||||
| 44 | + | .sv-list { | |||
| 45 | + | display: grid; | |||
| 46 | + | gap: 0.6rem; | |||
| 47 | + | margin: 0.75rem 0 0; | |||
| 48 | + | padding: 0; | |||
| 49 | + | list-style: none; | |||
| 50 | + | } | |||
| 51 | + | ||||
| 52 | + | .sv-list-row { | |||
| 53 | + | padding: 0.75rem; | |||
| 54 | + | border: 1px solid var(--sv-border, #2a313d); | |||
| 55 | + | border-radius: 0.65rem; | |||
| 56 | + | background: color-mix(in srgb, var(--sv-surface-variant, #1d2129) 72%, transparent); | |||
| 57 | + | } | |||
| 58 | + | ||||
| 59 | + | .sv-link { | |||
| 60 | + | color: var(--sv-accent, #8ab4ff); | |||
| 61 | + | font-weight: 700; | |||
| 62 | + | text-decoration: none; | |||
| 63 | + | } | |||
| 64 | + | ||||
| 65 | + | .sv-link:hover { | |||
| 66 | + | text-decoration: underline; | |||
| 67 | + | } | |||
| 68 | + | ||||
| 69 | + | .sv-badge { | |||
| 70 | + | display: inline-flex; | |||
| 71 | + | align-items: center; | |||
| 72 | + | border: 1px solid var(--sv-border, #2a313d); | |||
| 73 | + | border-radius: 999px; | |||
| 74 | + | padding: 0.15rem 0.45rem; | |||
| 75 | + | background: var(--sv-surface-variant, #1d2129); | |||
| 76 | + | color: var(--sv-muted, #9aa3b2); | |||
| 77 | + | font-size: 12px; | |||
| 78 | + | font-weight: 700; | |||
| 79 | + | } | |||
| 80 | + | ||||
| 27 | .sv-header { | 81 | .sv-header { | ||
| 28 | background: var(--sv-surface-variant, #1d2129); | 82 | background: var(--sv-surface-variant, #1d2129); | ||
| 29 | border: 1px solid var(--sv-border, #2a313d); | 83 | border: 1px solid var(--sv-border, #2a313d); | ||
| @@ -209,7 +263,7 @@ body { | |||||
| 209 | border: 1px solid color-mix(in srgb, #ef4444 65%, var(--sv-border, #2a313d)); | 263 | border: 1px solid color-mix(in srgb, #ef4444 65%, var(--sv-border, #2a313d)); | ||
| 210 | background: color-mix(in srgb, #ef4444 12%, var(--sv-surface, #171a21)); | 264 | background: color-mix(in srgb, #ef4444 12%, var(--sv-surface, #171a21)); | ||
| 211 | border-radius: 0.75rem; | 265 | border-radius: 0.75rem; | ||
| 212 | - | color: var(--sv-fg, #e8ebf0); | 266 | + | color: var(--sv-text, #e8ebf0); |
| 213 | padding: 1rem; | 267 | padding: 1rem; | ||
| 214 | } | 268 | } | ||
| 215 | 269 | ||||
| @@ -298,7 +352,7 @@ body { | |||||
| 298 | border-radius: 0.55rem; | 352 | border-radius: 0.55rem; | ||
| 299 | padding: 0.55rem 0.65rem; | 353 | padding: 0.55rem 0.65rem; | ||
| 300 | background: var(--sv-surface-variant, #1d2129); | 354 | background: var(--sv-surface-variant, #1d2129); | ||
| 301 | - | color: var(--sv-fg, #e8ebf0); | 355 | + | color: var(--sv-text, #e8ebf0); |
| 302 | font: inherit; | 356 | font: inherit; | ||
| 303 | } | 357 | } | ||
| 304 | 358 | ||||
| @@ -321,14 +375,35 @@ body { | |||||
| 321 | .sv-button-secondary { | 375 | .sv-button-secondary { | ||
| 322 | border-color: var(--sv-border, #2a313d); | 376 | border-color: var(--sv-border, #2a313d); | ||
| 323 | background: var(--sv-surface-variant, #1d2129); | 377 | background: var(--sv-surface-variant, #1d2129); | ||
| 324 | - | color: var(--sv-fg, #e8ebf0); | 378 | + | color: var(--sv-text, #e8ebf0); |
| 325 | } | 379 | } | ||
| 326 | 380 | ||||
| 327 | .sv-button-danger { | 381 | .sv-button-danger { | ||
| 328 | - | border-color: color-mix(in srgb, #ef4444 70%, var(--sv-border, #2a313d)); | 382 | + | border-color: color-mix(in srgb, #ef4444 78%, var(--sv-border, #2a313d)); |
| 383 | + | background: color-mix(in srgb, #ef4444 14%, var(--sv-surface-variant, #1d2129)); | |||
| 329 | color: #fecaca; | 384 | color: #fecaca; | ||
| 330 | } | 385 | } | ||
| 331 | 386 | ||||
| 387 | + | .sv-button-danger:hover { | |||
| 388 | + | background: color-mix(in srgb, #ef4444 24%, var(--sv-surface-variant, #1d2129)); | |||
| 389 | + | } | |||
| 390 | + | ||||
| 391 | + | .sv-button:focus-visible, | |||
| 392 | + | .sv-icon-button:focus-visible, | |||
| 393 | + | .sv-domain-filter:focus-visible, | |||
| 394 | + | .sv-filter-chip button:focus-visible, | |||
| 395 | + | .sv-link:focus-visible, | |||
| 396 | + | .sv-search-label input:focus-visible, | |||
| 397 | + | .sv-card-form input:focus-visible, | |||
| 398 | + | .sv-card-form select:focus-visible, | |||
| 399 | + | .sv-inline-form input:focus-visible, | |||
| 400 | + | .sv-inline-form select:focus-visible, | |||
| 401 | + | .sv-fieldset input:focus-visible, | |||
| 402 | + | .sv-fieldset select:focus-visible { | |||
| 403 | + | outline: 2px solid var(--sv-accent, #8ab4ff); | |||
| 404 | + | outline-offset: 2px; | |||
| 405 | + | } | |||
| 406 | + | ||||
| 332 | .sv-button:disabled { | 407 | .sv-button:disabled { | ||
| 333 | cursor: not-allowed; | 408 | cursor: not-allowed; | ||
| 334 | opacity: 0.45; | 409 | opacity: 0.45; | ||
| @@ -355,7 +430,7 @@ body { | |||||
| 355 | } | 430 | } | ||
| 356 | 431 | ||||
| 357 | .sv-domain-filter { | 432 | .sv-domain-filter { | ||
| 358 | - | color: var(--sv-fg, #e8ebf0); | 433 | + | color: var(--sv-text, #e8ebf0); |
| 359 | font-weight: 700; | 434 | font-weight: 700; | ||
| 360 | padding: 0; | 435 | padding: 0; | ||
| 361 | text-align: left; | 436 | text-align: left; | ||
| @@ -431,7 +506,7 @@ body { | |||||
| 431 | border-radius: 0.55rem; | 506 | border-radius: 0.55rem; | ||
| 432 | padding: 0.5rem 0.6rem; | 507 | padding: 0.5rem 0.6rem; | ||
| 433 | background: var(--sv-surface-variant, #1d2129); | 508 | background: var(--sv-surface-variant, #1d2129); | ||
| 434 | - | color: var(--sv-fg, #e8ebf0); | 509 | + | color: var(--sv-text, #e8ebf0); |
| 435 | font: inherit; | 510 | font: inherit; | ||
| 436 | } | 511 | } | ||
| 437 | 512 | ||||
| @@ -511,8 +586,30 @@ body { | |||||
| 511 | 586 | ||||
| 512 | @media (max-width: 720px) { | 587 | @media (max-width: 720px) { | ||
| 513 | .sv-favorite-header, | 588 | .sv-favorite-header, | ||
| 514 | - | .sv-keybinding-row { | 589 | + | .sv-keybinding-row, |
| 590 | + | .sv-history-item, | |||
| 591 | + | .sv-history-actions-panel, | |||
| 592 | + | .sv-toolbar { | |||
| 515 | display: grid; | 593 | display: grid; | ||
| 516 | grid-template-columns: 1fr; | 594 | grid-template-columns: 1fr; | ||
| 517 | } | 595 | } | ||
| 596 | + | ||||
| 597 | + | .sv-kv-row { | |||
| 598 | + | grid-template-columns: 1fr; | |||
| 599 | + | } | |||
| 600 | + | ||||
| 601 | + | .sv-card-form, | |||
| 602 | + | .sv-inline-form, | |||
| 603 | + | .sv-fieldset, | |||
| 604 | + | .sv-button-row { | |||
| 605 | + | align-items: stretch; | |||
| 606 | + | } | |||
| 607 | + | ||||
| 608 | + | .sv-card-form label, | |||
| 609 | + | .sv-inline-form label, | |||
| 610 | + | .sv-fieldset label, | |||
| 611 | + | .sv-button, | |||
| 612 | + | .sv-button-secondary { | |||
| 613 | + | width: 100%; | |||
| 614 | + | } | |||
| 518 | } | 615 | } | ||
M cmd/systemviews/bootstrap_test.go +10 -0
| @@ -138,6 +138,16 @@ func (f *fakeBridgeService) List(context.Context) ([]*entity.Favorite, error) { | |||
| 138 | 138 | return f.favorites, nil | |
| 139 | 139 | } | |
| 140 | 140 | ||
| 141 | + | func (*fakeBridgeService) CreateFavorite(context.Context, port.FavoriteCreateInput) (*entity.Favorite, error) { | |
| 142 | + | return nil, nil | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | func (*fakeBridgeService) UpdateFavorite(context.Context, port.FavoriteUpdateInput) (*entity.Favorite, error) { | |
| 146 | + | return nil, nil | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | func (*fakeBridgeService) DeleteFavorite(context.Context, int64) error { return nil } | |
| 150 | + | ||
| 141 | 151 | func (f *fakeBridgeService) ListFolders(context.Context) ([]*entity.Folder, error) { | |
| 142 | 152 | return f.folders, nil | |
| 143 | 153 | } | |
| @@ -138,6 +138,16 @@ func (f *fakeBridgeService) List(context.Context) ([]*entity.Favorite, error) { | |||||
| 138 | return f.favorites, nil | 138 | return f.favorites, nil | ||
| 139 | } | 139 | } | ||
| 140 | 140 | ||||
| 141 | + | func (*fakeBridgeService) CreateFavorite(context.Context, port.FavoriteCreateInput) (*entity.Favorite, error) { | |||
| 142 | + | return nil, nil | |||
| 143 | + | } | |||
| 144 | + | ||||
| 145 | + | func (*fakeBridgeService) UpdateFavorite(context.Context, port.FavoriteUpdateInput) (*entity.Favorite, error) { | |||
| 146 | + | return nil, nil | |||
| 147 | + | } | |||
| 148 | + | ||||
| 149 | + | func (*fakeBridgeService) DeleteFavorite(context.Context, int64) error { return nil } | |||
| 150 | + | ||||
| 141 | func (f *fakeBridgeService) ListFolders(context.Context) ([]*entity.Folder, error) { | 151 | func (f *fakeBridgeService) ListFolders(context.Context) ([]*entity.Folder, error) { | ||
| 142 | return f.folders, nil | 152 | return f.folders, nil | ||
| 143 | } | 153 | } | ||
M internal/ui/systemviews/app.go +2 -0
| @@ -4,6 +4,7 @@ import ( | |||
| 4 | 4 | "context" | |
| 5 | 5 | "errors" | |
| 6 | 6 | "strings" | |
| 7 | + | "sync" | |
| 7 | 8 | ||
| 8 | 9 | "github.com/bnema/dumber/internal/application/port" | |
| 9 | 10 | "github.com/bnema/dumber/internal/domain/entity" | |
| @@ -18,6 +19,7 @@ type Dependencies struct { | |||
| 18 | 19 | } | |
| 19 | 20 | ||
| 20 | 21 | type App struct { | |
| 22 | + | mu sync.Mutex | |
| 21 | 23 | deps Dependencies | |
| 22 | 24 | currentRoute Route | |
| 23 | 25 | shellTheme shellTheme | |
| @@ -4,6 +4,7 @@ import ( | |||||
| 4 | "context" | 4 | "context" | ||
| 5 | "errors" | 5 | "errors" | ||
| 6 | "strings" | 6 | "strings" | ||
| 7 | + | "sync" | |||
| 7 | 8 | ||||
| 8 | "github.com/bnema/dumber/internal/application/port" | 9 | "github.com/bnema/dumber/internal/application/port" | ||
| 9 | "github.com/bnema/dumber/internal/domain/entity" | 10 | "github.com/bnema/dumber/internal/domain/entity" | ||
| @@ -18,6 +19,7 @@ type Dependencies struct { | |||||
| 18 | } | 19 | } | ||
| 19 | 20 | ||||
| 20 | type App struct { | 21 | type App struct { | ||
| 22 | + | mu sync.Mutex | |||
| 21 | deps Dependencies | 23 | deps Dependencies | ||
| 22 | currentRoute Route | 24 | currentRoute Route | ||
| 23 | shellTheme shellTheme | 25 | shellTheme shellTheme | ||
M internal/ui/systemviews/config_actions.go +28 -10
| @@ -183,6 +183,9 @@ func (a *App) deleteSearchShortcut(ctx context.Context, data map[string]string) | |||
| 183 | 183 | return fmt.Errorf("search shortcut key is required") | |
| 184 | 184 | } | |
| 185 | 185 | cfg.SearchShortcuts = cloneSearchShortcuts(cfg.SearchShortcuts) | |
| 186 | + | if _, exists := cfg.SearchShortcuts[key]; !exists { | |
| 187 | + | return fmt.Errorf("search shortcut %q not found", key) | |
| 188 | + | } | |
| 186 | 189 | delete(cfg.SearchShortcuts, key) | |
| 187 | 190 | if err := a.saveEditableConfig(ctx, cfg); err != nil { | |
| 188 | 191 | return err | |
| @@ -243,6 +246,9 @@ func (a *App) setConfigKeybinding(ctx context.Context, data map[string]string) e | |||
| 243 | 246 | if mode == "" || action == "" { | |
| 244 | 247 | return fmt.Errorf("mode and action are required") | |
| 245 | 248 | } | |
| 249 | + | if err := a.requireKeybindingTarget(ctx, mode, action); err != nil { | |
| 250 | + | return err | |
| 251 | + | } | |
| 246 | 252 | resp, err := a.deps.Config.SetKeybinding(ctx, port.SetKeybindingRequest{ | |
| 247 | 253 | RequestID: configRequestIDPrefix, | |
| 248 | 254 | Mode: mode, | |
| @@ -266,6 +272,9 @@ func (a *App) resetConfigKeybinding(ctx context.Context, data map[string]string) | |||
| 266 | 272 | if mode == "" || action == "" { | |
| 267 | 273 | return fmt.Errorf("mode and action are required") | |
| 268 | 274 | } | |
| 275 | + | if err := a.requireKeybindingTarget(ctx, mode, action); err != nil { | |
| 276 | + | return err | |
| 277 | + | } | |
| 269 | 278 | if err := a.deps.Config.ResetKeybinding(ctx, port.ResetKeybindingRequest{ | |
| 270 | 279 | RequestID: configRequestIDPrefix, | |
| 271 | 280 | Mode: mode, | |
| @@ -278,23 +287,32 @@ func (a *App) resetConfigKeybinding(ctx context.Context, data map[string]string) | |||
| 278 | 287 | } | |
| 279 | 288 | ||
| 280 | 289 | func (a *App) editableConfig(ctx context.Context) (port.WebUIConfig, error) { | |
| 281 | - | payload, err := a.currentConfigPayload(ctx) | |
| 290 | + | cfg, err := a.deps.Config.Current(ctx) | |
| 282 | 291 | if err != nil { | |
| 283 | 292 | return port.WebUIConfig{}, err | |
| 284 | 293 | } | |
| 285 | - | return webUIConfigFromPayload(payload), nil | |
| 294 | + | a.config = &cfg | |
| 295 | + | return webUIConfigFromPayload(cfg), nil | |
| 286 | 296 | } | |
| 287 | 297 | ||
| 288 | - | func (a *App) currentConfigPayload(ctx context.Context) (port.SystemviewConfigPayload, error) { | |
| 289 | - | if a.config != nil { | |
| 290 | - | return *a.config, nil | |
| 291 | - | } | |
| 292 | - | cfg, err := a.deps.Config.Current(ctx) | |
| 298 | + | func (a *App) requireKeybindingTarget(ctx context.Context, mode, action string) error { | |
| 299 | + | keybindings, err := a.deps.Config.GetKeybindings(ctx) | |
| 293 | 300 | if err != nil { | |
| 294 | - | return port.SystemviewConfigPayload{}, err | |
| 301 | + | return err | |
| 295 | 302 | } | |
| 296 | - | a.config = &cfg | |
| 297 | - | return cfg, nil | |
| 303 | + | a.keybindings = keybindings | |
| 304 | + | groups := configKeybindingGroups(keybindings) | |
| 305 | + | for _, group := range groups { | |
| 306 | + | if group.Mode != mode { | |
| 307 | + | continue | |
| 308 | + | } | |
| 309 | + | for _, binding := range group.Bindings { | |
| 310 | + | if binding.Action == action { | |
| 311 | + | return nil | |
| 312 | + | } | |
| 313 | + | } | |
| 314 | + | } | |
| 315 | + | return fmt.Errorf("keybinding %s/%s not found", mode, action) | |
| 298 | 316 | } | |
| 299 | 317 | ||
| 300 | 318 | func (a *App) saveEditableConfig(ctx context.Context, cfg port.WebUIConfig) error { | |
| @@ -183,6 +183,9 @@ func (a *App) deleteSearchShortcut(ctx context.Context, data map[string]string) | |||||
| 183 | return fmt.Errorf("search shortcut key is required") | 183 | return fmt.Errorf("search shortcut key is required") | ||
| 184 | } | 184 | } | ||
| 185 | cfg.SearchShortcuts = cloneSearchShortcuts(cfg.SearchShortcuts) | 185 | cfg.SearchShortcuts = cloneSearchShortcuts(cfg.SearchShortcuts) | ||
| 186 | + | if _, exists := cfg.SearchShortcuts[key]; !exists { | |||
| 187 | + | return fmt.Errorf("search shortcut %q not found", key) | |||
| 188 | + | } | |||
| 186 | delete(cfg.SearchShortcuts, key) | 189 | delete(cfg.SearchShortcuts, key) | ||
| 187 | if err := a.saveEditableConfig(ctx, cfg); err != nil { | 190 | if err := a.saveEditableConfig(ctx, cfg); err != nil { | ||
| 188 | return err | 191 | return err | ||
| @@ -243,6 +246,9 @@ func (a *App) setConfigKeybinding(ctx context.Context, data map[string]string) e | |||||
| 243 | if mode == "" || action == "" { | 246 | if mode == "" || action == "" { | ||
| 244 | return fmt.Errorf("mode and action are required") | 247 | return fmt.Errorf("mode and action are required") | ||
| 245 | } | 248 | } | ||
| 249 | + | if err := a.requireKeybindingTarget(ctx, mode, action); err != nil { | |||
| 250 | + | return err | |||
| 251 | + | } | |||
| 246 | resp, err := a.deps.Config.SetKeybinding(ctx, port.SetKeybindingRequest{ | 252 | resp, err := a.deps.Config.SetKeybinding(ctx, port.SetKeybindingRequest{ | ||
| 247 | RequestID: configRequestIDPrefix, | 253 | RequestID: configRequestIDPrefix, | ||
| 248 | Mode: mode, | 254 | Mode: mode, | ||
| @@ -266,6 +272,9 @@ func (a *App) resetConfigKeybinding(ctx context.Context, data map[string]string) | |||||
| 266 | if mode == "" || action == "" { | 272 | if mode == "" || action == "" { | ||
| 267 | return fmt.Errorf("mode and action are required") | 273 | return fmt.Errorf("mode and action are required") | ||
| 268 | } | 274 | } | ||
| 275 | + | if err := a.requireKeybindingTarget(ctx, mode, action); err != nil { | |||
| 276 | + | return err | |||
| 277 | + | } | |||
| 269 | if err := a.deps.Config.ResetKeybinding(ctx, port.ResetKeybindingRequest{ | 278 | if err := a.deps.Config.ResetKeybinding(ctx, port.ResetKeybindingRequest{ | ||
| 270 | RequestID: configRequestIDPrefix, | 279 | RequestID: configRequestIDPrefix, | ||
| 271 | Mode: mode, | 280 | Mode: mode, | ||
| @@ -278,23 +287,32 @@ func (a *App) resetConfigKeybinding(ctx context.Context, data map[string]string) | |||||
| 278 | } | 287 | } | ||
| 279 | 288 | ||||
| 280 | func (a *App) editableConfig(ctx context.Context) (port.WebUIConfig, error) { | 289 | func (a *App) editableConfig(ctx context.Context) (port.WebUIConfig, error) { | ||
| 281 | - | payload, err := a.currentConfigPayload(ctx) | 290 | + | cfg, err := a.deps.Config.Current(ctx) |
| 282 | if err != nil { | 291 | if err != nil { | ||
| 283 | return port.WebUIConfig{}, err | 292 | return port.WebUIConfig{}, err | ||
| 284 | } | 293 | } | ||
| 285 | - | return webUIConfigFromPayload(payload), nil | 294 | + | a.config = &cfg |
| 295 | + | return webUIConfigFromPayload(cfg), nil | |||
| 286 | } | 296 | } | ||
| 287 | 297 | ||||
| 288 | - | func (a *App) currentConfigPayload(ctx context.Context) (port.SystemviewConfigPayload, error) { | 298 | + | func (a *App) requireKeybindingTarget(ctx context.Context, mode, action string) error { |
| 289 | - | if a.config != nil { | 299 | + | keybindings, err := a.deps.Config.GetKeybindings(ctx) |
| 290 | - | return *a.config, nil | |||
| 291 | - | } | |||
| 292 | - | cfg, err := a.deps.Config.Current(ctx) | |||
| 293 | if err != nil { | 300 | if err != nil { | ||
| 294 | - | return port.SystemviewConfigPayload{}, err | 301 | + | return err |
| 295 | } | 302 | } | ||
| 296 | - | a.config = &cfg | 303 | + | a.keybindings = keybindings |
| 297 | - | return cfg, nil | 304 | + | groups := configKeybindingGroups(keybindings) |
| 305 | + | for _, group := range groups { | |||
| 306 | + | if group.Mode != mode { | |||
| 307 | + | continue | |||
| 308 | + | } | |||
| 309 | + | for _, binding := range group.Bindings { | |||
| 310 | + | if binding.Action == action { | |||
| 311 | + | return nil | |||
| 312 | + | } | |||
| 313 | + | } | |||
| 314 | + | } | |||
| 315 | + | return fmt.Errorf("keybinding %s/%s not found", mode, action) | |||
| 298 | } | 316 | } | ||
| 299 | 317 | ||||
| 300 | func (a *App) saveEditableConfig(ctx context.Context, cfg port.WebUIConfig) error { | 318 | func (a *App) saveEditableConfig(ctx context.Context, cfg port.WebUIConfig) error { | ||
M internal/ui/systemviews/favorites.templ +3 -3
| @@ -80,7 +80,7 @@ templ FavoritesControls(data favoritesRenderData) { | |||
| 80 | 80 | for _, tag := range data.Tags { | |
| 81 | 81 | if tag != nil { | |
| 82 | 82 | <button type="button" class={ favoriteFilterClass(favoriteTagFilterActive(data, tag.ID)) } data-sv-action="favorite.filterTag" data-tag-id={ fmt.Sprintf("%d", tag.ID) }> | |
| 83 | - | <span class="sv-tag-dot" style={ "background:" + tag.Color }></span>{ tag.Name } | |
| 83 | + | <span class="sv-tag-dot" style={ "background:" + safeTagColor(tag.Color) }></span>{ tag.Name } | |
| 84 | 84 | </button> | |
| 85 | 85 | } | |
| 86 | 86 | } | |
| @@ -129,7 +129,7 @@ templ FavoriteTagControls(favorite *entity.Favorite, tags []*entity.Tag) { | |||
| 129 | 129 | for _, tag := range tags { | |
| 130 | 130 | if tag != nil { | |
| 131 | 131 | <button type="button" class={ favoriteTagButtonClass(favorite, tag) } data-sv-action={ favoriteTagButtonAction(favorite, tag) } data-favorite-id={ fmt.Sprintf("%d", favorite.ID) } data-tag-id={ fmt.Sprintf("%d", tag.ID) }> | |
| 132 | - | <span class="sv-tag-dot" style={ "background:" + tag.Color }></span>{ favoriteTagButtonLabel(favorite, tag) } | |
| 132 | + | <span class="sv-tag-dot" style={ "background:" + safeTagColor(tag.Color) }></span>{ favoriteTagButtonLabel(favorite, tag) } | |
| 133 | 133 | </button> | |
| 134 | 134 | } | |
| 135 | 135 | } | |
| @@ -169,7 +169,7 @@ templ TagItem(tag *entity.Tag) { | |||
| 169 | 169 | <li class="sv-list-row"> | |
| 170 | 170 | <form class="sv-inline-form" data-sv-action="tag.update"> | |
| 171 | 171 | <input type="hidden" name="id" value={ fmt.Sprintf("%d", tag.ID) }/> | |
| 172 | - | <span class="sv-tag-dot" style={ "background:" + tag.Color }></span> | |
| 172 | + | <span class="sv-tag-dot" style={ "background:" + safeTagColor(tag.Color) }></span> | |
| 173 | 173 | <label><span>Name</span><input name="name" type="text" value={ tag.Name } required/></label> | |
| 174 | 174 | <label><span>Color</span><input name="color" type="text" value={ tag.Color }/></label> | |
| 175 | 175 | <button class="sv-button sv-button-secondary" type="submit">Save</button> | |
| @@ -80,7 +80,7 @@ templ FavoritesControls(data favoritesRenderData) { | |||||
| 80 | for _, tag := range data.Tags { | 80 | for _, tag := range data.Tags { | ||
| 81 | if tag != nil { | 81 | if tag != nil { | ||
| 82 | <button type="button" class={ favoriteFilterClass(favoriteTagFilterActive(data, tag.ID)) } data-sv-action="favorite.filterTag" data-tag-id={ fmt.Sprintf("%d", tag.ID) }> | 82 | <button type="button" class={ favoriteFilterClass(favoriteTagFilterActive(data, tag.ID)) } data-sv-action="favorite.filterTag" data-tag-id={ fmt.Sprintf("%d", tag.ID) }> | ||
| 83 | - | <span class="sv-tag-dot" style={ "background:" + tag.Color }></span>{ tag.Name } | 83 | + | <span class="sv-tag-dot" style={ "background:" + safeTagColor(tag.Color) }></span>{ tag.Name } |
| 84 | </button> | 84 | </button> | ||
| 85 | } | 85 | } | ||
| 86 | } | 86 | } | ||
| @@ -129,7 +129,7 @@ templ FavoriteTagControls(favorite *entity.Favorite, tags []*entity.Tag) { | |||||
| 129 | for _, tag := range tags { | 129 | for _, tag := range tags { | ||
| 130 | if tag != nil { | 130 | if tag != nil { | ||
| 131 | <button type="button" class={ favoriteTagButtonClass(favorite, tag) } data-sv-action={ favoriteTagButtonAction(favorite, tag) } data-favorite-id={ fmt.Sprintf("%d", favorite.ID) } data-tag-id={ fmt.Sprintf("%d", tag.ID) }> | 131 | <button type="button" class={ favoriteTagButtonClass(favorite, tag) } data-sv-action={ favoriteTagButtonAction(favorite, tag) } data-favorite-id={ fmt.Sprintf("%d", favorite.ID) } data-tag-id={ fmt.Sprintf("%d", tag.ID) }> | ||
| 132 | - | <span class="sv-tag-dot" style={ "background:" + tag.Color }></span>{ favoriteTagButtonLabel(favorite, tag) } | 132 | + | <span class="sv-tag-dot" style={ "background:" + safeTagColor(tag.Color) }></span>{ favoriteTagButtonLabel(favorite, tag) } |
| 133 | </button> | 133 | </button> | ||
| 134 | } | 134 | } | ||
| 135 | } | 135 | } | ||
| @@ -169,7 +169,7 @@ templ TagItem(tag *entity.Tag) { | |||||
| 169 | <li class="sv-list-row"> | 169 | <li class="sv-list-row"> | ||
| 170 | <form class="sv-inline-form" data-sv-action="tag.update"> | 170 | <form class="sv-inline-form" data-sv-action="tag.update"> | ||
| 171 | <input type="hidden" name="id" value={ fmt.Sprintf("%d", tag.ID) }/> | 171 | <input type="hidden" name="id" value={ fmt.Sprintf("%d", tag.ID) }/> | ||
| 172 | - | <span class="sv-tag-dot" style={ "background:" + tag.Color }></span> | 172 | + | <span class="sv-tag-dot" style={ "background:" + safeTagColor(tag.Color) }></span> |
| 173 | <label><span>Name</span><input name="name" type="text" value={ tag.Name } required/></label> | 173 | <label><span>Name</span><input name="name" type="text" value={ tag.Name } required/></label> | ||
| 174 | <label><span>Color</span><input name="color" type="text" value={ tag.Color }/></label> | 174 | <label><span>Color</span><input name="color" type="text" value={ tag.Color }/></label> | ||
| 175 | <button class="sv-button sv-button-secondary" type="submit">Save</button> | 175 | <button class="sv-button sv-button-secondary" type="submit">Save</button> | ||
M internal/ui/systemviews/favorites_templ.go +8 -8 generated file: collapsed
M internal/ui/systemviews/favorites_view.go +17 -0
| @@ -103,6 +103,23 @@ func favoriteTagButtonClass(favorite *entity.Favorite, tag *entity.Tag) string { | |||
| 103 | 103 | return classes | |
| 104 | 104 | } | |
| 105 | 105 | ||
| 106 | + | func safeTagColor(raw string) string { | |
| 107 | + | value := strings.TrimSpace(raw) | |
| 108 | + | if len(value) != 4 && len(value) != 7 { | |
| 109 | + | return "#808080" | |
| 110 | + | } | |
| 111 | + | if value[0] != '#' { | |
| 112 | + | return "#808080" | |
| 113 | + | } | |
| 114 | + | for _, ch := range value[1:] { | |
| 115 | + | if (ch >= '0' && ch <= '9') || (ch >= 'a' && ch <= 'f') || (ch >= 'A' && ch <= 'F') { | |
| 116 | + | continue | |
| 117 | + | } | |
| 118 | + | return "#808080" | |
| 119 | + | } | |
| 120 | + | return value | |
| 121 | + | } | |
| 122 | + | ||
| 106 | 123 | func folderDisplayName(folder *entity.Folder) string { | |
| 107 | 124 | if folder == nil { | |
| 108 | 125 | return "" | |
| @@ -103,6 +103,23 @@ func favoriteTagButtonClass(favorite *entity.Favorite, tag *entity.Tag) string { | |||||
| 103 | return classes | 103 | return classes | ||
| 104 | } | 104 | } | ||
| 105 | 105 | ||||
| 106 | + | func safeTagColor(raw string) string { | |||
| 107 | + | value := strings.TrimSpace(raw) | |||
| 108 | + | if len(value) != 4 && len(value) != 7 { | |||
| 109 | + | return "#808080" | |||
| 110 | + | } | |||
| 111 | + | if value[0] != '#' { | |||
| 112 | + | return "#808080" | |||
| 113 | + | } | |||
| 114 | + | for _, ch := range value[1:] { | |||
| 115 | + | if (ch >= '0' && ch <= '9') || (ch >= 'a' && ch <= 'f') || (ch >= 'A' && ch <= 'F') { | |||
| 116 | + | continue | |||
| 117 | + | } | |||
| 118 | + | return "#808080" | |||
| 119 | + | } | |||
| 120 | + | return value | |||
| 121 | + | } | |||
| 122 | + | ||||
| 106 | func folderDisplayName(folder *entity.Folder) string { | 123 | func folderDisplayName(folder *entity.Folder) string { | ||
| 107 | if folder == nil { | 124 | if folder == nil { | ||
| 108 | return "" | 125 | return "" | ||
M internal/ui/systemviews/history_actions.go +2 -0
| @@ -25,6 +25,8 @@ func (a *App) HandleDOMAction(ctx context.Context, event DOMAction) error { | |||
| 25 | 25 | if a == nil { | |
| 26 | 26 | return fmt.Errorf("app is nil") | |
| 27 | 27 | } | |
| 28 | + | a.mu.Lock() | |
| 29 | + | defer a.mu.Unlock() | |
| 28 | 30 | if a.currentRoute == RouteUnknown || a.currentRoute == "" { | |
| 29 | 31 | a.currentRoute = ParseRoute(a.deps.LocationURI) | |
| 30 | 32 | } | |
| @@ -25,6 +25,8 @@ func (a *App) HandleDOMAction(ctx context.Context, event DOMAction) error { | |||||
| 25 | if a == nil { | 25 | if a == nil { | ||
| 26 | return fmt.Errorf("app is nil") | 26 | return fmt.Errorf("app is nil") | ||
| 27 | } | 27 | } | ||
| 28 | + | a.mu.Lock() | |||
| 29 | + | defer a.mu.Unlock() | |||
| 28 | if a.currentRoute == RouteUnknown || a.currentRoute == "" { | 30 | if a.currentRoute == RouteUnknown || a.currentRoute == "" { | ||
| 29 | a.currentRoute = ParseRoute(a.deps.LocationURI) | 31 | a.currentRoute = ParseRoute(a.deps.LocationURI) | ||
| 30 | } | 32 | } | ||
M internal/ui/systemviews/href_test.go +12 -0
| @@ -33,3 +33,15 @@ func TestFavoriteItemsHTMLSanitizesHrefSchemes(t *testing.T) { | |||
| 33 | 33 | assert.Contains(t, html, `href="#"`) | |
| 34 | 34 | assert.Contains(t, html, `href="https://example.com"`) | |
| 35 | 35 | } | |
| 36 | + | ||
| 37 | + | func TestFavoritesHTMLSanitizesTagColors(t *testing.T) { | |
| 38 | + | t.Parallel() | |
| 39 | + | ||
| 40 | + | html := favoritesHTML(favoritesRenderData{ | |
| 41 | + | Favorites: []*entity.Favorite{{ID: 1, URL: "https://example.com", Title: "Example"}}, | |
| 42 | + | Tags: []*entity.Tag{{ID: 7, Name: "Bad", Color: `red;background:url(javascript:alert(1))`}}, | |
| 43 | + | }) | |
| 44 | + | ||
| 45 | + | require.NotContains(t, html, `style="background:red`) | |
| 46 | + | assert.Contains(t, html, "background:#808080") | |
| 47 | + | } | |
| @@ -33,3 +33,15 @@ func TestFavoriteItemsHTMLSanitizesHrefSchemes(t *testing.T) { | |||||
| 33 | assert.Contains(t, html, `href="#"`) | 33 | assert.Contains(t, html, `href="#"`) | ||
| 34 | assert.Contains(t, html, `href="https://example.com"`) | 34 | assert.Contains(t, html, `href="https://example.com"`) | ||
| 35 | } | 35 | } | ||
| 36 | + | ||||
| 37 | + | func TestFavoritesHTMLSanitizesTagColors(t *testing.T) { | |||
| 38 | + | t.Parallel() | |||
| 39 | + | ||||
| 40 | + | html := favoritesHTML(favoritesRenderData{ | |||
| 41 | + | Favorites: []*entity.Favorite{{ID: 1, URL: "https://example.com", Title: "Example"}}, | |||
| 42 | + | Tags: []*entity.Tag{{ID: 7, Name: "Bad", Color: `red;background:url(javascript:alert(1))`}}, | |||
| 43 | + | }) | |||
| 44 | + | ||||
| 45 | + | require.NotContains(t, html, `style="background:red`) | |||
| 46 | + | assert.Contains(t, html, "background:#808080") | |||
| 47 | + | } | |||