feat(systemviews): add history management actions
- commit
- db236290e1613966c29297fab66e50b601f1971a
- author
- brice · · 2026-04-24 02:19 UTC
- parent
- 3183f09
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
7 files · +855 -35
M assets/systemviews/systemviews.css +144 -0
| @@ -249,3 +249,147 @@ body { | |||
| 249 | 249 | color: var(--sv-muted, #9aa3b2); | |
| 250 | 250 | font-weight: 600; | |
| 251 | 251 | } | |
| 252 | + | .sv-alert { | |
| 253 | + | border-radius: 0.75rem; | |
| 254 | + | padding: 0.75rem 1rem; | |
| 255 | + | border: 1px solid var(--sv-border, #2a313d); | |
| 256 | + | } | |
| 257 | + | ||
| 258 | + | .sv-alert-success { | |
| 259 | + | background: color-mix(in srgb, var(--sv-accent, #8ab4ff) 14%, var(--sv-surface, #171a21)); | |
| 260 | + | } | |
| 261 | + | ||
| 262 | + | .sv-alert-error { | |
| 263 | + | background: color-mix(in srgb, #ef4444 12%, var(--sv-surface, #171a21)); | |
| 264 | + | border-color: color-mix(in srgb, #ef4444 65%, var(--sv-border, #2a313d)); | |
| 265 | + | } | |
| 266 | + | ||
| 267 | + | .sv-toolbar, | |
| 268 | + | .sv-button-row, | |
| 269 | + | .sv-active-filters, | |
| 270 | + | .sv-history-actions-panel, | |
| 271 | + | .sv-history-row-actions { | |
| 272 | + | display: flex; | |
| 273 | + | flex-wrap: wrap; | |
| 274 | + | gap: 0.5rem; | |
| 275 | + | align-items: center; | |
| 276 | + | } | |
| 277 | + | ||
| 278 | + | .sv-toolbar { | |
| 279 | + | align-items: end; | |
| 280 | + | } | |
| 281 | + | ||
| 282 | + | .sv-history-actions-panel { | |
| 283 | + | margin-top: 0.75rem; | |
| 284 | + | justify-content: space-between; | |
| 285 | + | } | |
| 286 | + | ||
| 287 | + | .sv-search-label { | |
| 288 | + | display: grid; | |
| 289 | + | gap: 0.25rem; | |
| 290 | + | min-width: min(24rem, 100%); | |
| 291 | + | color: var(--sv-muted, #9aa3b2); | |
| 292 | + | font-size: 12px; | |
| 293 | + | font-weight: 600; | |
| 294 | + | } | |
| 295 | + | ||
| 296 | + | .sv-search-label input { | |
| 297 | + | border: 1px solid var(--sv-border, #2a313d); | |
| 298 | + | border-radius: 0.55rem; | |
| 299 | + | padding: 0.55rem 0.65rem; | |
| 300 | + | background: var(--sv-surface-variant, #1d2129); | |
| 301 | + | color: var(--sv-fg, #e8ebf0); | |
| 302 | + | font: inherit; | |
| 303 | + | } | |
| 304 | + | ||
| 305 | + | .sv-button { | |
| 306 | + | display: inline-flex; | |
| 307 | + | align-items: center; | |
| 308 | + | justify-content: center; | |
| 309 | + | min-height: 2rem; | |
| 310 | + | border: 1px solid var(--sv-accent, #8ab4ff); | |
| 311 | + | border-radius: 0.55rem; | |
| 312 | + | padding: 0.35rem 0.65rem; | |
| 313 | + | background: var(--sv-accent, #8ab4ff); | |
| 314 | + | color: var(--sv-background, #0f1115); | |
| 315 | + | cursor: pointer; | |
| 316 | + | font: inherit; | |
| 317 | + | font-weight: 600; | |
| 318 | + | text-decoration: none; | |
| 319 | + | } | |
| 320 | + | ||
| 321 | + | .sv-button-secondary { | |
| 322 | + | border-color: var(--sv-border, #2a313d); | |
| 323 | + | background: var(--sv-surface-variant, #1d2129); | |
| 324 | + | color: var(--sv-fg, #e8ebf0); | |
| 325 | + | } | |
| 326 | + | ||
| 327 | + | .sv-button-danger { | |
| 328 | + | border-color: color-mix(in srgb, #ef4444 70%, var(--sv-border, #2a313d)); | |
| 329 | + | color: #fecaca; | |
| 330 | + | } | |
| 331 | + | ||
| 332 | + | .sv-button:disabled { | |
| 333 | + | cursor: not-allowed; | |
| 334 | + | opacity: 0.45; | |
| 335 | + | } | |
| 336 | + | ||
| 337 | + | .sv-filter-chip { | |
| 338 | + | display: inline-flex; | |
| 339 | + | gap: 0.35rem; | |
| 340 | + | align-items: center; | |
| 341 | + | border: 1px solid var(--sv-border, #2a313d); | |
| 342 | + | border-radius: 999px; | |
| 343 | + | padding: 0.25rem 0.55rem; | |
| 344 | + | background: var(--sv-surface-variant, #1d2129); | |
| 345 | + | } | |
| 346 | + | ||
| 347 | + | .sv-filter-chip button, | |
| 348 | + | .sv-domain-filter, | |
| 349 | + | .sv-icon-button { | |
| 350 | + | border: 0; | |
| 351 | + | background: transparent; | |
| 352 | + | color: inherit; | |
| 353 | + | cursor: pointer; | |
| 354 | + | font: inherit; | |
| 355 | + | } | |
| 356 | + | ||
| 357 | + | .sv-domain-filter { | |
| 358 | + | color: var(--sv-fg, #e8ebf0); | |
| 359 | + | font-weight: 700; | |
| 360 | + | padding: 0; | |
| 361 | + | text-align: left; | |
| 362 | + | } | |
| 363 | + | ||
| 364 | + | .sv-icon-button { | |
| 365 | + | display: inline-flex; | |
| 366 | + | align-items: center; | |
| 367 | + | justify-content: center; | |
| 368 | + | width: 1.4rem; | |
| 369 | + | height: 1.4rem; | |
| 370 | + | border-radius: 999px; | |
| 371 | + | } | |
| 372 | + | ||
| 373 | + | .sv-danger { | |
| 374 | + | color: #fecaca; | |
| 375 | + | } | |
| 376 | + | ||
| 377 | + | .sv-icon-button:hover, | |
| 378 | + | .sv-filter-chip button:hover, | |
| 379 | + | .sv-domain-filter:hover { | |
| 380 | + | background: color-mix(in srgb, var(--sv-accent, #8ab4ff) 12%, transparent); | |
| 381 | + | } | |
| 382 | + | ||
| 383 | + | .sv-history-item.sv-focused { | |
| 384 | + | outline: 2px solid var(--sv-accent, #8ab4ff); | |
| 385 | + | outline-offset: 0.35rem; | |
| 386 | + | border-radius: 0.5rem; | |
| 387 | + | } | |
| 388 | + | ||
| 389 | + | .sv-key-hints kbd { | |
| 390 | + | border: 1px solid var(--sv-border, #2a313d); | |
| 391 | + | border-radius: 0.35rem; | |
| 392 | + | padding: 0.1rem 0.3rem; | |
| 393 | + | background: var(--sv-surface-variant, #1d2129); | |
| 394 | + | font-family: ui-monospace, SFMono-Regular, Menlo, monospace; | |
| 395 | + | } | |
| @@ -249,3 +249,147 @@ body { | |||||
| 249 | color: var(--sv-muted, #9aa3b2); | 249 | color: var(--sv-muted, #9aa3b2); | ||
| 250 | font-weight: 600; | 250 | font-weight: 600; | ||
| 251 | } | 251 | } | ||
| 252 | + | .sv-alert { | |||
| 253 | + | border-radius: 0.75rem; | |||
| 254 | + | padding: 0.75rem 1rem; | |||
| 255 | + | border: 1px solid var(--sv-border, #2a313d); | |||
| 256 | + | } | |||
| 257 | + | ||||
| 258 | + | .sv-alert-success { | |||
| 259 | + | background: color-mix(in srgb, var(--sv-accent, #8ab4ff) 14%, var(--sv-surface, #171a21)); | |||
| 260 | + | } | |||
| 261 | + | ||||
| 262 | + | .sv-alert-error { | |||
| 263 | + | background: color-mix(in srgb, #ef4444 12%, var(--sv-surface, #171a21)); | |||
| 264 | + | border-color: color-mix(in srgb, #ef4444 65%, var(--sv-border, #2a313d)); | |||
| 265 | + | } | |||
| 266 | + | ||||
| 267 | + | .sv-toolbar, | |||
| 268 | + | .sv-button-row, | |||
| 269 | + | .sv-active-filters, | |||
| 270 | + | .sv-history-actions-panel, | |||
| 271 | + | .sv-history-row-actions { | |||
| 272 | + | display: flex; | |||
| 273 | + | flex-wrap: wrap; | |||
| 274 | + | gap: 0.5rem; | |||
| 275 | + | align-items: center; | |||
| 276 | + | } | |||
| 277 | + | ||||
| 278 | + | .sv-toolbar { | |||
| 279 | + | align-items: end; | |||
| 280 | + | } | |||
| 281 | + | ||||
| 282 | + | .sv-history-actions-panel { | |||
| 283 | + | margin-top: 0.75rem; | |||
| 284 | + | justify-content: space-between; | |||
| 285 | + | } | |||
| 286 | + | ||||
| 287 | + | .sv-search-label { | |||
| 288 | + | display: grid; | |||
| 289 | + | gap: 0.25rem; | |||
| 290 | + | min-width: min(24rem, 100%); | |||
| 291 | + | color: var(--sv-muted, #9aa3b2); | |||
| 292 | + | font-size: 12px; | |||
| 293 | + | font-weight: 600; | |||
| 294 | + | } | |||
| 295 | + | ||||
| 296 | + | .sv-search-label input { | |||
| 297 | + | border: 1px solid var(--sv-border, #2a313d); | |||
| 298 | + | border-radius: 0.55rem; | |||
| 299 | + | padding: 0.55rem 0.65rem; | |||
| 300 | + | background: var(--sv-surface-variant, #1d2129); | |||
| 301 | + | color: var(--sv-fg, #e8ebf0); | |||
| 302 | + | font: inherit; | |||
| 303 | + | } | |||
| 304 | + | ||||
| 305 | + | .sv-button { | |||
| 306 | + | display: inline-flex; | |||
| 307 | + | align-items: center; | |||
| 308 | + | justify-content: center; | |||
| 309 | + | min-height: 2rem; | |||
| 310 | + | border: 1px solid var(--sv-accent, #8ab4ff); | |||
| 311 | + | border-radius: 0.55rem; | |||
| 312 | + | padding: 0.35rem 0.65rem; | |||
| 313 | + | background: var(--sv-accent, #8ab4ff); | |||
| 314 | + | color: var(--sv-background, #0f1115); | |||
| 315 | + | cursor: pointer; | |||
| 316 | + | font: inherit; | |||
| 317 | + | font-weight: 600; | |||
| 318 | + | text-decoration: none; | |||
| 319 | + | } | |||
| 320 | + | ||||
| 321 | + | .sv-button-secondary { | |||
| 322 | + | border-color: var(--sv-border, #2a313d); | |||
| 323 | + | background: var(--sv-surface-variant, #1d2129); | |||
| 324 | + | color: var(--sv-fg, #e8ebf0); | |||
| 325 | + | } | |||
| 326 | + | ||||
| 327 | + | .sv-button-danger { | |||
| 328 | + | border-color: color-mix(in srgb, #ef4444 70%, var(--sv-border, #2a313d)); | |||
| 329 | + | color: #fecaca; | |||
| 330 | + | } | |||
| 331 | + | ||||
| 332 | + | .sv-button:disabled { | |||
| 333 | + | cursor: not-allowed; | |||
| 334 | + | opacity: 0.45; | |||
| 335 | + | } | |||
| 336 | + | ||||
| 337 | + | .sv-filter-chip { | |||
| 338 | + | display: inline-flex; | |||
| 339 | + | gap: 0.35rem; | |||
| 340 | + | align-items: center; | |||
| 341 | + | border: 1px solid var(--sv-border, #2a313d); | |||
| 342 | + | border-radius: 999px; | |||
| 343 | + | padding: 0.25rem 0.55rem; | |||
| 344 | + | background: var(--sv-surface-variant, #1d2129); | |||
| 345 | + | } | |||
| 346 | + | ||||
| 347 | + | .sv-filter-chip button, | |||
| 348 | + | .sv-domain-filter, | |||
| 349 | + | .sv-icon-button { | |||
| 350 | + | border: 0; | |||
| 351 | + | background: transparent; | |||
| 352 | + | color: inherit; | |||
| 353 | + | cursor: pointer; | |||
| 354 | + | font: inherit; | |||
| 355 | + | } | |||
| 356 | + | ||||
| 357 | + | .sv-domain-filter { | |||
| 358 | + | color: var(--sv-fg, #e8ebf0); | |||
| 359 | + | font-weight: 700; | |||
| 360 | + | padding: 0; | |||
| 361 | + | text-align: left; | |||
| 362 | + | } | |||
| 363 | + | ||||
| 364 | + | .sv-icon-button { | |||
| 365 | + | display: inline-flex; | |||
| 366 | + | align-items: center; | |||
| 367 | + | justify-content: center; | |||
| 368 | + | width: 1.4rem; | |||
| 369 | + | height: 1.4rem; | |||
| 370 | + | border-radius: 999px; | |||
| 371 | + | } | |||
| 372 | + | ||||
| 373 | + | .sv-danger { | |||
| 374 | + | color: #fecaca; | |||
| 375 | + | } | |||
| 376 | + | ||||
| 377 | + | .sv-icon-button:hover, | |||
| 378 | + | .sv-filter-chip button:hover, | |||
| 379 | + | .sv-domain-filter:hover { | |||
| 380 | + | background: color-mix(in srgb, var(--sv-accent, #8ab4ff) 12%, transparent); | |||
| 381 | + | } | |||
| 382 | + | ||||
| 383 | + | .sv-history-item.sv-focused { | |||
| 384 | + | outline: 2px solid var(--sv-accent, #8ab4ff); | |||
| 385 | + | outline-offset: 0.35rem; | |||
| 386 | + | border-radius: 0.5rem; | |||
| 387 | + | } | |||
| 388 | + | ||||
| 389 | + | .sv-key-hints kbd { | |||
| 390 | + | border: 1px solid var(--sv-border, #2a313d); | |||
| 391 | + | border-radius: 0.35rem; | |||
| 392 | + | padding: 0.1rem 0.3rem; | |||
| 393 | + | background: var(--sv-surface-variant, #1d2129); | |||
| 394 | + | font-family: ui-monospace, SFMono-Regular, Menlo, monospace; | |||
| 395 | + | } | |||
M internal/ui/systemviews/app.go +53 -17
| @@ -3,6 +3,7 @@ package systemviews | |||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | 5 | "errors" | |
| 6 | + | "strings" | |
| 6 | 7 | ||
| 7 | 8 | "github.com/bnema/dumber/internal/application/port" | |
| 8 | 9 | "github.com/bnema/dumber/internal/domain/entity" | |
| @@ -17,18 +18,23 @@ type Dependencies struct { | |||
| 17 | 18 | } | |
| 18 | 19 | ||
| 19 | 20 | type App struct { | |
| 20 | - | deps Dependencies | |
| 21 | - | currentRoute Route | |
| 22 | - | shellTheme shellTheme | |
| 23 | - | historyEntries []*entity.HistoryEntry | |
| 24 | - | historyAnalytics *entity.HistoryAnalytics | |
| 25 | - | historyDomainStats []*entity.DomainStat | |
| 26 | - | favorites []*entity.Favorite | |
| 27 | - | folders []*entity.Folder | |
| 28 | - | tags []*entity.Tag | |
| 29 | - | config *port.SystemviewConfigPayload | |
| 30 | - | keybindings any | |
| 31 | - | 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 | + | config *port.SystemviewConfigPayload | |
| 36 | + | keybindings any | |
| 37 | + | renderedHTML string | |
| 32 | 38 | } | |
| 33 | 39 | ||
| 34 | 40 | const historyTimelineLimit = 25 | |
| @@ -50,7 +56,17 @@ func (a *App) Run() error { | |||
| 50 | 56 | return errors.New("DOM not configured") | |
| 51 | 57 | } | |
| 52 | 58 | ||
| 53 | - | return a.deps.DOM.Mount(a.renderedHTML) | |
| 59 | + | if err := a.deps.DOM.Mount(a.renderedHTML); err != nil { | |
| 60 | + | return err | |
| 61 | + | } | |
| 62 | + | if binder, ok := a.deps.DOM.(DOMActionBinder); ok { | |
| 63 | + | return binder.BindActions(func(action DOMAction) { | |
| 64 | + | go func() { | |
| 65 | + | _ = a.HandleDOMAction(context.Background(), action) | |
| 66 | + | }() | |
| 67 | + | }) | |
| 68 | + | } | |
| 69 | + | return nil | |
| 54 | 70 | } | |
| 55 | 71 | ||
| 56 | 72 | func (a *App) LoadInitial(ctx context.Context) error { | |
| @@ -122,7 +138,7 @@ func (a *App) loadHistoryRoute(ctx context.Context) error { | |||
| 122 | 138 | return nil | |
| 123 | 139 | } | |
| 124 | 140 | ||
| 125 | - | entries, err := a.deps.History.Timeline(ctx, historyTimelineLimit, 0) | |
| 141 | + | entries, err := a.loadHistoryEntries(ctx) | |
| 126 | 142 | if err != nil { | |
| 127 | 143 | return err | |
| 128 | 144 | } | |
| @@ -137,6 +153,7 @@ func (a *App) loadHistoryRoute(ctx context.Context) error { | |||
| 137 | 153 | a.favorites = nil | |
| 138 | 154 | a.folders = nil | |
| 139 | 155 | a.tags = nil | |
| 156 | + | entries = filterHistoryEntriesByDomain(entries, a.historyDomainFilter) | |
| 140 | 157 | a.historyEntries = entries | |
| 141 | 158 | a.historyAnalytics = analytics | |
| 142 | 159 | a.historyDomainStats = domains | |
| @@ -144,14 +161,28 @@ func (a *App) loadHistoryRoute(ctx context.Context) error { | |||
| 144 | 161 | route: RouteHistory, | |
| 145 | 162 | subtitle: "Recent visits", | |
| 146 | 163 | body: historyHTML(historyRenderData{ | |
| 147 | - | Entries: entries, | |
| 148 | - | Analytics: analytics, | |
| 149 | - | Domains: domains, | |
| 164 | + | Entries: entries, | |
| 165 | + | Analytics: analytics, | |
| 166 | + | Domains: domains, | |
| 167 | + | Query: a.historyQuery, | |
| 168 | + | DomainFilter: a.historyDomainFilter, | |
| 169 | + | Offset: a.historyOffset, | |
| 170 | + | Limit: historyTimelineLimit, | |
| 171 | + | Notice: a.historyNotice, | |
| 172 | + | Error: a.historyError, | |
| 150 | 173 | }), | |
| 151 | 174 | }, a.shellTheme) | |
| 152 | 175 | return nil | |
| 153 | 176 | } | |
| 154 | 177 | ||
| 178 | + | func (a *App) loadHistoryEntries(ctx context.Context) ([]*entity.HistoryEntry, error) { | |
| 179 | + | query := strings.TrimSpace(a.historyQuery) | |
| 180 | + | if query != "" { | |
| 181 | + | return a.deps.History.Search(ctx, query, historyTimelineLimit) | |
| 182 | + | } | |
| 183 | + | return a.deps.History.Timeline(ctx, historyTimelineLimit, a.historyOffset) | |
| 184 | + | } | |
| 185 | + | ||
| 155 | 186 | func (a *App) loadFavoritesRoute(ctx context.Context) error { | |
| 156 | 187 | if a.deps.Favorites == nil { | |
| 157 | 188 | a.resetRouteState() | |
| @@ -242,6 +273,11 @@ func (a *App) resetRouteState() { | |||
| 242 | 273 | a.historyEntries = nil | |
| 243 | 274 | a.historyAnalytics = nil | |
| 244 | 275 | a.historyDomainStats = nil | |
| 276 | + | a.historyQuery = "" | |
| 277 | + | a.historyDomainFilter = "" | |
| 278 | + | a.historyOffset = 0 | |
| 279 | + | a.historyNotice = "" | |
| 280 | + | a.historyError = "" | |
| 245 | 281 | a.favorites = nil | |
| 246 | 282 | a.folders = nil | |
| 247 | 283 | a.tags = nil | |
| @@ -3,6 +3,7 @@ package systemviews | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | "errors" | 5 | "errors" | ||
| 6 | + | "strings" | |||
| 6 | 7 | ||||
| 7 | "github.com/bnema/dumber/internal/application/port" | 8 | "github.com/bnema/dumber/internal/application/port" | ||
| 8 | "github.com/bnema/dumber/internal/domain/entity" | 9 | "github.com/bnema/dumber/internal/domain/entity" | ||
| @@ -17,18 +18,23 @@ type Dependencies struct { | |||||
| 17 | } | 18 | } | ||
| 18 | 19 | ||||
| 19 | type App struct { | 20 | type App struct { | ||
| 20 | - | deps Dependencies | 21 | + | deps Dependencies |
| 21 | - | currentRoute Route | 22 | + | currentRoute Route |
| 22 | - | shellTheme shellTheme | 23 | + | shellTheme shellTheme |
| 23 | - | historyEntries []*entity.HistoryEntry | 24 | + | historyEntries []*entity.HistoryEntry |
| 24 | - | historyAnalytics *entity.HistoryAnalytics | 25 | + | historyAnalytics *entity.HistoryAnalytics |
| 25 | - | historyDomainStats []*entity.DomainStat | 26 | + | historyDomainStats []*entity.DomainStat |
| 26 | - | favorites []*entity.Favorite | 27 | + | historyQuery string |
| 27 | - | folders []*entity.Folder | 28 | + | historyDomainFilter string |
| 28 | - | tags []*entity.Tag | 29 | + | historyOffset int |
| 29 | - | config *port.SystemviewConfigPayload | 30 | + | historyNotice string |
| 30 | - | keybindings any | 31 | + | historyError string |
| 31 | - | renderedHTML string | 32 | + | favorites []*entity.Favorite |
| 33 | + | folders []*entity.Folder | |||
| 34 | + | tags []*entity.Tag | |||
| 35 | + | config *port.SystemviewConfigPayload | |||
| 36 | + | keybindings any | |||
| 37 | + | renderedHTML string | |||
| 32 | } | 38 | } | ||
| 33 | 39 | ||||
| 34 | const historyTimelineLimit = 25 | 40 | const historyTimelineLimit = 25 | ||
| @@ -50,7 +56,17 @@ func (a *App) Run() error { | |||||
| 50 | return errors.New("DOM not configured") | 56 | return errors.New("DOM not configured") | ||
| 51 | } | 57 | } | ||
| 52 | 58 | ||||
| 53 | - | return a.deps.DOM.Mount(a.renderedHTML) | 59 | + | if err := a.deps.DOM.Mount(a.renderedHTML); err != nil { |
| 60 | + | return err | |||
| 61 | + | } | |||
| 62 | + | if binder, ok := a.deps.DOM.(DOMActionBinder); ok { | |||
| 63 | + | return binder.BindActions(func(action DOMAction) { | |||
| 64 | + | go func() { | |||
| 65 | + | _ = a.HandleDOMAction(context.Background(), action) | |||
| 66 | + | }() | |||
| 67 | + | }) | |||
| 68 | + | } | |||
| 69 | + | return nil | |||
| 54 | } | 70 | } | ||
| 55 | 71 | ||||
| 56 | func (a *App) LoadInitial(ctx context.Context) error { | 72 | func (a *App) LoadInitial(ctx context.Context) error { | ||
| @@ -122,7 +138,7 @@ func (a *App) loadHistoryRoute(ctx context.Context) error { | |||||
| 122 | return nil | 138 | return nil | ||
| 123 | } | 139 | } | ||
| 124 | 140 | ||||
| 125 | - | entries, err := a.deps.History.Timeline(ctx, historyTimelineLimit, 0) | 141 | + | entries, err := a.loadHistoryEntries(ctx) |
| 126 | if err != nil { | 142 | if err != nil { | ||
| 127 | return err | 143 | return err | ||
| 128 | } | 144 | } | ||
| @@ -137,6 +153,7 @@ func (a *App) loadHistoryRoute(ctx context.Context) error { | |||||
| 137 | a.favorites = nil | 153 | a.favorites = nil | ||
| 138 | a.folders = nil | 154 | a.folders = nil | ||
| 139 | a.tags = nil | 155 | a.tags = nil | ||
| 156 | + | entries = filterHistoryEntriesByDomain(entries, a.historyDomainFilter) | |||
| 140 | a.historyEntries = entries | 157 | a.historyEntries = entries | ||
| 141 | a.historyAnalytics = analytics | 158 | a.historyAnalytics = analytics | ||
| 142 | a.historyDomainStats = domains | 159 | a.historyDomainStats = domains | ||
| @@ -144,14 +161,28 @@ func (a *App) loadHistoryRoute(ctx context.Context) error { | |||||
| 144 | route: RouteHistory, | 161 | route: RouteHistory, | ||
| 145 | subtitle: "Recent visits", | 162 | subtitle: "Recent visits", | ||
| 146 | body: historyHTML(historyRenderData{ | 163 | body: historyHTML(historyRenderData{ | ||
| 147 | - | Entries: entries, | 164 | + | Entries: entries, |
| 148 | - | Analytics: analytics, | 165 | + | Analytics: analytics, |
| 149 | - | Domains: domains, | 166 | + | Domains: domains, |
| 167 | + | Query: a.historyQuery, | |||
| 168 | + | DomainFilter: a.historyDomainFilter, | |||
| 169 | + | Offset: a.historyOffset, | |||
| 170 | + | Limit: historyTimelineLimit, | |||
| 171 | + | Notice: a.historyNotice, | |||
| 172 | + | Error: a.historyError, | |||
| 150 | }), | 173 | }), | ||
| 151 | }, a.shellTheme) | 174 | }, a.shellTheme) | ||
| 152 | return nil | 175 | return nil | ||
| 153 | } | 176 | } | ||
| 154 | 177 | ||||
| 178 | + | func (a *App) loadHistoryEntries(ctx context.Context) ([]*entity.HistoryEntry, error) { | |||
| 179 | + | query := strings.TrimSpace(a.historyQuery) | |||
| 180 | + | if query != "" { | |||
| 181 | + | return a.deps.History.Search(ctx, query, historyTimelineLimit) | |||
| 182 | + | } | |||
| 183 | + | return a.deps.History.Timeline(ctx, historyTimelineLimit, a.historyOffset) | |||
| 184 | + | } | |||
| 185 | + | ||||
| 155 | func (a *App) loadFavoritesRoute(ctx context.Context) error { | 186 | func (a *App) loadFavoritesRoute(ctx context.Context) error { | ||
| 156 | if a.deps.Favorites == nil { | 187 | if a.deps.Favorites == nil { | ||
| 157 | a.resetRouteState() | 188 | a.resetRouteState() | ||
| @@ -242,6 +273,11 @@ func (a *App) resetRouteState() { | |||||
| 242 | a.historyEntries = nil | 273 | a.historyEntries = nil | ||
| 243 | a.historyAnalytics = nil | 274 | a.historyAnalytics = nil | ||
| 244 | a.historyDomainStats = nil | 275 | a.historyDomainStats = nil | ||
| 276 | + | a.historyQuery = "" | |||
| 277 | + | a.historyDomainFilter = "" | |||
| 278 | + | a.historyOffset = 0 | |||
| 279 | + | a.historyNotice = "" | |||
| 280 | + | a.historyError = "" | |||
| 245 | a.favorites = nil | 281 | a.favorites = nil | ||
| 246 | a.folders = nil | 282 | a.folders = nil | ||
| 247 | a.tags = nil | 283 | a.tags = nil | ||
M internal/ui/systemviews/app_test.go +99 -6
| @@ -85,6 +85,74 @@ func TestAppLoadInitialHistoryRouteUsesStyledSections(t *testing.T) { | |||
| 85 | 85 | assert.Contains(t, app.renderedHTML, `class="sv-list"`) | |
| 86 | 86 | } | |
| 87 | 87 | ||
| 88 | + | func TestAppLoadInitialHistoryRouteRendersManagementActions(t *testing.T) { | |
| 89 | + | t.Parallel() | |
| 90 | + | ||
| 91 | + | history := &fakeHistoryService{ | |
| 92 | + | entries: []*entity.HistoryEntry{{ | |
| 93 | + | ID: 42, | |
| 94 | + | URL: "https://example.com/page", | |
| 95 | + | Title: "Example", | |
| 96 | + | }}, | |
| 97 | + | domainStats: []*entity.DomainStat{{Domain: "example.com", PageCount: 1, TotalVisits: 3}}, | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | app := NewApp(Dependencies{ | |
| 101 | + | History: history, | |
| 102 | + | LocationURI: "dumb://history", | |
| 103 | + | }) | |
| 104 | + | ||
| 105 | + | require.NoError(t, app.LoadInitial(context.Background())) | |
| 106 | + | assert.Contains(t, app.renderedHTML, `data-sv-action="history.search"`) | |
| 107 | + | assert.Contains(t, app.renderedHTML, `data-sv-action="history.deleteEntry"`) | |
| 108 | + | assert.Contains(t, app.renderedHTML, `data-id="42"`) | |
| 109 | + | assert.Contains(t, app.renderedHTML, `data-sv-action="history.deleteRange"`) | |
| 110 | + | assert.Contains(t, app.renderedHTML, `data-range="hour"`) | |
| 111 | + | assert.Contains(t, app.renderedHTML, `data-sv-action="history.filterDomain"`) | |
| 112 | + | assert.Contains(t, app.renderedHTML, `data-sv-action="history.deleteDomain"`) | |
| 113 | + | assert.Contains(t, app.renderedHTML, "Keys:") | |
| 114 | + | assert.Contains(t, app.renderedHTML, "Enter") | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | func TestAppHandleHistoryActionsRefreshesDOM(t *testing.T) { | |
| 118 | + | dom := &fakeDOM{} | |
| 119 | + | history := &fakeHistoryService{ | |
| 120 | + | entries: []*entity.HistoryEntry{{ID: 42, URL: "https://example.com", Title: "Example"}}, | |
| 121 | + | searchEntries: []*entity.HistoryEntry{{ID: 7, URL: "https://search.example", Title: "Search result"}}, | |
| 122 | + | } | |
| 123 | + | app := NewApp(Dependencies{DOM: dom, History: history, LocationURI: "dumb://history"}) | |
| 124 | + | ||
| 125 | + | require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{ | |
| 126 | + | Action: historyActionSearch, | |
| 127 | + | Data: map[string]string{"query": " example "}, | |
| 128 | + | })) | |
| 129 | + | assert.True(t, history.searchCalled) | |
| 130 | + | assert.Equal(t, "example", history.query) | |
| 131 | + | assert.Contains(t, dom.html, "Search result") | |
| 132 | + | assert.Contains(t, dom.html, "Query: example") | |
| 133 | + | ||
| 134 | + | require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{ | |
| 135 | + | Action: historyActionDeleteEntry, | |
| 136 | + | Data: map[string]string{"id": "42"}, | |
| 137 | + | })) | |
| 138 | + | assert.Equal(t, int64(42), history.deletedEntryID) | |
| 139 | + | assert.Contains(t, dom.html, "Deleted history entry") | |
| 140 | + | ||
| 141 | + | require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{ | |
| 142 | + | Action: historyActionDeleteRange, | |
| 143 | + | Data: map[string]string{"range": "week"}, | |
| 144 | + | })) | |
| 145 | + | assert.Equal(t, "week", history.deletedRangeID) | |
| 146 | + | assert.Contains(t, dom.html, "Deleted history from this week") | |
| 147 | + | ||
| 148 | + | require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{ | |
| 149 | + | Action: historyActionDeleteDomain, | |
| 150 | + | Data: map[string]string{"domain": "example.com"}, | |
| 151 | + | })) | |
| 152 | + | assert.Equal(t, "example.com", history.deletedDomain) | |
| 153 | + | assert.Contains(t, dom.html, "Deleted history for example.com") | |
| 154 | + | } | |
| 155 | + | ||
| 88 | 156 | func TestAppLoadInitialHistoryRouteRendersErrorState(t *testing.T) { | |
| 89 | 157 | t.Parallel() | |
| 90 | 158 | ||
| @@ -338,6 +406,16 @@ type fakeHistoryService struct { | |||
| 338 | 406 | offset int | |
| 339 | 407 | entries []*entity.HistoryEntry | |
| 340 | 408 | err error | |
| 409 | + | ||
| 410 | + | searchCalled bool | |
| 411 | + | query string | |
| 412 | + | searchLimit int | |
| 413 | + | searchEntries []*entity.HistoryEntry | |
| 414 | + | ||
| 415 | + | deletedEntryID int64 | |
| 416 | + | deletedRangeID string | |
| 417 | + | deletedDomain string | |
| 418 | + | domainStats []*entity.DomainStat | |
| 341 | 419 | } | |
| 342 | 420 | ||
| 343 | 421 | func (s *fakeHistoryService) Timeline(_ context.Context, limit, offset int) ([]*entity.HistoryEntry, error) { | |
| @@ -347,23 +425,38 @@ func (s *fakeHistoryService) Timeline(_ context.Context, limit, offset int) ([]* | |||
| 347 | 425 | return s.entries, s.err | |
| 348 | 426 | } | |
| 349 | 427 | ||
| 350 | - | func (s *fakeHistoryService) Search(context.Context, string, int) ([]*entity.HistoryEntry, error) { | |
| 351 | - | return nil, nil | |
| 428 | + | func (s *fakeHistoryService) Search(_ context.Context, query string, limit int) ([]*entity.HistoryEntry, error) { | |
| 429 | + | s.searchCalled = true | |
| 430 | + | s.query = query | |
| 431 | + | s.searchLimit = limit | |
| 432 | + | if s.searchEntries != nil { | |
| 433 | + | return s.searchEntries, nil | |
| 434 | + | } | |
| 435 | + | return s.entries, nil | |
| 352 | 436 | } | |
| 353 | 437 | ||
| 354 | - | func (s *fakeHistoryService) DeleteEntry(context.Context, int64) error { return nil } | |
| 438 | + | func (s *fakeHistoryService) DeleteEntry(_ context.Context, id int64) error { | |
| 439 | + | s.deletedEntryID = id | |
| 440 | + | return nil | |
| 441 | + | } | |
| 355 | 442 | ||
| 356 | - | func (s *fakeHistoryService) DeleteRange(context.Context, string) error { return nil } | |
| 443 | + | func (s *fakeHistoryService) DeleteRange(_ context.Context, rangeID string) error { | |
| 444 | + | s.deletedRangeID = rangeID | |
| 445 | + | return nil | |
| 446 | + | } | |
| 357 | 447 | ||
| 358 | 448 | func (s *fakeHistoryService) Analytics(context.Context) (*entity.HistoryAnalytics, error) { | |
| 359 | 449 | return nil, nil | |
| 360 | 450 | } | |
| 361 | 451 | ||
| 362 | 452 | func (s *fakeHistoryService) DomainStats(context.Context, int) ([]*entity.DomainStat, error) { | |
| 363 | - | return nil, nil | |
| 453 | + | return s.domainStats, nil | |
| 364 | 454 | } | |
| 365 | 455 | ||
| 366 | - | func (s *fakeHistoryService) DeleteDomain(context.Context, string) error { return nil } | |
| 456 | + | func (s *fakeHistoryService) DeleteDomain(_ context.Context, domain string) error { | |
| 457 | + | s.deletedDomain = domain | |
| 458 | + | return nil | |
| 459 | + | } | |
| 367 | 460 | ||
| 368 | 461 | type fakeFavoritesService struct { | |
| 369 | 462 | calledList bool | |
| @@ -85,6 +85,74 @@ func TestAppLoadInitialHistoryRouteUsesStyledSections(t *testing.T) { | |||||
| 85 | assert.Contains(t, app.renderedHTML, `class="sv-list"`) | 85 | assert.Contains(t, app.renderedHTML, `class="sv-list"`) | ||
| 86 | } | 86 | } | ||
| 87 | 87 | ||||
| 88 | + | func TestAppLoadInitialHistoryRouteRendersManagementActions(t *testing.T) { | |||
| 89 | + | t.Parallel() | |||
| 90 | + | ||||
| 91 | + | history := &fakeHistoryService{ | |||
| 92 | + | entries: []*entity.HistoryEntry{{ | |||
| 93 | + | ID: 42, | |||
| 94 | + | URL: "https://example.com/page", | |||
| 95 | + | Title: "Example", | |||
| 96 | + | }}, | |||
| 97 | + | domainStats: []*entity.DomainStat{{Domain: "example.com", PageCount: 1, TotalVisits: 3}}, | |||
| 98 | + | } | |||
| 99 | + | ||||
| 100 | + | app := NewApp(Dependencies{ | |||
| 101 | + | History: history, | |||
| 102 | + | LocationURI: "dumb://history", | |||
| 103 | + | }) | |||
| 104 | + | ||||
| 105 | + | require.NoError(t, app.LoadInitial(context.Background())) | |||
| 106 | + | assert.Contains(t, app.renderedHTML, `data-sv-action="history.search"`) | |||
| 107 | + | assert.Contains(t, app.renderedHTML, `data-sv-action="history.deleteEntry"`) | |||
| 108 | + | assert.Contains(t, app.renderedHTML, `data-id="42"`) | |||
| 109 | + | assert.Contains(t, app.renderedHTML, `data-sv-action="history.deleteRange"`) | |||
| 110 | + | assert.Contains(t, app.renderedHTML, `data-range="hour"`) | |||
| 111 | + | assert.Contains(t, app.renderedHTML, `data-sv-action="history.filterDomain"`) | |||
| 112 | + | assert.Contains(t, app.renderedHTML, `data-sv-action="history.deleteDomain"`) | |||
| 113 | + | assert.Contains(t, app.renderedHTML, "Keys:") | |||
| 114 | + | assert.Contains(t, app.renderedHTML, "Enter") | |||
| 115 | + | } | |||
| 116 | + | ||||
| 117 | + | func TestAppHandleHistoryActionsRefreshesDOM(t *testing.T) { | |||
| 118 | + | dom := &fakeDOM{} | |||
| 119 | + | history := &fakeHistoryService{ | |||
| 120 | + | entries: []*entity.HistoryEntry{{ID: 42, URL: "https://example.com", Title: "Example"}}, | |||
| 121 | + | searchEntries: []*entity.HistoryEntry{{ID: 7, URL: "https://search.example", Title: "Search result"}}, | |||
| 122 | + | } | |||
| 123 | + | app := NewApp(Dependencies{DOM: dom, History: history, LocationURI: "dumb://history"}) | |||
| 124 | + | ||||
| 125 | + | require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{ | |||
| 126 | + | Action: historyActionSearch, | |||
| 127 | + | Data: map[string]string{"query": " example "}, | |||
| 128 | + | })) | |||
| 129 | + | assert.True(t, history.searchCalled) | |||
| 130 | + | assert.Equal(t, "example", history.query) | |||
| 131 | + | assert.Contains(t, dom.html, "Search result") | |||
| 132 | + | assert.Contains(t, dom.html, "Query: example") | |||
| 133 | + | ||||
| 134 | + | require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{ | |||
| 135 | + | Action: historyActionDeleteEntry, | |||
| 136 | + | Data: map[string]string{"id": "42"}, | |||
| 137 | + | })) | |||
| 138 | + | assert.Equal(t, int64(42), history.deletedEntryID) | |||
| 139 | + | assert.Contains(t, dom.html, "Deleted history entry") | |||
| 140 | + | ||||
| 141 | + | require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{ | |||
| 142 | + | Action: historyActionDeleteRange, | |||
| 143 | + | Data: map[string]string{"range": "week"}, | |||
| 144 | + | })) | |||
| 145 | + | assert.Equal(t, "week", history.deletedRangeID) | |||
| 146 | + | assert.Contains(t, dom.html, "Deleted history from this week") | |||
| 147 | + | ||||
| 148 | + | require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{ | |||
| 149 | + | Action: historyActionDeleteDomain, | |||
| 150 | + | Data: map[string]string{"domain": "example.com"}, | |||
| 151 | + | })) | |||
| 152 | + | assert.Equal(t, "example.com", history.deletedDomain) | |||
| 153 | + | assert.Contains(t, dom.html, "Deleted history for example.com") | |||
| 154 | + | } | |||
| 155 | + | ||||
| 88 | func TestAppLoadInitialHistoryRouteRendersErrorState(t *testing.T) { | 156 | func TestAppLoadInitialHistoryRouteRendersErrorState(t *testing.T) { | ||
| 89 | t.Parallel() | 157 | t.Parallel() | ||
| 90 | 158 | ||||
| @@ -338,6 +406,16 @@ type fakeHistoryService struct { | |||||
| 338 | offset int | 406 | offset int | ||
| 339 | entries []*entity.HistoryEntry | 407 | entries []*entity.HistoryEntry | ||
| 340 | err error | 408 | err error | ||
| 409 | + | ||||
| 410 | + | searchCalled bool | |||
| 411 | + | query string | |||
| 412 | + | searchLimit int | |||
| 413 | + | searchEntries []*entity.HistoryEntry | |||
| 414 | + | ||||
| 415 | + | deletedEntryID int64 | |||
| 416 | + | deletedRangeID string | |||
| 417 | + | deletedDomain string | |||
| 418 | + | domainStats []*entity.DomainStat | |||
| 341 | } | 419 | } | ||
| 342 | 420 | ||||
| 343 | func (s *fakeHistoryService) Timeline(_ context.Context, limit, offset int) ([]*entity.HistoryEntry, error) { | 421 | func (s *fakeHistoryService) Timeline(_ context.Context, limit, offset int) ([]*entity.HistoryEntry, error) { | ||
| @@ -347,23 +425,38 @@ func (s *fakeHistoryService) Timeline(_ context.Context, limit, offset int) ([]* | |||||
| 347 | return s.entries, s.err | 425 | return s.entries, s.err | ||
| 348 | } | 426 | } | ||
| 349 | 427 | ||||
| 350 | - | func (s *fakeHistoryService) Search(context.Context, string, int) ([]*entity.HistoryEntry, error) { | 428 | + | func (s *fakeHistoryService) Search(_ context.Context, query string, limit int) ([]*entity.HistoryEntry, error) { |
| 351 | - | return nil, nil | 429 | + | s.searchCalled = true |
| 430 | + | s.query = query | |||
| 431 | + | s.searchLimit = limit | |||
| 432 | + | if s.searchEntries != nil { | |||
| 433 | + | return s.searchEntries, nil | |||
| 434 | + | } | |||
| 435 | + | return s.entries, nil | |||
| 352 | } | 436 | } | ||
| 353 | 437 | ||||
| 354 | - | func (s *fakeHistoryService) DeleteEntry(context.Context, int64) error { return nil } | 438 | + | func (s *fakeHistoryService) DeleteEntry(_ context.Context, id int64) error { |
| 439 | + | s.deletedEntryID = id | |||
| 440 | + | return nil | |||
| 441 | + | } | |||
| 355 | 442 | ||||
| 356 | - | func (s *fakeHistoryService) DeleteRange(context.Context, string) error { return nil } | 443 | + | func (s *fakeHistoryService) DeleteRange(_ context.Context, rangeID string) error { |
| 444 | + | s.deletedRangeID = rangeID | |||
| 445 | + | return nil | |||
| 446 | + | } | |||
| 357 | 447 | ||||
| 358 | func (s *fakeHistoryService) Analytics(context.Context) (*entity.HistoryAnalytics, error) { | 448 | func (s *fakeHistoryService) Analytics(context.Context) (*entity.HistoryAnalytics, error) { | ||
| 359 | return nil, nil | 449 | return nil, nil | ||
| 360 | } | 450 | } | ||
| 361 | 451 | ||||
| 362 | func (s *fakeHistoryService) DomainStats(context.Context, int) ([]*entity.DomainStat, error) { | 452 | func (s *fakeHistoryService) DomainStats(context.Context, int) ([]*entity.DomainStat, error) { | ||
| 363 | - | return nil, nil | 453 | + | return s.domainStats, nil |
| 364 | } | 454 | } | ||
| 365 | 455 | ||||
| 366 | - | func (s *fakeHistoryService) DeleteDomain(context.Context, string) error { return nil } | 456 | + | func (s *fakeHistoryService) DeleteDomain(_ context.Context, domain string) error { |
| 457 | + | s.deletedDomain = domain | |||
| 458 | + | return nil | |||
| 459 | + | } | |||
| 367 | 460 | ||||
| 368 | type fakeFavoritesService struct { | 461 | type fakeFavoritesService struct { | ||
| 369 | calledList bool | 462 | calledList bool | ||
M internal/ui/systemviews/dom.go +15 -0
| @@ -9,6 +9,21 @@ type DOM interface { | |||
| 9 | 9 | Mount(html string) error | |
| 10 | 10 | } | |
| 11 | 11 | ||
| 12 | + | // DOMAction is a browser event delegated from the mounted systemview DOM. | |
| 13 | + | type DOMAction struct { | |
| 14 | + | Action string | |
| 15 | + | Data map[string]string | |
| 16 | + | } | |
| 17 | + | ||
| 18 | + | // DOMActionHandler handles delegated browser actions. | |
| 19 | + | type DOMActionHandler func(DOMAction) | |
| 20 | + | ||
| 21 | + | // DOMActionBinder is implemented by DOM adapters that can delegate events back | |
| 22 | + | // to the App. Tests can keep using the smaller DOM interface. | |
| 23 | + | type DOMActionBinder interface { | |
| 24 | + | BindActions(handler DOMActionHandler) error | |
| 25 | + | } | |
| 26 | + | ||
| 12 | 27 | func placeholderHTML(route Route) string { | |
| 13 | 28 | name := html.EscapeString(string(route)) | |
| 14 | 29 | if name == "" || route == RouteUnknown { | |
| @@ -9,6 +9,21 @@ type DOM interface { | |||||
| 9 | Mount(html string) error | 9 | Mount(html string) error | ||
| 10 | } | 10 | } | ||
| 11 | 11 | ||||
| 12 | + | // DOMAction is a browser event delegated from the mounted systemview DOM. | |||
| 13 | + | type DOMAction struct { | |||
| 14 | + | Action string | |||
| 15 | + | Data map[string]string | |||
| 16 | + | } | |||
| 17 | + | ||||
| 18 | + | // DOMActionHandler handles delegated browser actions. | |||
| 19 | + | type DOMActionHandler func(DOMAction) | |||
| 20 | + | ||||
| 21 | + | // DOMActionBinder is implemented by DOM adapters that can delegate events back | |||
| 22 | + | // to the App. Tests can keep using the smaller DOM interface. | |||
| 23 | + | type DOMActionBinder interface { | |||
| 24 | + | BindActions(handler DOMActionHandler) error | |||
| 25 | + | } | |||
| 26 | + | ||||
| 12 | func placeholderHTML(route Route) string { | 27 | func placeholderHTML(route Route) string { | ||
| 13 | name := html.EscapeString(string(route)) | 28 | name := html.EscapeString(string(route)) | ||
| 14 | if name == "" || route == RouteUnknown { | 29 | if name == "" || route == RouteUnknown { | ||
M internal/ui/systemviews/dom_js.go +218 -0
| @@ -4,12 +4,15 @@ package systemviews | |||
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "fmt" | |
| 7 | + | "strconv" | |
| 8 | + | "strings" | |
| 7 | 9 | "syscall/js" | |
| 8 | 10 | ) | |
| 9 | 11 | ||
| 10 | 12 | type browserDOM struct { | |
| 11 | 13 | document js.Value | |
| 12 | 14 | target js.Value | |
| 15 | + | bindings []js.Func | |
| 13 | 16 | } | |
| 14 | 17 | ||
| 15 | 18 | func NewDOM() DOM { | |
| @@ -33,3 +36,218 @@ func (d *browserDOM) Mount(html string) error { | |||
| 33 | 36 | d.target.Set("innerHTML", html) | |
| 34 | 37 | return nil | |
| 35 | 38 | } | |
| 39 | + | ||
| 40 | + | func (d *browserDOM) BindActions(handler DOMActionHandler) error { | |
| 41 | + | if d == nil || !d.target.Truthy() || handler == nil { | |
| 42 | + | return fmt.Errorf("DOM action binding target not available") | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | clickHandler := js.FuncOf(func(_ js.Value, args []js.Value) any { | |
| 46 | + | event := firstJSArg(args) | |
| 47 | + | if !event.Truthy() { | |
| 48 | + | return nil | |
| 49 | + | } | |
| 50 | + | element := closestActionElement(event) | |
| 51 | + | if !element.Truthy() { | |
| 52 | + | return nil | |
| 53 | + | } | |
| 54 | + | action := element.Call("getAttribute", "data-sv-action").String() | |
| 55 | + | if action == "" { | |
| 56 | + | return nil | |
| 57 | + | } | |
| 58 | + | if !confirmAction(element) { | |
| 59 | + | return nil | |
| 60 | + | } | |
| 61 | + | event.Call("preventDefault") | |
| 62 | + | handler(DOMAction{Action: action, Data: collectActionData(element)}) | |
| 63 | + | return nil | |
| 64 | + | }) | |
| 65 | + | d.target.Call("addEventListener", "click", clickHandler) | |
| 66 | + | d.bindings = append(d.bindings, clickHandler) | |
| 67 | + | ||
| 68 | + | submitHandler := js.FuncOf(func(_ js.Value, args []js.Value) any { | |
| 69 | + | event := firstJSArg(args) | |
| 70 | + | if !event.Truthy() { | |
| 71 | + | return nil | |
| 72 | + | } | |
| 73 | + | form := event.Get("target") | |
| 74 | + | if !form.Truthy() { | |
| 75 | + | return nil | |
| 76 | + | } | |
| 77 | + | action := form.Call("getAttribute", "data-sv-action").String() | |
| 78 | + | if action == "" { | |
| 79 | + | return nil | |
| 80 | + | } | |
| 81 | + | event.Call("preventDefault") | |
| 82 | + | data := collectActionData(form) | |
| 83 | + | if queryInput := form.Call("querySelector", `[name="query"]`); queryInput.Truthy() { | |
| 84 | + | data["query"] = queryInput.Get("value").String() | |
| 85 | + | } | |
| 86 | + | handler(DOMAction{Action: action, Data: data}) | |
| 87 | + | return nil | |
| 88 | + | }) | |
| 89 | + | d.target.Call("addEventListener", "submit", submitHandler) | |
| 90 | + | d.bindings = append(d.bindings, submitHandler) | |
| 91 | + | ||
| 92 | + | keydownHandler := js.FuncOf(func(_ js.Value, args []js.Value) any { | |
| 93 | + | d.handleHistoryKeyboard(firstJSArg(args)) | |
| 94 | + | return nil | |
| 95 | + | }) | |
| 96 | + | d.document.Call("addEventListener", "keydown", keydownHandler) | |
| 97 | + | d.bindings = append(d.bindings, keydownHandler) | |
| 98 | + | ||
| 99 | + | return nil | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | func firstJSArg(args []js.Value) js.Value { | |
| 103 | + | if len(args) == 0 { | |
| 104 | + | return js.Value{} | |
| 105 | + | } | |
| 106 | + | return args[0] | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | func closestActionElement(event js.Value) js.Value { | |
| 110 | + | target := event.Get("target") | |
| 111 | + | if !target.Truthy() || !target.Get("closest").Truthy() { | |
| 112 | + | return js.Value{} | |
| 113 | + | } | |
| 114 | + | return target.Call("closest", "[data-sv-action]") | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | func collectActionData(element js.Value) map[string]string { | |
| 118 | + | data := map[string]string{} | |
| 119 | + | if !element.Truthy() { | |
| 120 | + | return data | |
| 121 | + | } | |
| 122 | + | dataset := element.Get("dataset") | |
| 123 | + | if !dataset.Truthy() { | |
| 124 | + | return data | |
| 125 | + | } | |
| 126 | + | keys := js.Global().Get("Object").Call("keys", dataset) | |
| 127 | + | for i := 0; i < keys.Get("length").Int(); i++ { | |
| 128 | + | key := keys.Index(i).String() | |
| 129 | + | if key == "svAction" || key == "svConfirm" { | |
| 130 | + | continue | |
| 131 | + | } | |
| 132 | + | data[key] = dataset.Get(key).String() | |
| 133 | + | } | |
| 134 | + | return data | |
| 135 | + | } | |
| 136 | + | ||
| 137 | + | func confirmAction(element js.Value) bool { | |
| 138 | + | message := element.Call("getAttribute", "data-sv-confirm").String() | |
| 139 | + | if message == "" { | |
| 140 | + | return true | |
| 141 | + | } | |
| 142 | + | confirm := js.Global().Get("window").Get("confirm") | |
| 143 | + | if !confirm.Truthy() { | |
| 144 | + | return true | |
| 145 | + | } | |
| 146 | + | return confirm.Invoke(message).Bool() | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | func (d *browserDOM) handleHistoryKeyboard(event js.Value) { | |
| 150 | + | if d == nil || !d.target.Truthy() || !event.Truthy() || !d.isHistoryRoute() { | |
| 151 | + | return | |
| 152 | + | } | |
| 153 | + | key := strings.ToLower(event.Get("key").String()) | |
| 154 | + | target := event.Get("target") | |
| 155 | + | if isEditableTarget(target) && key != "escape" { | |
| 156 | + | return | |
| 157 | + | } | |
| 158 | + | ||
| 159 | + | switch key { | |
| 160 | + | case "/": | |
| 161 | + | if input := d.target.Call("querySelector", `[data-sv-history-search]`); input.Truthy() { | |
| 162 | + | event.Call("preventDefault") | |
| 163 | + | input.Call("focus") | |
| 164 | + | } | |
| 165 | + | case "j", "arrowdown": | |
| 166 | + | event.Call("preventDefault") | |
| 167 | + | d.focusHistoryRow(1) | |
| 168 | + | case "k", "arrowup": | |
| 169 | + | event.Call("preventDefault") | |
| 170 | + | d.focusHistoryRow(-1) | |
| 171 | + | case "enter": | |
| 172 | + | if row := d.activeHistoryRow(); row.Truthy() { | |
| 173 | + | event.Call("preventDefault") | |
| 174 | + | if link := row.Call("querySelector", ".sv-history-open"); link.Truthy() { | |
| 175 | + | link.Call("click") | |
| 176 | + | } | |
| 177 | + | } | |
| 178 | + | case "d", "delete", "backspace": | |
| 179 | + | if row := d.activeHistoryRow(); row.Truthy() { | |
| 180 | + | event.Call("preventDefault") | |
| 181 | + | if button := row.Call("querySelector", `[data-sv-action="history.deleteEntry"]`); button.Truthy() { | |
| 182 | + | button.Call("click") | |
| 183 | + | } | |
| 184 | + | } | |
| 185 | + | case "escape": | |
| 186 | + | if target.Truthy() && isEditableTarget(target) { | |
| 187 | + | target.Call("blur") | |
| 188 | + | } | |
| 189 | + | } | |
| 190 | + | } | |
| 191 | + | ||
| 192 | + | func (d *browserDOM) isHistoryRoute() bool { | |
| 193 | + | return d.target.Call("querySelector", `[data-route="history"]`).Truthy() | |
| 194 | + | } | |
| 195 | + | ||
| 196 | + | func isEditableTarget(target js.Value) bool { | |
| 197 | + | if !target.Truthy() { | |
| 198 | + | return false | |
| 199 | + | } | |
| 200 | + | tag := strings.ToLower(target.Get("tagName").String()) | |
| 201 | + | return tag == "input" || tag == "textarea" || tag == "select" || target.Get("isContentEditable").Bool() | |
| 202 | + | } | |
| 203 | + | ||
| 204 | + | func (d *browserDOM) focusHistoryRow(delta int) { | |
| 205 | + | rows := d.target.Call("querySelectorAll", "[data-sv-history-row]") | |
| 206 | + | length := rows.Get("length").Int() | |
| 207 | + | if length == 0 { | |
| 208 | + | return | |
| 209 | + | } | |
| 210 | + | idx := d.activeHistoryIndex() | |
| 211 | + | if idx < 0 { | |
| 212 | + | idx = 0 | |
| 213 | + | } else { | |
| 214 | + | idx += delta | |
| 215 | + | } | |
| 216 | + | if idx < 0 { | |
| 217 | + | idx = 0 | |
| 218 | + | } | |
| 219 | + | if idx >= length { | |
| 220 | + | idx = length - 1 | |
| 221 | + | } | |
| 222 | + | for i := 0; i < length; i++ { | |
| 223 | + | rows.Index(i).Get("classList").Call("remove", "sv-focused") | |
| 224 | + | } | |
| 225 | + | row := rows.Index(idx) | |
| 226 | + | row.Get("classList").Call("add", "sv-focused") | |
| 227 | + | d.target.Get("dataset").Set("svActiveHistoryIndex", strconv.Itoa(idx)) | |
| 228 | + | row.Call("scrollIntoView", map[string]any{"block": "nearest"}) | |
| 229 | + | } | |
| 230 | + | ||
| 231 | + | func (d *browserDOM) activeHistoryRow() js.Value { | |
| 232 | + | rows := d.target.Call("querySelectorAll", "[data-sv-history-row]") | |
| 233 | + | length := rows.Get("length").Int() | |
| 234 | + | if length == 0 { | |
| 235 | + | return js.Value{} | |
| 236 | + | } | |
| 237 | + | idx := d.activeHistoryIndex() | |
| 238 | + | if idx < 0 || idx >= length { | |
| 239 | + | idx = 0 | |
| 240 | + | d.target.Get("dataset").Set("svActiveHistoryIndex", "0") | |
| 241 | + | rows.Index(0).Get("classList").Call("add", "sv-focused") | |
| 242 | + | } | |
| 243 | + | return rows.Index(idx) | |
| 244 | + | } | |
| 245 | + | ||
| 246 | + | func (d *browserDOM) activeHistoryIndex() int { | |
| 247 | + | value := d.target.Get("dataset").Get("svActiveHistoryIndex").String() | |
| 248 | + | idx, err := strconv.Atoi(value) | |
| 249 | + | if err != nil { | |
| 250 | + | return -1 | |
| 251 | + | } | |
| 252 | + | return idx | |
| 253 | + | } | |
| @@ -4,12 +4,15 @@ package systemviews | |||||
| 4 | 4 | ||||
| 5 | import ( | 5 | import ( | ||
| 6 | "fmt" | 6 | "fmt" | ||
| 7 | + | "strconv" | |||
| 8 | + | "strings" | |||
| 7 | "syscall/js" | 9 | "syscall/js" | ||
| 8 | ) | 10 | ) | ||
| 9 | 11 | ||||
| 10 | type browserDOM struct { | 12 | type browserDOM struct { | ||
| 11 | document js.Value | 13 | document js.Value | ||
| 12 | target js.Value | 14 | target js.Value | ||
| 15 | + | bindings []js.Func | |||
| 13 | } | 16 | } | ||
| 14 | 17 | ||||
| 15 | func NewDOM() DOM { | 18 | func NewDOM() DOM { | ||
| @@ -33,3 +36,218 @@ func (d *browserDOM) Mount(html string) error { | |||||
| 33 | d.target.Set("innerHTML", html) | 36 | d.target.Set("innerHTML", html) | ||
| 34 | return nil | 37 | return nil | ||
| 35 | } | 38 | } | ||
| 39 | + | ||||
| 40 | + | func (d *browserDOM) BindActions(handler DOMActionHandler) error { | |||
| 41 | + | if d == nil || !d.target.Truthy() || handler == nil { | |||
| 42 | + | return fmt.Errorf("DOM action binding target not available") | |||
| 43 | + | } | |||
| 44 | + | ||||
| 45 | + | clickHandler := js.FuncOf(func(_ js.Value, args []js.Value) any { | |||
| 46 | + | event := firstJSArg(args) | |||
| 47 | + | if !event.Truthy() { | |||
| 48 | + | return nil | |||
| 49 | + | } | |||
| 50 | + | element := closestActionElement(event) | |||
| 51 | + | if !element.Truthy() { | |||
| 52 | + | return nil | |||
| 53 | + | } | |||
| 54 | + | action := element.Call("getAttribute", "data-sv-action").String() | |||
| 55 | + | if action == "" { | |||
| 56 | + | return nil | |||
| 57 | + | } | |||
| 58 | + | if !confirmAction(element) { | |||
| 59 | + | return nil | |||
| 60 | + | } | |||
| 61 | + | event.Call("preventDefault") | |||
| 62 | + | handler(DOMAction{Action: action, Data: collectActionData(element)}) | |||
| 63 | + | return nil | |||
| 64 | + | }) | |||
| 65 | + | d.target.Call("addEventListener", "click", clickHandler) | |||
| 66 | + | d.bindings = append(d.bindings, clickHandler) | |||
| 67 | + | ||||
| 68 | + | submitHandler := js.FuncOf(func(_ js.Value, args []js.Value) any { | |||
| 69 | + | event := firstJSArg(args) | |||
| 70 | + | if !event.Truthy() { | |||
| 71 | + | return nil | |||
| 72 | + | } | |||
| 73 | + | form := event.Get("target") | |||
| 74 | + | if !form.Truthy() { | |||
| 75 | + | return nil | |||
| 76 | + | } | |||
| 77 | + | action := form.Call("getAttribute", "data-sv-action").String() | |||
| 78 | + | if action == "" { | |||
| 79 | + | return nil | |||
| 80 | + | } | |||
| 81 | + | event.Call("preventDefault") | |||
| 82 | + | data := collectActionData(form) | |||
| 83 | + | if queryInput := form.Call("querySelector", `[name="query"]`); queryInput.Truthy() { | |||
| 84 | + | data["query"] = queryInput.Get("value").String() | |||
| 85 | + | } | |||
| 86 | + | handler(DOMAction{Action: action, Data: data}) | |||
| 87 | + | return nil | |||
| 88 | + | }) | |||
| 89 | + | d.target.Call("addEventListener", "submit", submitHandler) | |||
| 90 | + | d.bindings = append(d.bindings, submitHandler) | |||
| 91 | + | ||||
| 92 | + | keydownHandler := js.FuncOf(func(_ js.Value, args []js.Value) any { | |||
| 93 | + | d.handleHistoryKeyboard(firstJSArg(args)) | |||
| 94 | + | return nil | |||
| 95 | + | }) | |||
| 96 | + | d.document.Call("addEventListener", "keydown", keydownHandler) | |||
| 97 | + | d.bindings = append(d.bindings, keydownHandler) | |||
| 98 | + | ||||
| 99 | + | return nil | |||
| 100 | + | } | |||
| 101 | + | ||||
| 102 | + | func firstJSArg(args []js.Value) js.Value { | |||
| 103 | + | if len(args) == 0 { | |||
| 104 | + | return js.Value{} | |||
| 105 | + | } | |||
| 106 | + | return args[0] | |||
| 107 | + | } | |||
| 108 | + | ||||
| 109 | + | func closestActionElement(event js.Value) js.Value { | |||
| 110 | + | target := event.Get("target") | |||
| 111 | + | if !target.Truthy() || !target.Get("closest").Truthy() { | |||
| 112 | + | return js.Value{} | |||
| 113 | + | } | |||
| 114 | + | return target.Call("closest", "[data-sv-action]") | |||
| 115 | + | } | |||
| 116 | + | ||||
| 117 | + | func collectActionData(element js.Value) map[string]string { | |||
| 118 | + | data := map[string]string{} | |||
| 119 | + | if !element.Truthy() { | |||
| 120 | + | return data | |||
| 121 | + | } | |||
| 122 | + | dataset := element.Get("dataset") | |||
| 123 | + | if !dataset.Truthy() { | |||
| 124 | + | return data | |||
| 125 | + | } | |||
| 126 | + | keys := js.Global().Get("Object").Call("keys", dataset) | |||
| 127 | + | for i := 0; i < keys.Get("length").Int(); i++ { | |||
| 128 | + | key := keys.Index(i).String() | |||
| 129 | + | if key == "svAction" || key == "svConfirm" { | |||
| 130 | + | continue | |||
| 131 | + | } | |||
| 132 | + | data[key] = dataset.Get(key).String() | |||
| 133 | + | } | |||
| 134 | + | return data | |||
| 135 | + | } | |||
| 136 | + | ||||
| 137 | + | func confirmAction(element js.Value) bool { | |||
| 138 | + | message := element.Call("getAttribute", "data-sv-confirm").String() | |||
| 139 | + | if message == "" { | |||
| 140 | + | return true | |||
| 141 | + | } | |||
| 142 | + | confirm := js.Global().Get("window").Get("confirm") | |||
| 143 | + | if !confirm.Truthy() { | |||
| 144 | + | return true | |||
| 145 | + | } | |||
| 146 | + | return confirm.Invoke(message).Bool() | |||
| 147 | + | } | |||
| 148 | + | ||||
| 149 | + | func (d *browserDOM) handleHistoryKeyboard(event js.Value) { | |||
| 150 | + | if d == nil || !d.target.Truthy() || !event.Truthy() || !d.isHistoryRoute() { | |||
| 151 | + | return | |||
| 152 | + | } | |||
| 153 | + | key := strings.ToLower(event.Get("key").String()) | |||
| 154 | + | target := event.Get("target") | |||
| 155 | + | if isEditableTarget(target) && key != "escape" { | |||
| 156 | + | return | |||
| 157 | + | } | |||
| 158 | + | ||||
| 159 | + | switch key { | |||
| 160 | + | case "/": | |||
| 161 | + | if input := d.target.Call("querySelector", `[data-sv-history-search]`); input.Truthy() { | |||
| 162 | + | event.Call("preventDefault") | |||
| 163 | + | input.Call("focus") | |||
| 164 | + | } | |||
| 165 | + | case "j", "arrowdown": | |||
| 166 | + | event.Call("preventDefault") | |||
| 167 | + | d.focusHistoryRow(1) | |||
| 168 | + | case "k", "arrowup": | |||
| 169 | + | event.Call("preventDefault") | |||
| 170 | + | d.focusHistoryRow(-1) | |||
| 171 | + | case "enter": | |||
| 172 | + | if row := d.activeHistoryRow(); row.Truthy() { | |||
| 173 | + | event.Call("preventDefault") | |||
| 174 | + | if link := row.Call("querySelector", ".sv-history-open"); link.Truthy() { | |||
| 175 | + | link.Call("click") | |||
| 176 | + | } | |||
| 177 | + | } | |||
| 178 | + | case "d", "delete", "backspace": | |||
| 179 | + | if row := d.activeHistoryRow(); row.Truthy() { | |||
| 180 | + | event.Call("preventDefault") | |||
| 181 | + | if button := row.Call("querySelector", `[data-sv-action="history.deleteEntry"]`); button.Truthy() { | |||
| 182 | + | button.Call("click") | |||
| 183 | + | } | |||
| 184 | + | } | |||
| 185 | + | case "escape": | |||
| 186 | + | if target.Truthy() && isEditableTarget(target) { | |||
| 187 | + | target.Call("blur") | |||
| 188 | + | } | |||
| 189 | + | } | |||
| 190 | + | } | |||
| 191 | + | ||||
| 192 | + | func (d *browserDOM) isHistoryRoute() bool { | |||
| 193 | + | return d.target.Call("querySelector", `[data-route="history"]`).Truthy() | |||
| 194 | + | } | |||
| 195 | + | ||||
| 196 | + | func isEditableTarget(target js.Value) bool { | |||
| 197 | + | if !target.Truthy() { | |||
| 198 | + | return false | |||
| 199 | + | } | |||
| 200 | + | tag := strings.ToLower(target.Get("tagName").String()) | |||
| 201 | + | return tag == "input" || tag == "textarea" || tag == "select" || target.Get("isContentEditable").Bool() | |||
| 202 | + | } | |||
| 203 | + | ||||
| 204 | + | func (d *browserDOM) focusHistoryRow(delta int) { | |||
| 205 | + | rows := d.target.Call("querySelectorAll", "[data-sv-history-row]") | |||
| 206 | + | length := rows.Get("length").Int() | |||
| 207 | + | if length == 0 { | |||
| 208 | + | return | |||
| 209 | + | } | |||
| 210 | + | idx := d.activeHistoryIndex() | |||
| 211 | + | if idx < 0 { | |||
| 212 | + | idx = 0 | |||
| 213 | + | } else { | |||
| 214 | + | idx += delta | |||
| 215 | + | } | |||
| 216 | + | if idx < 0 { | |||
| 217 | + | idx = 0 | |||
| 218 | + | } | |||
| 219 | + | if idx >= length { | |||
| 220 | + | idx = length - 1 | |||
| 221 | + | } | |||
| 222 | + | for i := 0; i < length; i++ { | |||
| 223 | + | rows.Index(i).Get("classList").Call("remove", "sv-focused") | |||
| 224 | + | } | |||
| 225 | + | row := rows.Index(idx) | |||
| 226 | + | row.Get("classList").Call("add", "sv-focused") | |||
| 227 | + | d.target.Get("dataset").Set("svActiveHistoryIndex", strconv.Itoa(idx)) | |||
| 228 | + | row.Call("scrollIntoView", map[string]any{"block": "nearest"}) | |||
| 229 | + | } | |||
| 230 | + | ||||
| 231 | + | func (d *browserDOM) activeHistoryRow() js.Value { | |||
| 232 | + | rows := d.target.Call("querySelectorAll", "[data-sv-history-row]") | |||
| 233 | + | length := rows.Get("length").Int() | |||
| 234 | + | if length == 0 { | |||
| 235 | + | return js.Value{} | |||
| 236 | + | } | |||
| 237 | + | idx := d.activeHistoryIndex() | |||
| 238 | + | if idx < 0 || idx >= length { | |||
| 239 | + | idx = 0 | |||
| 240 | + | d.target.Get("dataset").Set("svActiveHistoryIndex", "0") | |||
| 241 | + | rows.Index(0).Get("classList").Call("add", "sv-focused") | |||
| 242 | + | } | |||
| 243 | + | return rows.Index(idx) | |||
| 244 | + | } | |||
| 245 | + | ||||
| 246 | + | func (d *browserDOM) activeHistoryIndex() int { | |||
| 247 | + | value := d.target.Get("dataset").Get("svActiveHistoryIndex").String() | |||
| 248 | + | idx, err := strconv.Atoi(value) | |||
| 249 | + | if err != nil { | |||
| 250 | + | return -1 | |||
| 251 | + | } | |||
| 252 | + | return idx | |||
| 253 | + | } | |||
A internal/ui/systemviews/history_actions.go +150 -0
| @@ -0,0 +1,150 @@ | |||
| 1 | + | package systemviews | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "fmt" | |
| 6 | + | "strconv" | |
| 7 | + | "strings" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | const ( | |
| 11 | + | historyActionSearch = "history.search" | |
| 12 | + | historyActionClear = "history.clear" | |
| 13 | + | historyActionFilterDomain = "history.filterDomain" | |
| 14 | + | historyActionClearDomain = "history.clearDomain" | |
| 15 | + | historyActionPage = "history.page" | |
| 16 | + | historyActionDeleteEntry = "history.deleteEntry" | |
| 17 | + | historyActionDeleteRange = "history.deleteRange" | |
| 18 | + | historyActionDeleteDomain = "history.deleteDomain" | |
| 19 | + | ) | |
| 20 | + | ||
| 21 | + | // HandleDOMAction applies an action delegated from the browser DOM and refreshes | |
| 22 | + | // the mounted view. It keeps UI orchestration in the systemview edge and calls | |
| 23 | + | // only application ports for data changes. | |
| 24 | + | func (a *App) HandleDOMAction(ctx context.Context, event DOMAction) error { | |
| 25 | + | if a == nil { | |
| 26 | + | return fmt.Errorf("app is nil") | |
| 27 | + | } | |
| 28 | + | if a.currentRoute == RouteUnknown || a.currentRoute == "" { | |
| 29 | + | a.currentRoute = ParseRoute(a.deps.LocationURI) | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | switch a.currentRoute { | |
| 33 | + | case RouteHistory: | |
| 34 | + | a.historyError = "" | |
| 35 | + | if err := a.handleHistoryAction(ctx, event); err != nil { | |
| 36 | + | a.historyNotice = "" | |
| 37 | + | a.historyError = err.Error() | |
| 38 | + | } | |
| 39 | + | if err := a.loadHistoryRoute(ctx); err != nil { | |
| 40 | + | a.renderRouteError(err) | |
| 41 | + | } | |
| 42 | + | return a.mountRenderedHTML() | |
| 43 | + | default: | |
| 44 | + | return nil | |
| 45 | + | } | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | func (a *App) handleHistoryAction(ctx context.Context, event DOMAction) error { | |
| 49 | + | if a.deps.History == nil { | |
| 50 | + | return fmt.Errorf("history service not configured") | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | data := event.Data | |
| 54 | + | switch event.Action { | |
| 55 | + | case historyActionSearch: | |
| 56 | + | a.historyQuery = strings.TrimSpace(data["query"]) | |
| 57 | + | a.historyOffset = 0 | |
| 58 | + | a.historyNotice = "" | |
| 59 | + | case historyActionClear: | |
| 60 | + | a.historyQuery = "" | |
| 61 | + | a.historyDomainFilter = "" | |
| 62 | + | a.historyOffset = 0 | |
| 63 | + | a.historyNotice = "" | |
| 64 | + | case historyActionFilterDomain: | |
| 65 | + | domain := strings.TrimSpace(data["domain"]) | |
| 66 | + | if domain == "" { | |
| 67 | + | return fmt.Errorf("domain is required") | |
| 68 | + | } | |
| 69 | + | a.historyDomainFilter = domain | |
| 70 | + | a.historyOffset = 0 | |
| 71 | + | a.historyNotice = "" | |
| 72 | + | case historyActionClearDomain: | |
| 73 | + | a.historyDomainFilter = "" | |
| 74 | + | a.historyOffset = 0 | |
| 75 | + | a.historyNotice = "" | |
| 76 | + | case historyActionPage: | |
| 77 | + | offset, err := strconv.Atoi(strings.TrimSpace(data["offset"])) | |
| 78 | + | if err != nil || offset < 0 { | |
| 79 | + | return fmt.Errorf("invalid history offset") | |
| 80 | + | } | |
| 81 | + | a.historyOffset = offset | |
| 82 | + | a.historyNotice = "" | |
| 83 | + | case historyActionDeleteEntry: | |
| 84 | + | id, err := strconv.ParseInt(strings.TrimSpace(data["id"]), 10, 64) | |
| 85 | + | if err != nil || id <= 0 { | |
| 86 | + | return fmt.Errorf("invalid history entry id") | |
| 87 | + | } | |
| 88 | + | if err := a.deps.History.DeleteEntry(ctx, id); err != nil { | |
| 89 | + | return err | |
| 90 | + | } | |
| 91 | + | a.historyNotice = "Deleted history entry" | |
| 92 | + | case historyActionDeleteRange: | |
| 93 | + | rangeID := strings.TrimSpace(data["range"]) | |
| 94 | + | if !validHistoryRange(rangeID) { | |
| 95 | + | return fmt.Errorf("invalid history cleanup range") | |
| 96 | + | } | |
| 97 | + | if err := a.deps.History.DeleteRange(ctx, rangeID); err != nil { | |
| 98 | + | return err | |
| 99 | + | } | |
| 100 | + | a.historyOffset = 0 | |
| 101 | + | a.historyNotice = historyRangeNotice(rangeID) | |
| 102 | + | case historyActionDeleteDomain: | |
| 103 | + | domain := strings.TrimSpace(data["domain"]) | |
| 104 | + | if domain == "" { | |
| 105 | + | return fmt.Errorf("domain is required") | |
| 106 | + | } | |
| 107 | + | if err := a.deps.History.DeleteDomain(ctx, domain); err != nil { | |
| 108 | + | return err | |
| 109 | + | } | |
| 110 | + | if strings.EqualFold(a.historyDomainFilter, domain) { | |
| 111 | + | a.historyDomainFilter = "" | |
| 112 | + | } | |
| 113 | + | a.historyOffset = 0 | |
| 114 | + | a.historyNotice = "Deleted history for " + domain | |
| 115 | + | } | |
| 116 | + | return nil | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | func (a *App) mountRenderedHTML() error { | |
| 120 | + | if a.deps.DOM == nil { | |
| 121 | + | return nil | |
| 122 | + | } | |
| 123 | + | return a.deps.DOM.Mount(a.renderedHTML) | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | func validHistoryRange(rangeID string) bool { | |
| 127 | + | switch rangeID { | |
| 128 | + | case "hour", "day", "week", "month", "all": | |
| 129 | + | return true | |
| 130 | + | default: | |
| 131 | + | return false | |
| 132 | + | } | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | func historyRangeNotice(rangeID string) string { | |
| 136 | + | switch rangeID { | |
| 137 | + | case "hour": | |
| 138 | + | return "Deleted history from the last hour" | |
| 139 | + | case "day": | |
| 140 | + | return "Deleted history from today" | |
| 141 | + | case "week": | |
| 142 | + | return "Deleted history from this week" | |
| 143 | + | case "month": | |
| 144 | + | return "Deleted history from this month" | |
| 145 | + | case "all": | |
| 146 | + | return "Deleted all history" | |
| 147 | + | default: | |
| 148 | + | return "Deleted history" | |
| 149 | + | } | |
| 150 | + | } | |
| @@ -0,0 +1,150 @@ | |||||
| 1 | + | package systemviews | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "fmt" | |||
| 6 | + | "strconv" | |||
| 7 | + | "strings" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | const ( | |||
| 11 | + | historyActionSearch = "history.search" | |||
| 12 | + | historyActionClear = "history.clear" | |||
| 13 | + | historyActionFilterDomain = "history.filterDomain" | |||
| 14 | + | historyActionClearDomain = "history.clearDomain" | |||
| 15 | + | historyActionPage = "history.page" | |||
| 16 | + | historyActionDeleteEntry = "history.deleteEntry" | |||
| 17 | + | historyActionDeleteRange = "history.deleteRange" | |||
| 18 | + | historyActionDeleteDomain = "history.deleteDomain" | |||
| 19 | + | ) | |||
| 20 | + | ||||
| 21 | + | // HandleDOMAction applies an action delegated from the browser DOM and refreshes | |||
| 22 | + | // the mounted view. It keeps UI orchestration in the systemview edge and calls | |||
| 23 | + | // only application ports for data changes. | |||
| 24 | + | func (a *App) HandleDOMAction(ctx context.Context, event DOMAction) error { | |||
| 25 | + | if a == nil { | |||
| 26 | + | return fmt.Errorf("app is nil") | |||
| 27 | + | } | |||
| 28 | + | if a.currentRoute == RouteUnknown || a.currentRoute == "" { | |||
| 29 | + | a.currentRoute = ParseRoute(a.deps.LocationURI) | |||
| 30 | + | } | |||
| 31 | + | ||||
| 32 | + | switch a.currentRoute { | |||
| 33 | + | case RouteHistory: | |||
| 34 | + | a.historyError = "" | |||
| 35 | + | if err := a.handleHistoryAction(ctx, event); err != nil { | |||
| 36 | + | a.historyNotice = "" | |||
| 37 | + | a.historyError = err.Error() | |||
| 38 | + | } | |||
| 39 | + | if err := a.loadHistoryRoute(ctx); err != nil { | |||
| 40 | + | a.renderRouteError(err) | |||
| 41 | + | } | |||
| 42 | + | return a.mountRenderedHTML() | |||
| 43 | + | default: | |||
| 44 | + | return nil | |||
| 45 | + | } | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | func (a *App) handleHistoryAction(ctx context.Context, event DOMAction) error { | |||
| 49 | + | if a.deps.History == nil { | |||
| 50 | + | return fmt.Errorf("history service not configured") | |||
| 51 | + | } | |||
| 52 | + | ||||
| 53 | + | data := event.Data | |||
| 54 | + | switch event.Action { | |||
| 55 | + | case historyActionSearch: | |||
| 56 | + | a.historyQuery = strings.TrimSpace(data["query"]) | |||
| 57 | + | a.historyOffset = 0 | |||
| 58 | + | a.historyNotice = "" | |||
| 59 | + | case historyActionClear: | |||
| 60 | + | a.historyQuery = "" | |||
| 61 | + | a.historyDomainFilter = "" | |||
| 62 | + | a.historyOffset = 0 | |||
| 63 | + | a.historyNotice = "" | |||
| 64 | + | case historyActionFilterDomain: | |||
| 65 | + | domain := strings.TrimSpace(data["domain"]) | |||
| 66 | + | if domain == "" { | |||
| 67 | + | return fmt.Errorf("domain is required") | |||
| 68 | + | } | |||
| 69 | + | a.historyDomainFilter = domain | |||
| 70 | + | a.historyOffset = 0 | |||
| 71 | + | a.historyNotice = "" | |||
| 72 | + | case historyActionClearDomain: | |||
| 73 | + | a.historyDomainFilter = "" | |||
| 74 | + | a.historyOffset = 0 | |||
| 75 | + | a.historyNotice = "" | |||
| 76 | + | case historyActionPage: | |||
| 77 | + | offset, err := strconv.Atoi(strings.TrimSpace(data["offset"])) | |||
| 78 | + | if err != nil || offset < 0 { | |||
| 79 | + | return fmt.Errorf("invalid history offset") | |||
| 80 | + | } | |||
| 81 | + | a.historyOffset = offset | |||
| 82 | + | a.historyNotice = "" | |||
| 83 | + | case historyActionDeleteEntry: | |||
| 84 | + | id, err := strconv.ParseInt(strings.TrimSpace(data["id"]), 10, 64) | |||
| 85 | + | if err != nil || id <= 0 { | |||
| 86 | + | return fmt.Errorf("invalid history entry id") | |||
| 87 | + | } | |||
| 88 | + | if err := a.deps.History.DeleteEntry(ctx, id); err != nil { | |||
| 89 | + | return err | |||
| 90 | + | } | |||
| 91 | + | a.historyNotice = "Deleted history entry" | |||
| 92 | + | case historyActionDeleteRange: | |||
| 93 | + | rangeID := strings.TrimSpace(data["range"]) | |||
| 94 | + | if !validHistoryRange(rangeID) { | |||
| 95 | + | return fmt.Errorf("invalid history cleanup range") | |||
| 96 | + | } | |||
| 97 | + | if err := a.deps.History.DeleteRange(ctx, rangeID); err != nil { | |||
| 98 | + | return err | |||
| 99 | + | } | |||
| 100 | + | a.historyOffset = 0 | |||
| 101 | + | a.historyNotice = historyRangeNotice(rangeID) | |||
| 102 | + | case historyActionDeleteDomain: | |||
| 103 | + | domain := strings.TrimSpace(data["domain"]) | |||
| 104 | + | if domain == "" { | |||
| 105 | + | return fmt.Errorf("domain is required") | |||
| 106 | + | } | |||
| 107 | + | if err := a.deps.History.DeleteDomain(ctx, domain); err != nil { | |||
| 108 | + | return err | |||
| 109 | + | } | |||
| 110 | + | if strings.EqualFold(a.historyDomainFilter, domain) { | |||
| 111 | + | a.historyDomainFilter = "" | |||
| 112 | + | } | |||
| 113 | + | a.historyOffset = 0 | |||
| 114 | + | a.historyNotice = "Deleted history for " + domain | |||
| 115 | + | } | |||
| 116 | + | return nil | |||
| 117 | + | } | |||
| 118 | + | ||||
| 119 | + | func (a *App) mountRenderedHTML() error { | |||
| 120 | + | if a.deps.DOM == nil { | |||
| 121 | + | return nil | |||
| 122 | + | } | |||
| 123 | + | return a.deps.DOM.Mount(a.renderedHTML) | |||
| 124 | + | } | |||
| 125 | + | ||||
| 126 | + | func validHistoryRange(rangeID string) bool { | |||
| 127 | + | switch rangeID { | |||
| 128 | + | case "hour", "day", "week", "month", "all": | |||
| 129 | + | return true | |||
| 130 | + | default: | |||
| 131 | + | return false | |||
| 132 | + | } | |||
| 133 | + | } | |||
| 134 | + | ||||
| 135 | + | func historyRangeNotice(rangeID string) string { | |||
| 136 | + | switch rangeID { | |||
| 137 | + | case "hour": | |||
| 138 | + | return "Deleted history from the last hour" | |||
| 139 | + | case "day": | |||
| 140 | + | return "Deleted history from today" | |||
| 141 | + | case "week": | |||
| 142 | + | return "Deleted history from this week" | |||
| 143 | + | case "month": | |||
| 144 | + | return "Deleted history from this month" | |||
| 145 | + | case "all": | |||
| 146 | + | return "Deleted all history" | |||
| 147 | + | default: | |||
| 148 | + | return "Deleted history" | |||
| 149 | + | } | |||
| 150 | + | } | |||
M internal/ui/systemviews/history_view.go +176 -12
| @@ -11,20 +11,145 @@ import ( | |||
| 11 | 11 | ) | |
| 12 | 12 | ||
| 13 | 13 | type historyRenderData struct { | |
| 14 | - | Entries []*entity.HistoryEntry | |
| 15 | - | Analytics *entity.HistoryAnalytics | |
| 16 | - | Domains []*entity.DomainStat | |
| 14 | + | Entries []*entity.HistoryEntry | |
| 15 | + | Analytics *entity.HistoryAnalytics | |
| 16 | + | Domains []*entity.DomainStat | |
| 17 | + | Query string | |
| 18 | + | DomainFilter string | |
| 19 | + | Offset int | |
| 20 | + | Limit int | |
| 21 | + | Notice string | |
| 22 | + | Error string | |
| 17 | 23 | } | |
| 18 | 24 | ||
| 19 | 25 | func historyHTML(data historyRenderData) string { | |
| 20 | 26 | sections := []string{ | |
| 27 | + | historyStatusHTML(data), | |
| 28 | + | sectionHTML("sv-history-controls", "Manage history", historyControlsHTML(data)), | |
| 21 | 29 | sectionHTML("sv-history-summary", "History", historySummaryHTML(data)), | |
| 22 | 30 | sectionHTML("sv-history-domains", "Top domains", historyDomainsHTML(data.Domains)), | |
| 23 | - | sectionHTML("sv-history-timeline", "Recent visits", historyTimelineHTML(data.Entries)), | |
| 31 | + | sectionHTML("sv-history-timeline", "Recent visits", historyTimelineHTML(data)), | |
| 24 | 32 | } | |
| 25 | 33 | return strings.Join(sections, "") | |
| 26 | 34 | } | |
| 27 | 35 | ||
| 36 | + | func historyStatusHTML(data historyRenderData) string { | |
| 37 | + | var out strings.Builder | |
| 38 | + | if strings.TrimSpace(data.Notice) != "" { | |
| 39 | + | out.WriteString(fmt.Sprintf(`<div class="sv-alert sv-alert-success" role="status">%s</div>`, html.EscapeString(data.Notice))) | |
| 40 | + | } | |
| 41 | + | if strings.TrimSpace(data.Error) != "" { | |
| 42 | + | out.WriteString(fmt.Sprintf(`<div class="sv-alert sv-alert-error" role="alert">%s</div>`, html.EscapeString(data.Error))) | |
| 43 | + | } | |
| 44 | + | return out.String() | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | func historyControlsHTML(data historyRenderData) string { | |
| 48 | + | query := strings.TrimSpace(data.Query) | |
| 49 | + | domain := strings.TrimSpace(data.DomainFilter) | |
| 50 | + | limit := data.Limit | |
| 51 | + | if limit <= 0 { | |
| 52 | + | limit = historyTimelineLimit | |
| 53 | + | } | |
| 54 | + | offset := data.Offset | |
| 55 | + | if offset < 0 { | |
| 56 | + | offset = 0 | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | var out strings.Builder | |
| 60 | + | out.WriteString(`<form class="sv-toolbar sv-history-search" data-sv-action="history.search">`) | |
| 61 | + | out.WriteString(fmt.Sprintf(`<label class="sv-search-label"><span>Search</span><input data-sv-history-search name="query" type="search" value="%s" placeholder="Search title or URL" autocomplete="off"></label>`, html.EscapeString(query))) | |
| 62 | + | out.WriteString(`<button class="sv-button" type="submit">Search</button>`) | |
| 63 | + | out.WriteString(`<button class="sv-button sv-button-secondary" type="button" data-sv-action="history.clear">Clear</button>`) | |
| 64 | + | out.WriteString(`</form>`) | |
| 65 | + | ||
| 66 | + | if query != "" || domain != "" { | |
| 67 | + | out.WriteString(`<div class="sv-active-filters">`) | |
| 68 | + | if query != "" { | |
| 69 | + | out.WriteString(fmt.Sprintf(`<span class="sv-filter-chip">Query: %s</span>`, html.EscapeString(query))) | |
| 70 | + | } | |
| 71 | + | if domain != "" { | |
| 72 | + | out.WriteString(fmt.Sprintf(`<span class="sv-filter-chip">Domain: %s <button type="button" data-sv-action="history.clearDomain" aria-label="Clear domain filter">×</button></span>`, html.EscapeString(domain))) | |
| 73 | + | } | |
| 74 | + | out.WriteString(`</div>`) | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | out.WriteString(`<div class="sv-history-actions-panel">`) | |
| 78 | + | out.WriteString(`<div class="sv-button-row" aria-label="History pagination">`) | |
| 79 | + | prevOffset := offset - limit | |
| 80 | + | if prevOffset < 0 { | |
| 81 | + | prevOffset = 0 | |
| 82 | + | } | |
| 83 | + | out.WriteString(historyActionButton("Previous", historyActionPage, map[string]string{"offset": fmt.Sprintf("%d", prevOffset)}, false, offset == 0 || query != "")) | |
| 84 | + | out.WriteString(fmt.Sprintf(`<span class="sv-meta">Showing %d item%s%s</span>`, countHistoryEntries(data.Entries), pluralSuffix(countHistoryEntries(data.Entries)), historyOffsetLabel(offset, query))) | |
| 85 | + | out.WriteString(historyActionButton("Next", historyActionPage, map[string]string{"offset": fmt.Sprintf("%d", offset+limit)}, false, query != "" || countHistoryEntries(data.Entries) < limit)) | |
| 86 | + | out.WriteString(`</div>`) | |
| 87 | + | ||
| 88 | + | out.WriteString(`<div class="sv-button-row" aria-label="History cleanup">`) | |
| 89 | + | out.WriteString(`<span class="sv-meta">Clean up</span>`) | |
| 90 | + | for _, item := range []struct { | |
| 91 | + | label string | |
| 92 | + | rangeID string | |
| 93 | + | }{ | |
| 94 | + | {label: "Last hour", rangeID: "hour"}, | |
| 95 | + | {label: "Today", rangeID: "day"}, | |
| 96 | + | {label: "Week", rangeID: "week"}, | |
| 97 | + | {label: "Month", rangeID: "month"}, | |
| 98 | + | {label: "All", rangeID: "all"}, | |
| 99 | + | } { | |
| 100 | + | confirm := "Delete history for " + strings.ToLower(item.label) + "?" | |
| 101 | + | if item.rangeID == "all" { | |
| 102 | + | confirm = "Delete all browsing history?" | |
| 103 | + | } | |
| 104 | + | out.WriteString(historyActionButton(item.label, historyActionDeleteRange, map[string]string{"range": item.rangeID, "sv-confirm": confirm}, true, false)) | |
| 105 | + | } | |
| 106 | + | out.WriteString(`</div>`) | |
| 107 | + | out.WriteString(`</div>`) | |
| 108 | + | ||
| 109 | + | out.WriteString(`<p class="sv-meta sv-key-hints">Keys: <kbd>/</kbd> search, <kbd>j</kbd>/<kbd>k</kbd> move, <kbd>Enter</kbd> open, <kbd>d</kbd> delete focused entry.</p>`) | |
| 110 | + | return out.String() | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | func historyOffsetLabel(offset int, query string) string { | |
| 114 | + | if strings.TrimSpace(query) != "" { | |
| 115 | + | return " matching search" | |
| 116 | + | } | |
| 117 | + | if offset <= 0 { | |
| 118 | + | return "" | |
| 119 | + | } | |
| 120 | + | return fmt.Sprintf(" from offset %d", offset) | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | func historyActionButton(label, action string, data map[string]string, danger, disabled bool) string { | |
| 124 | + | classes := "sv-button sv-button-secondary" | |
| 125 | + | if danger { | |
| 126 | + | classes += " sv-button-danger" | |
| 127 | + | } | |
| 128 | + | attrs := []string{ | |
| 129 | + | `type="button"`, | |
| 130 | + | fmt.Sprintf(`class="%s"`, html.EscapeString(classes)), | |
| 131 | + | fmt.Sprintf(`data-sv-action="%s"`, html.EscapeString(action)), | |
| 132 | + | } | |
| 133 | + | if disabled { | |
| 134 | + | attrs = append(attrs, `disabled aria-disabled="true"`) | |
| 135 | + | } | |
| 136 | + | for key, value := range data { | |
| 137 | + | attrs = append(attrs, fmt.Sprintf(`data-%s="%s"`, html.EscapeString(kebabDataKey(key)), html.EscapeString(value))) | |
| 138 | + | } | |
| 139 | + | return fmt.Sprintf(`<button %s>%s</button>`, strings.Join(attrs, " "), html.EscapeString(label)) | |
| 140 | + | } | |
| 141 | + | ||
| 142 | + | func kebabDataKey(key string) string { | |
| 143 | + | return strings.ReplaceAll(key, "_", "-") | |
| 144 | + | } | |
| 145 | + | ||
| 146 | + | func pluralSuffix(count int) string { | |
| 147 | + | if count == 1 { | |
| 148 | + | return "" | |
| 149 | + | } | |
| 150 | + | return "s" | |
| 151 | + | } | |
| 152 | + | ||
| 28 | 153 | func historySummaryHTML(data historyRenderData) string { | |
| 29 | 154 | entries := int64(countHistoryEntries(data.Entries)) | |
| 30 | 155 | visits := sumHistoryVisits(data.Entries) | |
| @@ -53,12 +178,21 @@ func historyDomainsHTML(domains []*entity.DomainStat) string { | |||
| 53 | 178 | if domain == nil || strings.TrimSpace(domain.Domain) == "" { | |
| 54 | 179 | continue | |
| 55 | 180 | } | |
| 181 | + | displayDomain := strings.TrimPrefix(domain.Domain, "www.") | |
| 182 | + | chips.WriteString(`<span class="sv-domain-chip">`) | |
| 183 | + | chips.WriteString(fmt.Sprintf( | |
| 184 | + | `<button type="button" class="sv-domain-filter" data-sv-action="history.filterDomain" data-domain="%s">%s</button>`, | |
| 185 | + | html.EscapeString(displayDomain), | |
| 186 | + | html.EscapeString(displayDomain), | |
| 187 | + | )) | |
| 188 | + | chips.WriteString(fmt.Sprintf(`<span class="sv-domain-meta">%d pages · %d visits</span>`, domain.PageCount, domain.TotalVisits)) | |
| 56 | 189 | chips.WriteString(fmt.Sprintf( | |
| 57 | - | `<span class="sv-domain-chip"><span>%s</span><span class="sv-domain-meta">%d pages · %d visits</span></span>`, | |
| 58 | - | html.EscapeString(strings.TrimPrefix(domain.Domain, "www.")), | |
| 59 | - | domain.PageCount, | |
| 60 | - | domain.TotalVisits, | |
| 190 | + | `<button type="button" class="sv-icon-button sv-danger" data-sv-action="history.deleteDomain" data-domain="%s" data-sv-confirm="Delete all history for %s?" aria-label="Delete history for %s">×</button>`, | |
| 191 | + | html.EscapeString(displayDomain), | |
| 192 | + | html.EscapeString(displayDomain), | |
| 193 | + | html.EscapeString(displayDomain), | |
| 61 | 194 | )) | |
| 195 | + | chips.WriteString(`</span>`) | |
| 62 | 196 | } | |
| 63 | 197 | if chips.Len() == 0 { | |
| 64 | 198 | return emptyStateHTML("No domain statistics yet") | |
| @@ -66,9 +200,12 @@ func historyDomainsHTML(domains []*entity.DomainStat) string { | |||
| 66 | 200 | return `<div class="sv-domain-list">` + chips.String() + `</div>` | |
| 67 | 201 | } | |
| 68 | 202 | ||
| 69 | - | func historyTimelineHTML(entries []*entity.HistoryEntry) string { | |
| 70 | - | groups := groupHistoryEntries(entries) | |
| 203 | + | func historyTimelineHTML(data historyRenderData) string { | |
| 204 | + | groups := groupHistoryEntries(data.Entries) | |
| 71 | 205 | if len(groups) == 0 { | |
| 206 | + | if strings.TrimSpace(data.Query) != "" || strings.TrimSpace(data.DomainFilter) != "" { | |
| 207 | + | return emptyStateHTML("No history entries match the current filters") | |
| 208 | + | } | |
| 72 | 209 | return emptyStateHTML("No history entries") | |
| 73 | 210 | } | |
| 74 | 211 | ||
| @@ -111,10 +248,19 @@ func historyItemHTML(entry *entity.HistoryEntry) string { | |||
| 111 | 248 | meta = append(meta, formatHistoryTime(entry.LastVisited)) | |
| 112 | 249 | } | |
| 113 | 250 | ||
| 114 | - | return fmt.Sprintf(`<article class="sv-history-item"><div class="sv-history-main">%s<p class="sv-history-url">%s</p></div><p class="sv-history-meta">%s</p></article>`, | |
| 115 | - | linkHTML(entry.URL, label), | |
| 251 | + | deleteButton := historyActionButton("Delete", historyActionDeleteEntry, map[string]string{ | |
| 252 | + | "id": fmt.Sprintf("%d", entry.ID), | |
| 253 | + | "sv-confirm": "Delete this history entry?", | |
| 254 | + | }, true, entry.ID <= 0) | |
| 255 | + | ||
| 256 | + | return fmt.Sprintf(`<article class="sv-history-item" data-sv-history-row data-history-id="%d"><div class="sv-history-main"><a class="sv-link sv-history-open" href="%s">%s</a><p class="sv-history-url">%s</p><p class="sv-history-meta">%s</p></div><div class="sv-history-row-actions"><a class="sv-button sv-button-secondary" href="%s">Open</a>%s</div></article>`, | |
| 257 | + | entry.ID, | |
| 258 | + | html.EscapeString(sanitizeHref(entry.URL)), | |
| 259 | + | html.EscapeString(label), | |
| 116 | 260 | html.EscapeString(entry.URL), | |
| 117 | 261 | html.EscapeString(strings.Join(nonEmptyStrings(meta), " · ")), | |
| 262 | + | html.EscapeString(sanitizeHref(entry.URL)), | |
| 263 | + | deleteButton, | |
| 118 | 264 | ) | |
| 119 | 265 | } | |
| 120 | 266 | ||
| @@ -201,6 +347,24 @@ func countUniqueHistoryDays(entries []*entity.HistoryEntry) int64 { | |||
| 201 | 347 | return int64(len(seen)) | |
| 202 | 348 | } | |
| 203 | 349 | ||
| 350 | + | func filterHistoryEntriesByDomain(entries []*entity.HistoryEntry, domain string) []*entity.HistoryEntry { | |
| 351 | + | domain = strings.TrimPrefix(strings.ToLower(strings.TrimSpace(domain)), "www.") | |
| 352 | + | if domain == "" { | |
| 353 | + | return entries | |
| 354 | + | } | |
| 355 | + | filtered := make([]*entity.HistoryEntry, 0, len(entries)) | |
| 356 | + | for _, entry := range entries { | |
| 357 | + | if entry == nil { | |
| 358 | + | continue | |
| 359 | + | } | |
| 360 | + | entryDomain := strings.TrimPrefix(strings.ToLower(displayHistoryDomain(entry.URL)), "www.") | |
| 361 | + | if entryDomain == domain { | |
| 362 | + | filtered = append(filtered, entry) | |
| 363 | + | } | |
| 364 | + | } | |
| 365 | + | return filtered | |
| 366 | + | } | |
| 367 | + | ||
| 204 | 368 | func displayHistoryDomain(raw string) string { | |
| 205 | 369 | parsed, err := url.Parse(raw) | |
| 206 | 370 | if err != nil || parsed.Hostname() == "" { | |
| @@ -11,20 +11,145 @@ import ( | |||||
| 11 | ) | 11 | ) | ||
| 12 | 12 | ||||
| 13 | type historyRenderData struct { | 13 | type historyRenderData struct { | ||
| 14 | - | Entries []*entity.HistoryEntry | 14 | + | Entries []*entity.HistoryEntry |
| 15 | - | Analytics *entity.HistoryAnalytics | 15 | + | Analytics *entity.HistoryAnalytics |
| 16 | - | Domains []*entity.DomainStat | 16 | + | Domains []*entity.DomainStat |
| 17 | + | Query string | |||
| 18 | + | DomainFilter string | |||
| 19 | + | Offset int | |||
| 20 | + | Limit int | |||
| 21 | + | Notice string | |||
| 22 | + | Error string | |||
| 17 | } | 23 | } | ||
| 18 | 24 | ||||
| 19 | func historyHTML(data historyRenderData) string { | 25 | func historyHTML(data historyRenderData) string { | ||
| 20 | sections := []string{ | 26 | sections := []string{ | ||
| 27 | + | historyStatusHTML(data), | |||
| 28 | + | sectionHTML("sv-history-controls", "Manage history", historyControlsHTML(data)), | |||
| 21 | sectionHTML("sv-history-summary", "History", historySummaryHTML(data)), | 29 | sectionHTML("sv-history-summary", "History", historySummaryHTML(data)), | ||
| 22 | sectionHTML("sv-history-domains", "Top domains", historyDomainsHTML(data.Domains)), | 30 | sectionHTML("sv-history-domains", "Top domains", historyDomainsHTML(data.Domains)), | ||
| 23 | - | sectionHTML("sv-history-timeline", "Recent visits", historyTimelineHTML(data.Entries)), | 31 | + | sectionHTML("sv-history-timeline", "Recent visits", historyTimelineHTML(data)), |
| 24 | } | 32 | } | ||
| 25 | return strings.Join(sections, "") | 33 | return strings.Join(sections, "") | ||
| 26 | } | 34 | } | ||
| 27 | 35 | ||||
| 36 | + | func historyStatusHTML(data historyRenderData) string { | |||
| 37 | + | var out strings.Builder | |||
| 38 | + | if strings.TrimSpace(data.Notice) != "" { | |||
| 39 | + | out.WriteString(fmt.Sprintf(`<div class="sv-alert sv-alert-success" role="status">%s</div>`, html.EscapeString(data.Notice))) | |||
| 40 | + | } | |||
| 41 | + | if strings.TrimSpace(data.Error) != "" { | |||
| 42 | + | out.WriteString(fmt.Sprintf(`<div class="sv-alert sv-alert-error" role="alert">%s</div>`, html.EscapeString(data.Error))) | |||
| 43 | + | } | |||
| 44 | + | return out.String() | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | func historyControlsHTML(data historyRenderData) string { | |||
| 48 | + | query := strings.TrimSpace(data.Query) | |||
| 49 | + | domain := strings.TrimSpace(data.DomainFilter) | |||
| 50 | + | limit := data.Limit | |||
| 51 | + | if limit <= 0 { | |||
| 52 | + | limit = historyTimelineLimit | |||
| 53 | + | } | |||
| 54 | + | offset := data.Offset | |||
| 55 | + | if offset < 0 { | |||
| 56 | + | offset = 0 | |||
| 57 | + | } | |||
| 58 | + | ||||
| 59 | + | var out strings.Builder | |||
| 60 | + | out.WriteString(`<form class="sv-toolbar sv-history-search" data-sv-action="history.search">`) | |||
| 61 | + | out.WriteString(fmt.Sprintf(`<label class="sv-search-label"><span>Search</span><input data-sv-history-search name="query" type="search" value="%s" placeholder="Search title or URL" autocomplete="off"></label>`, html.EscapeString(query))) | |||
| 62 | + | out.WriteString(`<button class="sv-button" type="submit">Search</button>`) | |||
| 63 | + | out.WriteString(`<button class="sv-button sv-button-secondary" type="button" data-sv-action="history.clear">Clear</button>`) | |||
| 64 | + | out.WriteString(`</form>`) | |||
| 65 | + | ||||
| 66 | + | if query != "" || domain != "" { | |||
| 67 | + | out.WriteString(`<div class="sv-active-filters">`) | |||
| 68 | + | if query != "" { | |||
| 69 | + | out.WriteString(fmt.Sprintf(`<span class="sv-filter-chip">Query: %s</span>`, html.EscapeString(query))) | |||
| 70 | + | } | |||
| 71 | + | if domain != "" { | |||
| 72 | + | out.WriteString(fmt.Sprintf(`<span class="sv-filter-chip">Domain: %s <button type="button" data-sv-action="history.clearDomain" aria-label="Clear domain filter">×</button></span>`, html.EscapeString(domain))) | |||
| 73 | + | } | |||
| 74 | + | out.WriteString(`</div>`) | |||
| 75 | + | } | |||
| 76 | + | ||||
| 77 | + | out.WriteString(`<div class="sv-history-actions-panel">`) | |||
| 78 | + | out.WriteString(`<div class="sv-button-row" aria-label="History pagination">`) | |||
| 79 | + | prevOffset := offset - limit | |||
| 80 | + | if prevOffset < 0 { | |||
| 81 | + | prevOffset = 0 | |||
| 82 | + | } | |||
| 83 | + | out.WriteString(historyActionButton("Previous", historyActionPage, map[string]string{"offset": fmt.Sprintf("%d", prevOffset)}, false, offset == 0 || query != "")) | |||
| 84 | + | out.WriteString(fmt.Sprintf(`<span class="sv-meta">Showing %d item%s%s</span>`, countHistoryEntries(data.Entries), pluralSuffix(countHistoryEntries(data.Entries)), historyOffsetLabel(offset, query))) | |||
| 85 | + | out.WriteString(historyActionButton("Next", historyActionPage, map[string]string{"offset": fmt.Sprintf("%d", offset+limit)}, false, query != "" || countHistoryEntries(data.Entries) < limit)) | |||
| 86 | + | out.WriteString(`</div>`) | |||
| 87 | + | ||||
| 88 | + | out.WriteString(`<div class="sv-button-row" aria-label="History cleanup">`) | |||
| 89 | + | out.WriteString(`<span class="sv-meta">Clean up</span>`) | |||
| 90 | + | for _, item := range []struct { | |||
| 91 | + | label string | |||
| 92 | + | rangeID string | |||
| 93 | + | }{ | |||
| 94 | + | {label: "Last hour", rangeID: "hour"}, | |||
| 95 | + | {label: "Today", rangeID: "day"}, | |||
| 96 | + | {label: "Week", rangeID: "week"}, | |||
| 97 | + | {label: "Month", rangeID: "month"}, | |||
| 98 | + | {label: "All", rangeID: "all"}, | |||
| 99 | + | } { | |||
| 100 | + | confirm := "Delete history for " + strings.ToLower(item.label) + "?" | |||
| 101 | + | if item.rangeID == "all" { | |||
| 102 | + | confirm = "Delete all browsing history?" | |||
| 103 | + | } | |||
| 104 | + | out.WriteString(historyActionButton(item.label, historyActionDeleteRange, map[string]string{"range": item.rangeID, "sv-confirm": confirm}, true, false)) | |||
| 105 | + | } | |||
| 106 | + | out.WriteString(`</div>`) | |||
| 107 | + | out.WriteString(`</div>`) | |||
| 108 | + | ||||
| 109 | + | out.WriteString(`<p class="sv-meta sv-key-hints">Keys: <kbd>/</kbd> search, <kbd>j</kbd>/<kbd>k</kbd> move, <kbd>Enter</kbd> open, <kbd>d</kbd> delete focused entry.</p>`) | |||
| 110 | + | return out.String() | |||
| 111 | + | } | |||
| 112 | + | ||||
| 113 | + | func historyOffsetLabel(offset int, query string) string { | |||
| 114 | + | if strings.TrimSpace(query) != "" { | |||
| 115 | + | return " matching search" | |||
| 116 | + | } | |||
| 117 | + | if offset <= 0 { | |||
| 118 | + | return "" | |||
| 119 | + | } | |||
| 120 | + | return fmt.Sprintf(" from offset %d", offset) | |||
| 121 | + | } | |||
| 122 | + | ||||
| 123 | + | func historyActionButton(label, action string, data map[string]string, danger, disabled bool) string { | |||
| 124 | + | classes := "sv-button sv-button-secondary" | |||
| 125 | + | if danger { | |||
| 126 | + | classes += " sv-button-danger" | |||
| 127 | + | } | |||
| 128 | + | attrs := []string{ | |||
| 129 | + | `type="button"`, | |||
| 130 | + | fmt.Sprintf(`class="%s"`, html.EscapeString(classes)), | |||
| 131 | + | fmt.Sprintf(`data-sv-action="%s"`, html.EscapeString(action)), | |||
| 132 | + | } | |||
| 133 | + | if disabled { | |||
| 134 | + | attrs = append(attrs, `disabled aria-disabled="true"`) | |||
| 135 | + | } | |||
| 136 | + | for key, value := range data { | |||
| 137 | + | attrs = append(attrs, fmt.Sprintf(`data-%s="%s"`, html.EscapeString(kebabDataKey(key)), html.EscapeString(value))) | |||
| 138 | + | } | |||
| 139 | + | return fmt.Sprintf(`<button %s>%s</button>`, strings.Join(attrs, " "), html.EscapeString(label)) | |||
| 140 | + | } | |||
| 141 | + | ||||
| 142 | + | func kebabDataKey(key string) string { | |||
| 143 | + | return strings.ReplaceAll(key, "_", "-") | |||
| 144 | + | } | |||
| 145 | + | ||||
| 146 | + | func pluralSuffix(count int) string { | |||
| 147 | + | if count == 1 { | |||
| 148 | + | return "" | |||
| 149 | + | } | |||
| 150 | + | return "s" | |||
| 151 | + | } | |||
| 152 | + | ||||
| 28 | func historySummaryHTML(data historyRenderData) string { | 153 | func historySummaryHTML(data historyRenderData) string { | ||
| 29 | entries := int64(countHistoryEntries(data.Entries)) | 154 | entries := int64(countHistoryEntries(data.Entries)) | ||
| 30 | visits := sumHistoryVisits(data.Entries) | 155 | visits := sumHistoryVisits(data.Entries) | ||
| @@ -53,12 +178,21 @@ func historyDomainsHTML(domains []*entity.DomainStat) string { | |||||
| 53 | if domain == nil || strings.TrimSpace(domain.Domain) == "" { | 178 | if domain == nil || strings.TrimSpace(domain.Domain) == "" { | ||
| 54 | continue | 179 | continue | ||
| 55 | } | 180 | } | ||
| 181 | + | displayDomain := strings.TrimPrefix(domain.Domain, "www.") | |||
| 182 | + | chips.WriteString(`<span class="sv-domain-chip">`) | |||
| 183 | + | chips.WriteString(fmt.Sprintf( | |||
| 184 | + | `<button type="button" class="sv-domain-filter" data-sv-action="history.filterDomain" data-domain="%s">%s</button>`, | |||
| 185 | + | html.EscapeString(displayDomain), | |||
| 186 | + | html.EscapeString(displayDomain), | |||
| 187 | + | )) | |||
| 188 | + | chips.WriteString(fmt.Sprintf(`<span class="sv-domain-meta">%d pages · %d visits</span>`, domain.PageCount, domain.TotalVisits)) | |||
| 56 | chips.WriteString(fmt.Sprintf( | 189 | chips.WriteString(fmt.Sprintf( | ||
| 57 | - | `<span class="sv-domain-chip"><span>%s</span><span class="sv-domain-meta">%d pages · %d visits</span></span>`, | 190 | + | `<button type="button" class="sv-icon-button sv-danger" data-sv-action="history.deleteDomain" data-domain="%s" data-sv-confirm="Delete all history for %s?" aria-label="Delete history for %s">×</button>`, |
| 58 | - | html.EscapeString(strings.TrimPrefix(domain.Domain, "www.")), | 191 | + | html.EscapeString(displayDomain), |
| 59 | - | domain.PageCount, | 192 | + | html.EscapeString(displayDomain), |
| 60 | - | domain.TotalVisits, | 193 | + | html.EscapeString(displayDomain), |
| 61 | )) | 194 | )) | ||
| 195 | + | chips.WriteString(`</span>`) | |||
| 62 | } | 196 | } | ||
| 63 | if chips.Len() == 0 { | 197 | if chips.Len() == 0 { | ||
| 64 | return emptyStateHTML("No domain statistics yet") | 198 | return emptyStateHTML("No domain statistics yet") | ||
| @@ -66,9 +200,12 @@ func historyDomainsHTML(domains []*entity.DomainStat) string { | |||||
| 66 | return `<div class="sv-domain-list">` + chips.String() + `</div>` | 200 | return `<div class="sv-domain-list">` + chips.String() + `</div>` | ||
| 67 | } | 201 | } | ||
| 68 | 202 | ||||
| 69 | - | func historyTimelineHTML(entries []*entity.HistoryEntry) string { | 203 | + | func historyTimelineHTML(data historyRenderData) string { |
| 70 | - | groups := groupHistoryEntries(entries) | 204 | + | groups := groupHistoryEntries(data.Entries) |
| 71 | if len(groups) == 0 { | 205 | if len(groups) == 0 { | ||
| 206 | + | if strings.TrimSpace(data.Query) != "" || strings.TrimSpace(data.DomainFilter) != "" { | |||
| 207 | + | return emptyStateHTML("No history entries match the current filters") | |||
| 208 | + | } | |||
| 72 | return emptyStateHTML("No history entries") | 209 | return emptyStateHTML("No history entries") | ||
| 73 | } | 210 | } | ||
| 74 | 211 | ||||
| @@ -111,10 +248,19 @@ func historyItemHTML(entry *entity.HistoryEntry) string { | |||||
| 111 | meta = append(meta, formatHistoryTime(entry.LastVisited)) | 248 | meta = append(meta, formatHistoryTime(entry.LastVisited)) | ||
| 112 | } | 249 | } | ||
| 113 | 250 | ||||
| 114 | - | return fmt.Sprintf(`<article class="sv-history-item"><div class="sv-history-main">%s<p class="sv-history-url">%s</p></div><p class="sv-history-meta">%s</p></article>`, | 251 | + | deleteButton := historyActionButton("Delete", historyActionDeleteEntry, map[string]string{ |
| 115 | - | linkHTML(entry.URL, label), | 252 | + | "id": fmt.Sprintf("%d", entry.ID), |
| 253 | + | "sv-confirm": "Delete this history entry?", | |||
| 254 | + | }, true, entry.ID <= 0) | |||
| 255 | + | ||||
| 256 | + | return fmt.Sprintf(`<article class="sv-history-item" data-sv-history-row data-history-id="%d"><div class="sv-history-main"><a class="sv-link sv-history-open" href="%s">%s</a><p class="sv-history-url">%s</p><p class="sv-history-meta">%s</p></div><div class="sv-history-row-actions"><a class="sv-button sv-button-secondary" href="%s">Open</a>%s</div></article>`, | |||
| 257 | + | entry.ID, | |||
| 258 | + | html.EscapeString(sanitizeHref(entry.URL)), | |||
| 259 | + | html.EscapeString(label), | |||
| 116 | html.EscapeString(entry.URL), | 260 | html.EscapeString(entry.URL), | ||
| 117 | html.EscapeString(strings.Join(nonEmptyStrings(meta), " · ")), | 261 | html.EscapeString(strings.Join(nonEmptyStrings(meta), " · ")), | ||
| 262 | + | html.EscapeString(sanitizeHref(entry.URL)), | |||
| 263 | + | deleteButton, | |||
| 118 | ) | 264 | ) | ||
| 119 | } | 265 | } | ||
| 120 | 266 | ||||
| @@ -201,6 +347,24 @@ func countUniqueHistoryDays(entries []*entity.HistoryEntry) int64 { | |||||
| 201 | return int64(len(seen)) | 347 | return int64(len(seen)) | ||
| 202 | } | 348 | } | ||
| 203 | 349 | ||||
| 350 | + | func filterHistoryEntriesByDomain(entries []*entity.HistoryEntry, domain string) []*entity.HistoryEntry { | |||
| 351 | + | domain = strings.TrimPrefix(strings.ToLower(strings.TrimSpace(domain)), "www.") | |||
| 352 | + | if domain == "" { | |||
| 353 | + | return entries | |||
| 354 | + | } | |||
| 355 | + | filtered := make([]*entity.HistoryEntry, 0, len(entries)) | |||
| 356 | + | for _, entry := range entries { | |||
| 357 | + | if entry == nil { | |||
| 358 | + | continue | |||
| 359 | + | } | |||
| 360 | + | entryDomain := strings.TrimPrefix(strings.ToLower(displayHistoryDomain(entry.URL)), "www.") | |||
| 361 | + | if entryDomain == domain { | |||
| 362 | + | filtered = append(filtered, entry) | |||
| 363 | + | } | |||
| 364 | + | } | |||
| 365 | + | return filtered | |||
| 366 | + | } | |||
| 367 | + | ||||
| 204 | func displayHistoryDomain(raw string) string { | 368 | func displayHistoryDomain(raw string) string { | ||
| 205 | parsed, err := url.Parse(raw) | 369 | parsed, err := url.Parse(raw) | ||
| 206 | if err != nil || parsed.Hostname() == "" { | 370 | if err != nil || parsed.Hostname() == "" { | ||