detached a743a66

refactor(systemviews): render views with templ

commit
a743a66cbcbf9a47d8ec1c076e2c17b6fd8d8f54
author
brice · · 2026-04-24 02:48 UTC
parent
40aec41
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
14 files · +3255 -429
M Makefile +7 -2
@@ -1,6 +1,6 @@
11 # Makefile for dumber (Clean Architecture - puregotk)
22
3-.PHONY: build build-systemviews build-quick install-local test lint clean install-tools dev generate help init man flatpak-deps flatpak-build flatpak-install flatpak-run flatpak-clean stress-omnibox-callbacks verify-purego check
3+.PHONY: build build-systemviews generate-systemviews build-quick install-local test lint clean install-tools dev generate help init man flatpak-deps flatpak-build flatpak-install flatpak-run flatpak-clean stress-omnibox-callbacks verify-purego check
44
55 # Load local overrides from .env.local if present (Makefile syntax)
66 ifneq (,$(wildcard .env.local))
@@ -46,7 +46,12 @@ build: build-systemviews ## Build the application (pure Go, no CGO)
4646 @rm -f $(DIST_DIR)/cef-helper
4747 @echo "Build successful! Binary: $(DIST_DIR)/$(BINARY_NAME)"
4848
49-build-systemviews: ## Build the WASM systemviews runtime
49+generate-systemviews: ## Generate Go code from systemviews templ components
50+ @echo "Generating systemviews templ components..."
51+ go tool templ generate -path internal/ui/systemviews -include-version=false
52+ @echo "Systemviews templ generation complete"
53+
54+build-systemviews: generate-systemviews ## Build the WASM systemviews runtime
5055 @echo "Building systemviews wasm assets..."
5156 @mkdir -p assets/systemviews
5257 @cp "$$(go env GOROOT)/lib/wasm/wasm_exec.js" assets/systemviews/wasm_exec.js
@@ -1,6 +1,6 @@
1 # Makefile for dumber (Clean Architecture - puregotk)1 # Makefile for dumber (Clean Architecture - puregotk)
2 2
3-.PHONY: build build-systemviews build-quick install-local test lint clean install-tools dev generate help init man flatpak-deps flatpak-build flatpak-install flatpak-run flatpak-clean stress-omnibox-callbacks verify-purego check3+.PHONY: build build-systemviews generate-systemviews build-quick install-local test lint clean install-tools dev generate help init man flatpak-deps flatpak-build flatpak-install flatpak-run flatpak-clean stress-omnibox-callbacks verify-purego check
4 4
5 # Load local overrides from .env.local if present (Makefile syntax)5 # Load local overrides from .env.local if present (Makefile syntax)
6 ifneq (,$(wildcard .env.local))6 ifneq (,$(wildcard .env.local))
@@ -46,7 +46,12 @@ build: build-systemviews ## Build the application (pure Go, no CGO)
46 @rm -f $(DIST_DIR)/cef-helper46 @rm -f $(DIST_DIR)/cef-helper
47 @echo "Build successful! Binary: $(DIST_DIR)/$(BINARY_NAME)"47 @echo "Build successful! Binary: $(DIST_DIR)/$(BINARY_NAME)"
48 48
49-build-systemviews: ## Build the WASM systemviews runtime49+generate-systemviews: ## Generate Go code from systemviews templ components
50+ @echo "Generating systemviews templ components..."
51+ go tool templ generate -path internal/ui/systemviews -include-version=false
52+ @echo "Systemviews templ generation complete"
53+
54+build-systemviews: generate-systemviews ## Build the WASM systemviews runtime
50 @echo "Building systemviews wasm assets..."55 @echo "Building systemviews wasm assets..."
51 @mkdir -p assets/systemviews56 @mkdir -p assets/systemviews
52 @cp "$$(go env GOROOT)/lib/wasm/wasm_exec.js" assets/systemviews/wasm_exec.js57 @cp "$$(go env GOROOT)/lib/wasm/wasm_exec.js" assets/systemviews/wasm_exec.js
M go.mod +12 -0
@@ -3,6 +3,7 @@ module github.com/bnema/dumber
33 go 1.26.1
44
55 require (
6+ github.com/a-h/templ v0.3.1001
67 github.com/bnema/purego v0.11.0-bnema.2
78 github.com/bnema/purego-cef v0.10.2
89 github.com/bnema/purego-ffmpeg v0.3.1
@@ -27,18 +28,23 @@ require (
2728 )
2829
2930 require (
31+ github.com/a-h/parse v0.0.0-20250122154542-74294addb73e // indirect
32+ github.com/andybalholm/brotli v1.2.0 // indirect
3033 github.com/atotto/clipboard v0.1.4 // indirect
3134 github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
35+ github.com/cenkalti/backoff/v4 v4.3.0 // indirect
3236 github.com/charmbracelet/colorprofile v0.4.1 // indirect
3337 github.com/charmbracelet/x/ansi v0.11.6 // indirect
3438 github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
3539 github.com/charmbracelet/x/term v0.2.2 // indirect
40+ github.com/cli/browser v1.3.0 // indirect
3641 github.com/clipperhouse/displaywidth v0.9.0 // indirect
3742 github.com/clipperhouse/stringish v0.1.1 // indirect
3843 github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
3944 github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect
4045 github.com/davecgh/go-spew v1.1.1 // indirect
4146 github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
47+ github.com/fatih/color v1.16.0 // indirect
4248 github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
4349 github.com/inconshreveable/mousetrap v1.1.0 // indirect
4450 github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
@@ -50,6 +56,7 @@ require (
5056 github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
5157 github.com/muesli/cancelreader v0.2.2 // indirect
5258 github.com/muesli/termenv v0.16.0 // indirect
59+ github.com/natefinch/atomic v1.0.1 // indirect
5360 github.com/pmezard/go-difflib v1.0.0 // indirect
5461 github.com/rivo/uniseg v0.4.7 // indirect
5562 github.com/russross/blackfriday/v2 v2.1.0 // indirect
@@ -64,8 +71,13 @@ require (
6471 github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
6572 go.uber.org/multierr v1.11.0 // indirect
6673 go.yaml.in/yaml/v3 v3.0.4 // indirect
74+ golang.org/x/mod v0.34.0 // indirect
75+ golang.org/x/net v0.52.0 // indirect
6776 golang.org/x/sync v0.20.0 // indirect
6877 golang.org/x/text v0.36.0 // indirect
78+ golang.org/x/tools v0.43.0 // indirect
6979 gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect
7080 modernc.org/sqlite v1.47.0 // indirect
7181 )
82+
83+tool github.com/a-h/templ/cmd/templ
@@ -3,6 +3,7 @@ module github.com/bnema/dumber
3 go 1.26.13 go 1.26.1
4 4
5 require (5 require (
6+ github.com/a-h/templ v0.3.1001
6 github.com/bnema/purego v0.11.0-bnema.27 github.com/bnema/purego v0.11.0-bnema.2
7 github.com/bnema/purego-cef v0.10.28 github.com/bnema/purego-cef v0.10.2
8 github.com/bnema/purego-ffmpeg v0.3.19 github.com/bnema/purego-ffmpeg v0.3.1
@@ -27,18 +28,23 @@ require (
27 )28 )
28 29
29 require (30 require (
31+ github.com/a-h/parse v0.0.0-20250122154542-74294addb73e // indirect
32+ github.com/andybalholm/brotli v1.2.0 // indirect
30 github.com/atotto/clipboard v0.1.4 // indirect33 github.com/atotto/clipboard v0.1.4 // indirect
31 github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect34 github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
35+ github.com/cenkalti/backoff/v4 v4.3.0 // indirect
32 github.com/charmbracelet/colorprofile v0.4.1 // indirect36 github.com/charmbracelet/colorprofile v0.4.1 // indirect
33 github.com/charmbracelet/x/ansi v0.11.6 // indirect37 github.com/charmbracelet/x/ansi v0.11.6 // indirect
34 github.com/charmbracelet/x/cellbuf v0.0.15 // indirect38 github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
35 github.com/charmbracelet/x/term v0.2.2 // indirect39 github.com/charmbracelet/x/term v0.2.2 // indirect
40+ github.com/cli/browser v1.3.0 // indirect
36 github.com/clipperhouse/displaywidth v0.9.0 // indirect41 github.com/clipperhouse/displaywidth v0.9.0 // indirect
37 github.com/clipperhouse/stringish v0.1.1 // indirect42 github.com/clipperhouse/stringish v0.1.1 // indirect
38 github.com/clipperhouse/uax29/v2 v2.5.0 // indirect43 github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
39 github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect44 github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect
40 github.com/davecgh/go-spew v1.1.1 // indirect45 github.com/davecgh/go-spew v1.1.1 // indirect
41 github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect46 github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
47+ github.com/fatih/color v1.16.0 // indirect
42 github.com/go-viper/mapstructure/v2 v2.5.0 // indirect48 github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
43 github.com/inconshreveable/mousetrap v1.1.0 // indirect49 github.com/inconshreveable/mousetrap v1.1.0 // indirect
44 github.com/lucasb-eyer/go-colorful v1.3.0 // indirect50 github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
@@ -50,6 +56,7 @@ require (
50 github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect56 github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
51 github.com/muesli/cancelreader v0.2.2 // indirect57 github.com/muesli/cancelreader v0.2.2 // indirect
52 github.com/muesli/termenv v0.16.0 // indirect58 github.com/muesli/termenv v0.16.0 // indirect
59+ github.com/natefinch/atomic v1.0.1 // indirect
53 github.com/pmezard/go-difflib v1.0.0 // indirect60 github.com/pmezard/go-difflib v1.0.0 // indirect
54 github.com/rivo/uniseg v0.4.7 // indirect61 github.com/rivo/uniseg v0.4.7 // indirect
55 github.com/russross/blackfriday/v2 v2.1.0 // indirect62 github.com/russross/blackfriday/v2 v2.1.0 // indirect
@@ -64,8 +71,13 @@ require (
64 github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect71 github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
65 go.uber.org/multierr v1.11.0 // indirect72 go.uber.org/multierr v1.11.0 // indirect
66 go.yaml.in/yaml/v3 v3.0.4 // indirect73 go.yaml.in/yaml/v3 v3.0.4 // indirect
74+ golang.org/x/mod v0.34.0 // indirect
75+ golang.org/x/net v0.52.0 // indirect
67 golang.org/x/sync v0.20.0 // indirect76 golang.org/x/sync v0.20.0 // indirect
68 golang.org/x/text v0.36.0 // indirect77 golang.org/x/text v0.36.0 // indirect
78+ golang.org/x/tools v0.43.0 // indirect
69 gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect79 gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect
70 modernc.org/sqlite v1.47.0 // indirect80 modernc.org/sqlite v1.47.0 // indirect
71 )81 )
82+
83+tool github.com/a-h/templ/cmd/templ
M go.sum +22 -0 generated file: collapsed
A internal/ui/systemviews/components.templ +77 -0
@@ -0,0 +1,77 @@
1+package systemviews
2+
3+templ appFrameComponent(page renderedPage, theme shellTheme, body templ.Component) {
4+ <div class={ appRootClass(theme) } data-route={ string(page.route) }
5+ if theme.InlineVars != "" {
6+ style={ theme.InlineVars }
7+ }
8+ >
9+ <div class="sv-shell">
10+ if page.subtitle != "" {
11+ <h1 class="sv-title">{ page.subtitle }</h1>
12+ }
13+ <div class="sv-content">
14+ @body
15+ </div>
16+ </div>
17+ </div>
18+}
19+
20+templ Section(className string, title string) {
21+ <section class={ sectionClass(className) }>
22+ <h2>{ title }</h2>
23+ { children... }
24+ </section>
25+}
26+
27+templ Meta(text string) {
28+ <p class="sv-meta">{ text }</p>
29+}
30+
31+templ EmptyState(message string) {
32+ <div class="sv-empty"><p>{ message }</p></div>
33+}
34+
35+templ Alert(kind string, message string) {
36+ if message != "" {
37+ <div class={ alertClass(kind) } role={ alertRole(kind) }>{ message }</div>
38+ }
39+}
40+
41+templ List(emptyMessage string) {
42+ <ul class="sv-list">
43+ { children... }
44+ </ul>
45+}
46+
47+templ ListRow() {
48+ <li class="sv-list-row">
49+ { children... }
50+ </li>
51+}
52+
53+templ Button(label string, action string, classes string, attrs templ.Attributes, disabled bool) {
54+ <button type="button" class={ buttonClass(classes) } data-sv-action={ action } disabled?={ disabled } { attrs... }>{ label }</button>
55+}
56+
57+templ KeyList(keys []string) {
58+ if len(keys) == 0 {
59+ <span class="sv-key sv-key-empty">Unassigned</span>
60+ } else {
61+ for _, key := range keys {
62+ <span class="sv-key">{ key }</span>
63+ }
64+ }
65+}
66+
67+templ KV(rows []kvPair) {
68+ if len(rows) == 0 {
69+ @EmptyState("No configuration details")
70+ } else {
71+ <dl class="sv-kv">
72+ for _, row := range rows {
73+ <div class="sv-kv-row"><dt>{ row.Label }</dt><dd>{ row.Value }</dd></div>
74+ }
75+ </dl>
76+ }
77+}
@@ -0,0 +1,77 @@
1+package systemviews
2+
3+templ appFrameComponent(page renderedPage, theme shellTheme, body templ.Component) {
4+ <div class={ appRootClass(theme) } data-route={ string(page.route) }
5+ if theme.InlineVars != "" {
6+ style={ theme.InlineVars }
7+ }
8+ >
9+ <div class="sv-shell">
10+ if page.subtitle != "" {
11+ <h1 class="sv-title">{ page.subtitle }</h1>
12+ }
13+ <div class="sv-content">
14+ @body
15+ </div>
16+ </div>
17+ </div>
18+}
19+
20+templ Section(className string, title string) {
21+ <section class={ sectionClass(className) }>
22+ <h2>{ title }</h2>
23+ { children... }
24+ </section>
25+}
26+
27+templ Meta(text string) {
28+ <p class="sv-meta">{ text }</p>
29+}
30+
31+templ EmptyState(message string) {
32+ <div class="sv-empty"><p>{ message }</p></div>
33+}
34+
35+templ Alert(kind string, message string) {
36+ if message != "" {
37+ <div class={ alertClass(kind) } role={ alertRole(kind) }>{ message }</div>
38+ }
39+}
40+
41+templ List(emptyMessage string) {
42+ <ul class="sv-list">
43+ { children... }
44+ </ul>
45+}
46+
47+templ ListRow() {
48+ <li class="sv-list-row">
49+ { children... }
50+ </li>
51+}
52+
53+templ Button(label string, action string, classes string, attrs templ.Attributes, disabled bool) {
54+ <button type="button" class={ buttonClass(classes) } data-sv-action={ action } disabled?={ disabled } { attrs... }>{ label }</button>
55+}
56+
57+templ KeyList(keys []string) {
58+ if len(keys) == 0 {
59+ <span class="sv-key sv-key-empty">Unassigned</span>
60+ } else {
61+ for _, key := range keys {
62+ <span class="sv-key">{ key }</span>
63+ }
64+ }
65+}
66+
67+templ KV(rows []kvPair) {
68+ if len(rows) == 0 {
69+ @EmptyState("No configuration details")
70+ } else {
71+ <dl class="sv-kv">
72+ for _, row := range rows {
73+ <div class="sv-kv-row"><dt>{ row.Label }</dt><dd>{ row.Value }</dd></div>
74+ }
75+ </dl>
76+ }
77+}
A internal/ui/systemviews/components_templ.go +635 -0 generated file: collapsed
A internal/ui/systemviews/favorites.templ +199 -0
@@ -0,0 +1,199 @@
1+package systemviews
2+
3+import (
4+ "fmt"
5+ "github.com/bnema/dumber/internal/domain/entity"
6+)
7+
8+templ FavoritesView(data favoritesRenderData) {
9+ @Alert("success", data.Notice)
10+ @Alert("error", data.Error)
11+ @Section("sv-favorites-controls", "Manage favorites") {
12+ @FavoritesControls(data)
13+ }
14+ @Section("sv-favorites-list", "Favorites") {
15+ @Meta(favoritesSummary(data))
16+ if len(data.Favorites) == 0 {
17+ @EmptyState("No favorites")
18+ } else {
19+ <ul class="sv-list">
20+ for _, favorite := range data.Favorites {
21+ if favorite != nil {
22+ @FavoriteItem(favorite, data.Folders, data.Tags)
23+ }
24+ }
25+ </ul>
26+ }
27+ }
28+ @Section("sv-folders-list", "Folders") {
29+ @Meta(foldersSummary(data))
30+ @FolderCreateForm()
31+ if len(data.Folders) == 0 {
32+ @EmptyState("No folders")
33+ } else {
34+ <ul class="sv-list">
35+ for _, folder := range data.Folders {
36+ if folder != nil {
37+ @FolderItem(folder)
38+ }
39+ }
40+ </ul>
41+ }
42+ }
43+ @Section("sv-tags-list", "Tags") {
44+ @Meta(tagsSummary(data))
45+ @TagCreateForm()
46+ if len(data.Tags) == 0 {
47+ @EmptyState("No tags")
48+ } else {
49+ <ul class="sv-list">
50+ for _, tag := range data.Tags {
51+ if tag != nil {
52+ @TagItem(tag)
53+ }
54+ }
55+ </ul>
56+ }
57+ }
58+}
59+
60+templ FavoritesControls(data favoritesRenderData) {
61+ <form class="sv-card-form" data-sv-action="favorite.create">
62+ <label><span>URL</span><input name="url" type="url" placeholder="https://example.com" required/></label>
63+ <label><span>Title</span><input name="title" type="text" placeholder="Optional title"/></label>
64+ @FolderSelect("folder_id", data.Folders, nil, "Folder")
65+ <button class="sv-button" type="submit">Add favorite</button>
66+ </form>
67+ <div class="sv-filter-panel">
68+ <div class="sv-button-row">
69+ <span class="sv-meta">Folders</span>
70+ <button type="button" class={ favoriteFilterClass(favoriteAllFilterActive(data)) } data-sv-action="favorite.clearFilters">All</button>
71+ <button type="button" class={ favoriteFilterClass(favoriteFolderFilterActive(data, entity.FolderID(0))) } data-sv-action="favorite.filterFolder" data-folder-id="root">Unfiled</button>
72+ for _, folder := range data.Folders {
73+ if folder != nil {
74+ <button type="button" class={ favoriteFilterClass(favoriteFolderFilterActive(data, folder.ID)) } data-sv-action="favorite.filterFolder" data-folder-id={ fmt.Sprintf("%d", folder.ID) }>{ folderDisplayName(folder) }</button>
75+ }
76+ }
77+ </div>
78+ <div class="sv-button-row">
79+ <span class="sv-meta">Tags</span>
80+ for _, tag := range data.Tags {
81+ if tag != nil {
82+ <button type="button" class={ favoriteFilterClass(favoriteTagFilterActive(data, tag.ID)) } data-sv-action="favorite.filterTag" data-tag-id={ fmt.Sprintf("%d", tag.ID) }>
83+ <span class="sv-tag-dot" style={ "background:" + tag.Color }></span>{ tag.Name }
84+ </button>
85+ }
86+ }
87+ </div>
88+ </div>
89+ <p class="sv-meta sv-key-hints">Keys: <kbd>j</kbd>/<kbd>k</kbd> move, <kbd>Enter</kbd> open, <kbd>e</kbd> edit title, <kbd>d</kbd> delete focused favorite.</p>
90+}
91+
92+templ FavoriteItem(favorite *entity.Favorite, folders []*entity.Folder, tags []*entity.Tag) {
93+ <li class="sv-list-row">
94+ <article class="sv-favorite-item" data-sv-favorite-row data-favorite-id={ fmt.Sprintf("%d", favorite.ID) }>
95+ <div class="sv-favorite-header">
96+ <div>
97+ <a class="sv-link sv-favorite-open" href={ templ.SafeURL(sanitizeHref(favorite.URL)) }>{ favoriteItemLabel(favorite) }</a>
98+ <p class="sv-history-url">{ favorite.URL }</p>
99+ if favoriteMetaText(favorite, folders) != "" {
100+ <p class="sv-meta">{ favoriteMetaText(favorite, folders) }</p>
101+ }
102+ </div>
103+ <div class="sv-history-row-actions">
104+ <a class="sv-button sv-button-secondary" href={ templ.SafeURL(sanitizeHref(favorite.URL)) }>Open</a>
105+ <button type="button" class="sv-button sv-button-secondary sv-button-danger" data-sv-action="favorite.delete" data-id={ fmt.Sprintf("%d", favorite.ID) } data-sv-confirm="Delete this favorite?">Delete</button>
106+ </div>
107+ </div>
108+ @FavoriteEditForm(favorite, folders)
109+ @FavoriteTagControls(favorite, tags)
110+ </article>
111+ </li>
112+}
113+
114+templ FavoriteEditForm(favorite *entity.Favorite, folders []*entity.Folder) {
115+ <form class="sv-inline-form" data-sv-action="favorite.update">
116+ <input type="hidden" name="id" value={ fmt.Sprintf("%d", favorite.ID) }/>
117+ <label><span>Title</span><input name="title" type="text" value={ favorite.Title }/></label>
118+ <label><span>Favicon</span><input name="favicon_url" type="url" value={ favorite.FaviconURL }/></label>
119+ @FolderSelect("folder_id", folders, favorite.FolderID, "Folder")
120+ @ShortcutSelect(favorite.ShortcutKey)
121+ <button class="sv-button sv-button-secondary" type="submit">Save</button>
122+ </form>
123+}
124+
125+templ FavoriteTagControls(favorite *entity.Favorite, tags []*entity.Tag) {
126+ if len(tags) > 0 {
127+ <div class="sv-tag-controls">
128+ <span class="sv-meta">Tags</span>
129+ for _, tag := range tags {
130+ if tag != nil {
131+ <button type="button" class={ favoriteTagButtonClass(favorite, tag) } data-sv-action={ favoriteTagButtonAction(favorite, tag) } data-favorite-id={ fmt.Sprintf("%d", favorite.ID) } data-tag-id={ fmt.Sprintf("%d", tag.ID) }>
132+ <span class="sv-tag-dot" style={ "background:" + tag.Color }></span>{ favoriteTagButtonLabel(favorite, tag) }
133+ </button>
134+ }
135+ }
136+ </div>
137+ }
138+}
139+
140+templ FolderCreateForm() {
141+ <form class="sv-inline-form" data-sv-action="folder.create">
142+ <label><span>Name</span><input name="name" type="text" required/></label>
143+ <label><span>Icon</span><input name="icon" type="text" placeholder="Optional"/></label>
144+ <button class="sv-button sv-button-secondary" type="submit">Create folder</button>
145+ </form>
146+}
147+
148+templ FolderItem(folder *entity.Folder) {
149+ <li class="sv-list-row">
150+ <form class="sv-inline-form" data-sv-action="folder.update">
151+ <input type="hidden" name="id" value={ fmt.Sprintf("%d", folder.ID) }/>
152+ <label><span>Name</span><input name="name" type="text" value={ folder.Name } required/></label>
153+ <label><span>Icon</span><input name="icon" type="text" value={ folder.Icon }/></label>
154+ <button class="sv-button sv-button-secondary" type="submit">Save</button>
155+ <button type="button" class="sv-button sv-button-secondary sv-button-danger" data-sv-action="folder.delete" data-id={ fmt.Sprintf("%d", folder.ID) } data-sv-confirm="Delete this folder? Favorites will move out of it.">Delete</button>
156+ </form>
157+ </li>
158+}
159+
160+templ TagCreateForm() {
161+ <form class="sv-inline-form" data-sv-action="tag.create">
162+ <label><span>Name</span><input name="name" type="text" required/></label>
163+ <label><span>Color</span><input name="color" type="text" placeholder="#808080"/></label>
164+ <button class="sv-button sv-button-secondary" type="submit">Create tag</button>
165+ </form>
166+}
167+
168+templ TagItem(tag *entity.Tag) {
169+ <li class="sv-list-row">
170+ <form class="sv-inline-form" data-sv-action="tag.update">
171+ <input type="hidden" name="id" value={ fmt.Sprintf("%d", tag.ID) }/>
172+ <span class="sv-tag-dot" style={ "background:" + tag.Color }></span>
173+ <label><span>Name</span><input name="name" type="text" value={ tag.Name } required/></label>
174+ <label><span>Color</span><input name="color" type="text" value={ tag.Color }/></label>
175+ <button class="sv-button sv-button-secondary" type="submit">Save</button>
176+ <button type="button" class="sv-button sv-button-secondary sv-button-danger" data-sv-action="tag.delete" data-id={ fmt.Sprintf("%d", tag.ID) } data-sv-confirm="Delete this tag?">Delete</button>
177+ </form>
178+ </li>
179+}
180+
181+templ FolderSelect(name string, folders []*entity.Folder, selected *entity.FolderID, label string) {
182+ <label><span>{ label }</span><select name={ name }>
183+ <option value="">Unfiled</option>
184+ for _, folder := range folders {
185+ if folder != nil {
186+ <option value={ fmt.Sprintf("%d", folder.ID) } selected?={ folderSelected(selected, folder.ID) }>{ folderDisplayName(folder) }</option>
187+ }
188+ }
189+ </select></label>
190+}
191+
192+templ ShortcutSelect(selected *int) {
193+ <label><span>Shortcut</span><select name="shortcut_key">
194+ <option value="">None</option>
195+ for i := 1; i <= 9; i++ {
196+ <option value={ fmt.Sprintf("%d", i) } selected?={ shortcutSelected(selected, i) }>{ fmt.Sprintf("%d", i) }</option>
197+ }
198+ </select></label>
199+}
@@ -0,0 +1,199 @@
1+package systemviews
2+
3+import (
4+ "fmt"
5+ "github.com/bnema/dumber/internal/domain/entity"
6+)
7+
8+templ FavoritesView(data favoritesRenderData) {
9+ @Alert("success", data.Notice)
10+ @Alert("error", data.Error)
11+ @Section("sv-favorites-controls", "Manage favorites") {
12+ @FavoritesControls(data)
13+ }
14+ @Section("sv-favorites-list", "Favorites") {
15+ @Meta(favoritesSummary(data))
16+ if len(data.Favorites) == 0 {
17+ @EmptyState("No favorites")
18+ } else {
19+ <ul class="sv-list">
20+ for _, favorite := range data.Favorites {
21+ if favorite != nil {
22+ @FavoriteItem(favorite, data.Folders, data.Tags)
23+ }
24+ }
25+ </ul>
26+ }
27+ }
28+ @Section("sv-folders-list", "Folders") {
29+ @Meta(foldersSummary(data))
30+ @FolderCreateForm()
31+ if len(data.Folders) == 0 {
32+ @EmptyState("No folders")
33+ } else {
34+ <ul class="sv-list">
35+ for _, folder := range data.Folders {
36+ if folder != nil {
37+ @FolderItem(folder)
38+ }
39+ }
40+ </ul>
41+ }
42+ }
43+ @Section("sv-tags-list", "Tags") {
44+ @Meta(tagsSummary(data))
45+ @TagCreateForm()
46+ if len(data.Tags) == 0 {
47+ @EmptyState("No tags")
48+ } else {
49+ <ul class="sv-list">
50+ for _, tag := range data.Tags {
51+ if tag != nil {
52+ @TagItem(tag)
53+ }
54+ }
55+ </ul>
56+ }
57+ }
58+}
59+
60+templ FavoritesControls(data favoritesRenderData) {
61+ <form class="sv-card-form" data-sv-action="favorite.create">
62+ <label><span>URL</span><input name="url" type="url" placeholder="https://example.com" required/></label>
63+ <label><span>Title</span><input name="title" type="text" placeholder="Optional title"/></label>
64+ @FolderSelect("folder_id", data.Folders, nil, "Folder")
65+ <button class="sv-button" type="submit">Add favorite</button>
66+ </form>
67+ <div class="sv-filter-panel">
68+ <div class="sv-button-row">
69+ <span class="sv-meta">Folders</span>
70+ <button type="button" class={ favoriteFilterClass(favoriteAllFilterActive(data)) } data-sv-action="favorite.clearFilters">All</button>
71+ <button type="button" class={ favoriteFilterClass(favoriteFolderFilterActive(data, entity.FolderID(0))) } data-sv-action="favorite.filterFolder" data-folder-id="root">Unfiled</button>
72+ for _, folder := range data.Folders {
73+ if folder != nil {
74+ <button type="button" class={ favoriteFilterClass(favoriteFolderFilterActive(data, folder.ID)) } data-sv-action="favorite.filterFolder" data-folder-id={ fmt.Sprintf("%d", folder.ID) }>{ folderDisplayName(folder) }</button>
75+ }
76+ }
77+ </div>
78+ <div class="sv-button-row">
79+ <span class="sv-meta">Tags</span>
80+ for _, tag := range data.Tags {
81+ if tag != nil {
82+ <button type="button" class={ favoriteFilterClass(favoriteTagFilterActive(data, tag.ID)) } data-sv-action="favorite.filterTag" data-tag-id={ fmt.Sprintf("%d", tag.ID) }>
83+ <span class="sv-tag-dot" style={ "background:" + tag.Color }></span>{ tag.Name }
84+ </button>
85+ }
86+ }
87+ </div>
88+ </div>
89+ <p class="sv-meta sv-key-hints">Keys: <kbd>j</kbd>/<kbd>k</kbd> move, <kbd>Enter</kbd> open, <kbd>e</kbd> edit title, <kbd>d</kbd> delete focused favorite.</p>
90+}
91+
92+templ FavoriteItem(favorite *entity.Favorite, folders []*entity.Folder, tags []*entity.Tag) {
93+ <li class="sv-list-row">
94+ <article class="sv-favorite-item" data-sv-favorite-row data-favorite-id={ fmt.Sprintf("%d", favorite.ID) }>
95+ <div class="sv-favorite-header">
96+ <div>
97+ <a class="sv-link sv-favorite-open" href={ templ.SafeURL(sanitizeHref(favorite.URL)) }>{ favoriteItemLabel(favorite) }</a>
98+ <p class="sv-history-url">{ favorite.URL }</p>
99+ if favoriteMetaText(favorite, folders) != "" {
100+ <p class="sv-meta">{ favoriteMetaText(favorite, folders) }</p>
101+ }
102+ </div>
103+ <div class="sv-history-row-actions">
104+ <a class="sv-button sv-button-secondary" href={ templ.SafeURL(sanitizeHref(favorite.URL)) }>Open</a>
105+ <button type="button" class="sv-button sv-button-secondary sv-button-danger" data-sv-action="favorite.delete" data-id={ fmt.Sprintf("%d", favorite.ID) } data-sv-confirm="Delete this favorite?">Delete</button>
106+ </div>
107+ </div>
108+ @FavoriteEditForm(favorite, folders)
109+ @FavoriteTagControls(favorite, tags)
110+ </article>
111+ </li>
112+}
113+
114+templ FavoriteEditForm(favorite *entity.Favorite, folders []*entity.Folder) {
115+ <form class="sv-inline-form" data-sv-action="favorite.update">
116+ <input type="hidden" name="id" value={ fmt.Sprintf("%d", favorite.ID) }/>
117+ <label><span>Title</span><input name="title" type="text" value={ favorite.Title }/></label>
118+ <label><span>Favicon</span><input name="favicon_url" type="url" value={ favorite.FaviconURL }/></label>
119+ @FolderSelect("folder_id", folders, favorite.FolderID, "Folder")
120+ @ShortcutSelect(favorite.ShortcutKey)
121+ <button class="sv-button sv-button-secondary" type="submit">Save</button>
122+ </form>
123+}
124+
125+templ FavoriteTagControls(favorite *entity.Favorite, tags []*entity.Tag) {
126+ if len(tags) > 0 {
127+ <div class="sv-tag-controls">
128+ <span class="sv-meta">Tags</span>
129+ for _, tag := range tags {
130+ if tag != nil {
131+ <button type="button" class={ favoriteTagButtonClass(favorite, tag) } data-sv-action={ favoriteTagButtonAction(favorite, tag) } data-favorite-id={ fmt.Sprintf("%d", favorite.ID) } data-tag-id={ fmt.Sprintf("%d", tag.ID) }>
132+ <span class="sv-tag-dot" style={ "background:" + tag.Color }></span>{ favoriteTagButtonLabel(favorite, tag) }
133+ </button>
134+ }
135+ }
136+ </div>
137+ }
138+}
139+
140+templ FolderCreateForm() {
141+ <form class="sv-inline-form" data-sv-action="folder.create">
142+ <label><span>Name</span><input name="name" type="text" required/></label>
143+ <label><span>Icon</span><input name="icon" type="text" placeholder="Optional"/></label>
144+ <button class="sv-button sv-button-secondary" type="submit">Create folder</button>
145+ </form>
146+}
147+
148+templ FolderItem(folder *entity.Folder) {
149+ <li class="sv-list-row">
150+ <form class="sv-inline-form" data-sv-action="folder.update">
151+ <input type="hidden" name="id" value={ fmt.Sprintf("%d", folder.ID) }/>
152+ <label><span>Name</span><input name="name" type="text" value={ folder.Name } required/></label>
153+ <label><span>Icon</span><input name="icon" type="text" value={ folder.Icon }/></label>
154+ <button class="sv-button sv-button-secondary" type="submit">Save</button>
155+ <button type="button" class="sv-button sv-button-secondary sv-button-danger" data-sv-action="folder.delete" data-id={ fmt.Sprintf("%d", folder.ID) } data-sv-confirm="Delete this folder? Favorites will move out of it.">Delete</button>
156+ </form>
157+ </li>
158+}
159+
160+templ TagCreateForm() {
161+ <form class="sv-inline-form" data-sv-action="tag.create">
162+ <label><span>Name</span><input name="name" type="text" required/></label>
163+ <label><span>Color</span><input name="color" type="text" placeholder="#808080"/></label>
164+ <button class="sv-button sv-button-secondary" type="submit">Create tag</button>
165+ </form>
166+}
167+
168+templ TagItem(tag *entity.Tag) {
169+ <li class="sv-list-row">
170+ <form class="sv-inline-form" data-sv-action="tag.update">
171+ <input type="hidden" name="id" value={ fmt.Sprintf("%d", tag.ID) }/>
172+ <span class="sv-tag-dot" style={ "background:" + tag.Color }></span>
173+ <label><span>Name</span><input name="name" type="text" value={ tag.Name } required/></label>
174+ <label><span>Color</span><input name="color" type="text" value={ tag.Color }/></label>
175+ <button class="sv-button sv-button-secondary" type="submit">Save</button>
176+ <button type="button" class="sv-button sv-button-secondary sv-button-danger" data-sv-action="tag.delete" data-id={ fmt.Sprintf("%d", tag.ID) } data-sv-confirm="Delete this tag?">Delete</button>
177+ </form>
178+ </li>
179+}
180+
181+templ FolderSelect(name string, folders []*entity.Folder, selected *entity.FolderID, label string) {
182+ <label><span>{ label }</span><select name={ name }>
183+ <option value="">Unfiled</option>
184+ for _, folder := range folders {
185+ if folder != nil {
186+ <option value={ fmt.Sprintf("%d", folder.ID) } selected?={ folderSelected(selected, folder.ID) }>{ folderDisplayName(folder) }</option>
187+ }
188+ }
189+ </select></label>
190+}
191+
192+templ ShortcutSelect(selected *int) {
193+ <label><span>Shortcut</span><select name="shortcut_key">
194+ <option value="">None</option>
195+ for i := 1; i <= 9; i++ {
196+ <option value={ fmt.Sprintf("%d", i) } selected?={ shortcutSelected(selected, i) }>{ fmt.Sprintf("%d", i) }</option>
197+ }
198+ </select></label>
199+}
A internal/ui/systemviews/favorites_templ.go +1198 -0 generated file: collapsed
M internal/ui/systemviews/favorites_view.go +53 -201
@@ -2,7 +2,6 @@ package systemviews
22
33 import (
44 "fmt"
5- "html"
65 "strings"
76
87 "github.com/bnema/dumber/internal/domain/entity"
@@ -19,112 +18,56 @@ type favoritesRenderData struct {
1918 }
2019
2120 func favoritesHTML(data favoritesRenderData) string {
22- favoritesSummary := fmt.Sprintf("%d favorites · %d folders · %d tags", len(data.Favorites), len(data.Folders), len(data.Tags))
23- foldersSummary := fmt.Sprintf("%d folders", len(data.Folders))
24- tagsSummary := fmt.Sprintf("%d tags", len(data.Tags))
25- sections := []string{
26- favoritesStatusHTML(data),
27- sectionHTML("sv-favorites-controls", "Manage favorites", favoritesControlsHTML(data)),
28- sectionHTML("sv-favorites-list", "Favorites", metaHTML(favoritesSummary)+listHTML(favoriteItemsHTML(data.Favorites, data.Folders, data.Tags), "No favorites")),
29- sectionHTML("sv-folders-list", "Folders", metaHTML(foldersSummary)+folderCreateHTML()+listHTML(folderItemsHTML(data.Folders), "No folders")),
30- sectionHTML("sv-tags-list", "Tags", metaHTML(tagsSummary)+tagCreateHTML()+listHTML(tagItemsHTML(data.Tags), "No tags")),
31- }
32- return strings.Join(sections, "")
21+ return mustRenderComponent(FavoritesView(data))
3322 }
3423
35-func favoritesStatusHTML(data favoritesRenderData) string {
36- var out strings.Builder
37- if strings.TrimSpace(data.Notice) != "" {
38- out.WriteString(fmt.Sprintf(`<div class="sv-alert sv-alert-success" role="status">%s</div>`, html.EscapeString(data.Notice)))
39- }
40- if strings.TrimSpace(data.Error) != "" {
41- out.WriteString(fmt.Sprintf(`<div class="sv-alert sv-alert-error" role="alert">%s</div>`, html.EscapeString(data.Error)))
42- }
43- return out.String()
24+func favoritesSummary(data favoritesRenderData) string {
25+ return fmt.Sprintf("%d favorites · %d folders · %d tags", len(data.Favorites), len(data.Folders), len(data.Tags))
4426 }
4527
46-func favoritesControlsHTML(data favoritesRenderData) string {
47- var out strings.Builder
48- out.WriteString(`<form class="sv-card-form" data-sv-action="favorite.create">`)
49- out.WriteString(`<label><span>URL</span><input name="url" type="url" placeholder="https://example.com" required></label>`)
50- out.WriteString(`<label><span>Title</span><input name="title" type="text" placeholder="Optional title"></label>`)
51- out.WriteString(folderSelectHTML("folder_id", data.Folders, nil, "Folder"))
52- out.WriteString(`<button class="sv-button" type="submit">Add favorite</button>`)
53- out.WriteString(`</form>`)
54-
55- out.WriteString(`<div class="sv-filter-panel">`)
56- out.WriteString(`<div class="sv-button-row"><span class="sv-meta">Folders</span>`)
57- out.WriteString(favoriteFilterButton("All", "favorite.clearFilters", nil, "", data.FolderFilter == nil && data.TagFilter == nil))
58- out.WriteString(favoriteFilterButton("Unfiled", "favorite.filterFolder", map[string]string{"folder_id": "root"}, "", data.FolderFilter != nil && *data.FolderFilter == 0))
59- for _, folder := range data.Folders {
60- if folder == nil {
61- continue
62- }
63- active := data.FolderFilter != nil && *data.FolderFilter == folder.ID
64- out.WriteString(favoriteFilterButton(folderDisplayName(folder), "favorite.filterFolder", map[string]string{"folder_id": fmt.Sprintf("%d", folder.ID)}, "", active))
65- }
66- out.WriteString(`</div>`)
28+func foldersSummary(data favoritesRenderData) string {
29+ return fmt.Sprintf("%d folders", len(data.Folders))
30+}
6731
68- out.WriteString(`<div class="sv-button-row"><span class="sv-meta">Tags</span>`)
69- for _, tag := range data.Tags {
70- if tag == nil {
71- continue
72- }
73- active := data.TagFilter != nil && *data.TagFilter == tag.ID
74- out.WriteString(favoriteFilterButton(tag.Name, "favorite.filterTag", map[string]string{"tag_id": fmt.Sprintf("%d", tag.ID)}, tag.Color, active))
75- }
76- out.WriteString(`</div></div>`)
77- out.WriteString(`<p class="sv-meta sv-key-hints">Keys: <kbd>j</kbd>/<kbd>k</kbd> move, <kbd>Enter</kbd> open, <kbd>e</kbd> edit title, <kbd>d</kbd> delete focused favorite.</p>`)
78- return out.String()
32+func tagsSummary(data favoritesRenderData) string {
33+ return fmt.Sprintf("%d tags", len(data.Tags))
7934 }
8035
81-func favoriteFilterButton(label, action string, data map[string]string, color string, active bool) string {
36+func favoriteFilterClass(active bool) string {
8237 classes := "sv-button sv-button-secondary"
8338 if active {
8439 classes += " sv-button-active"
8540 }
86- attrs := []string{`type="button"`, fmt.Sprintf(`class="%s"`, html.EscapeString(classes)), fmt.Sprintf(`data-sv-action="%s"`, html.EscapeString(action))}
87- for key, value := range data {
88- attrs = append(attrs, fmt.Sprintf(`data-%s="%s"`, html.EscapeString(kebabDataKey(key)), html.EscapeString(value)))
89- }
90- style := ""
91- if strings.TrimSpace(color) != "" {
92- style = fmt.Sprintf(`<span class="sv-tag-dot" style="background:%s"></span>`, html.EscapeString(color))
93- }
94- return fmt.Sprintf(`<button %s>%s%s</button>`, strings.Join(attrs, " "), style, html.EscapeString(label))
41+ return classes
9542 }
9643
97-func favoriteItemsHTML(favorites []*entity.Favorite, folders []*entity.Folder, tags []*entity.Tag) string {
98- var items strings.Builder
99- for _, favorite := range favorites {
100- if favorite == nil {
101- continue
102- }
103- items.WriteString(listRowHTML(favoriteItemHTML(favorite, folders, tags)))
104- }
105- return items.String()
44+func favoriteFolderFilterActive(data favoritesRenderData, folderID entity.FolderID) bool {
45+ return data.FolderFilter != nil && *data.FolderFilter == folderID
46+}
47+
48+func favoriteAllFilterActive(data favoritesRenderData) bool {
49+ return data.FolderFilter == nil && data.TagFilter == nil
50+}
51+
52+func favoriteTagFilterActive(data favoritesRenderData, tagID entity.TagID) bool {
53+ return data.TagFilter != nil && *data.TagFilter == tagID
10654 }
10755
108-func favoriteItemHTML(favorite *entity.Favorite, folders []*entity.Folder, tags []*entity.Tag) string {
56+func favoriteItemLabel(favorite *entity.Favorite) string {
57+ if favorite == nil {
58+ return ""
59+ }
10960 label := strings.TrimSpace(favorite.Title)
11061 if label == "" {
11162 label = favorite.URL
11263 }
113-
114- return fmt.Sprintf(`<article class="sv-favorite-item" data-sv-favorite-row data-favorite-id="%d"><div class="sv-favorite-header"><div><a class="sv-link sv-favorite-open" href="%s">%s</a><p class="sv-history-url">%s</p>%s</div><div class="sv-history-row-actions"><a class="sv-button sv-button-secondary" href="%s">Open</a><button type="button" class="sv-button sv-button-secondary sv-button-danger" data-sv-action="favorite.delete" data-id="%d" data-sv-confirm="Delete this favorite?">Delete</button></div></div>%s%s</article>`,
115- favorite.ID,
116- html.EscapeString(sanitizeHref(favorite.URL)),
117- html.EscapeString(label),
118- html.EscapeString(favorite.URL),
119- favoriteMetaHTML(favorite, folders),
120- html.EscapeString(sanitizeHref(favorite.URL)),
121- favorite.ID,
122- favoriteEditHTML(favorite, folders),
123- favoriteTagControlsHTML(favorite, tags),
124- )
64+ return label
12565 }
12666
127-func favoriteMetaHTML(favorite *entity.Favorite, folders []*entity.Folder) string {
67+func favoriteMetaText(favorite *entity.Favorite, folders []*entity.Folder) string {
68+ if favorite == nil {
69+ return ""
70+ }
12871 parts := []string{}
12972 if favorite.ShortcutKey != nil {
13073 parts = append(parts, fmt.Sprintf("Shortcut %d", *favorite.ShortcutKey))
@@ -132,131 +75,32 @@ func favoriteMetaHTML(favorite *entity.Favorite, folders []*entity.Folder) strin
13275 if name := folderNameForID(folders, favorite.FolderID); name != "" {
13376 parts = append(parts, "Folder "+name)
13477 }
135- if len(parts) == 0 {
136- return ""
137- }
138- return `<p class="sv-meta">` + html.EscapeString(strings.Join(parts, " · ")) + `</p>`
139-}
140-
141-func favoriteEditHTML(favorite *entity.Favorite, folders []*entity.Folder) string {
142- return fmt.Sprintf(`<form class="sv-inline-form" data-sv-action="favorite.update"><input type="hidden" name="id" value="%d"><label><span>Title</span><input name="title" type="text" value="%s"></label><label><span>Favicon</span><input name="favicon_url" type="url" value="%s"></label>%s%s<button class="sv-button sv-button-secondary" type="submit">Save</button></form>`,
143- favorite.ID,
144- html.EscapeString(favorite.Title),
145- html.EscapeString(favorite.FaviconURL),
146- folderSelectHTML("folder_id", folders, favorite.FolderID, "Folder"),
147- shortcutSelectHTML(favorite.ShortcutKey),
148- )
149-}
150-
151-func favoriteTagControlsHTML(favorite *entity.Favorite, tags []*entity.Tag) string {
152- if len(tags) == 0 {
153- return ""
154- }
155- var out strings.Builder
156- out.WriteString(`<div class="sv-tag-controls"><span class="sv-meta">Tags</span>`)
157- for _, tag := range tags {
158- if tag == nil {
159- continue
160- }
161- action := "tag.assign"
162- label := "+ " + tag.Name
163- classes := "sv-button sv-button-secondary"
164- if favorite.HasTag(tag.ID) {
165- action = "tag.remove"
166- label = "✓ " + tag.Name
167- classes += " sv-button-active"
168- }
169- out.WriteString(fmt.Sprintf(`<button type="button" class="%s" data-sv-action="%s" data-favorite-id="%d" data-tag-id="%d"><span class="sv-tag-dot" style="background:%s"></span>%s</button>`,
170- html.EscapeString(classes),
171- html.EscapeString(action),
172- favorite.ID,
173- tag.ID,
174- html.EscapeString(tag.Color),
175- html.EscapeString(label),
176- ))
177- }
178- out.WriteString(`</div>`)
179- return out.String()
180-}
181-
182-func folderCreateHTML() string {
183- return `<form class="sv-inline-form" data-sv-action="folder.create"><label><span>Name</span><input name="name" type="text" required></label><label><span>Icon</span><input name="icon" type="text" placeholder="Optional"></label><button class="sv-button sv-button-secondary" type="submit">Create folder</button></form>`
78+ return strings.Join(parts, " · ")
18479 }
18580
186-func folderItemsHTML(folders []*entity.Folder) string {
187- var items strings.Builder
188- for _, folder := range folders {
189- if folder == nil {
190- continue
191- }
192- items.WriteString(listRowHTML(folderItemHTML(folder)))
81+func favoriteTagButtonAction(favorite *entity.Favorite, tag *entity.Tag) string {
82+ if favorite != nil && tag != nil && favorite.HasTag(tag.ID) {
83+ return tagActionRemove
19384 }
194- return items.String()
195-}
196-
197-func folderItemHTML(folder *entity.Folder) string {
198- return fmt.Sprintf(`<form class="sv-inline-form" data-sv-action="folder.update"><input type="hidden" name="id" value="%d"><label><span>Name</span><input name="name" type="text" value="%s" required></label><label><span>Icon</span><input name="icon" type="text" value="%s"></label><button class="sv-button sv-button-secondary" type="submit">Save</button><button type="button" class="sv-button sv-button-secondary sv-button-danger" data-sv-action="folder.delete" data-id="%d" data-sv-confirm="Delete this folder? Favorites will move out of it.">Delete</button></form>`,
199- folder.ID,
200- html.EscapeString(folder.Name),
201- html.EscapeString(folder.Icon),
202- folder.ID,
203- )
85+ return tagActionAssign
20486 }
20587
206-func tagCreateHTML() string {
207- return `<form class="sv-inline-form" data-sv-action="tag.create"><label><span>Name</span><input name="name" type="text" required></label><label><span>Color</span><input name="color" type="text" placeholder="#808080"></label><button class="sv-button sv-button-secondary" type="submit">Create tag</button></form>`
208-}
209-
210-func tagItemsHTML(tags []*entity.Tag) string {
211- var items strings.Builder
212- for _, tag := range tags {
213- if tag == nil {
214- continue
215- }
216- items.WriteString(listRowHTML(tagItemHTML(tag)))
88+func favoriteTagButtonLabel(favorite *entity.Favorite, tag *entity.Tag) string {
89+ if tag == nil {
90+ return ""
21791 }
218- return items.String()
219-}
220-
221-func tagItemHTML(tag *entity.Tag) string {
222- return fmt.Sprintf(`<form class="sv-inline-form" data-sv-action="tag.update"><input type="hidden" name="id" value="%d"><span class="sv-tag-dot" style="background:%s"></span><label><span>Name</span><input name="name" type="text" value="%s" required></label><label><span>Color</span><input name="color" type="text" value="%s"></label><button class="sv-button sv-button-secondary" type="submit">Save</button><button type="button" class="sv-button sv-button-secondary sv-button-danger" data-sv-action="tag.delete" data-id="%d" data-sv-confirm="Delete this tag?">Delete</button></form>`,
223- tag.ID,
224- html.EscapeString(tag.Color),
225- html.EscapeString(tag.Name),
226- html.EscapeString(tag.Color),
227- tag.ID,
228- )
229-}
230-
231-func folderSelectHTML(name string, folders []*entity.Folder, selected *entity.FolderID, label string) string {
232- var out strings.Builder
233- out.WriteString(fmt.Sprintf(`<label><span>%s</span><select name="%s"><option value="">Unfiled</option>`, html.EscapeString(label), html.EscapeString(name)))
234- for _, folder := range folders {
235- if folder == nil {
236- continue
237- }
238- selectedAttr := ""
239- if selected != nil && *selected == folder.ID {
240- selectedAttr = ` selected`
241- }
242- out.WriteString(fmt.Sprintf(`<option value="%d"%s>%s</option>`, folder.ID, selectedAttr, html.EscapeString(folderDisplayName(folder))))
92+ if favorite != nil && favorite.HasTag(tag.ID) {
93+ return "✓ " + tag.Name
24394 }
244- out.WriteString(`</select></label>`)
245- return out.String()
95+ return "+ " + tag.Name
24696 }
24797
248-func shortcutSelectHTML(selected *int) string {
249- var out strings.Builder
250- out.WriteString(`<label><span>Shortcut</span><select name="shortcut_key"><option value="">None</option>`)
251- for i := 1; i <= 9; i++ {
252- selectedAttr := ""
253- if selected != nil && *selected == i {
254- selectedAttr = ` selected`
255- }
256- out.WriteString(fmt.Sprintf(`<option value="%d"%s>%d</option>`, i, selectedAttr, i))
98+func favoriteTagButtonClass(favorite *entity.Favorite, tag *entity.Tag) string {
99+ classes := "sv-button sv-button-secondary"
100+ if favorite != nil && tag != nil && favorite.HasTag(tag.ID) {
101+ classes += " sv-button-active"
257102 }
258- out.WriteString(`</select></label>`)
259- return out.String()
103+ return classes
260104 }
261105
262106 func folderDisplayName(folder *entity.Folder) string {
@@ -281,6 +125,14 @@ func folderNameForID(folders []*entity.Folder, id *entity.FolderID) string {
281125 return "Unknown"
282126 }
283127
128+func folderSelected(selected *entity.FolderID, folderID entity.FolderID) bool {
129+ return selected != nil && *selected == folderID
130+}
131+
132+func shortcutSelected(selected *int, value int) bool {
133+ return selected != nil && *selected == value
134+}
135+
284136 func filterFavorites(favorites []*entity.Favorite, folderID *entity.FolderID, tagID *entity.TagID) []*entity.Favorite {
285137 if folderID == nil && tagID == nil {
286138 return favorites
@@ -2,7 +2,6 @@ package systemviews
2 2
3 import (3 import (
4 "fmt"4 "fmt"
5- "html"
6 "strings"5 "strings"
7 6
8 "github.com/bnema/dumber/internal/domain/entity"7 "github.com/bnema/dumber/internal/domain/entity"
@@ -19,112 +18,56 @@ type favoritesRenderData struct {
19 }18 }
20 19
21 func favoritesHTML(data favoritesRenderData) string {20 func favoritesHTML(data favoritesRenderData) string {
22- favoritesSummary := fmt.Sprintf("%d favorites · %d folders · %d tags", len(data.Favorites), len(data.Folders), len(data.Tags))21+ return mustRenderComponent(FavoritesView(data))
23- foldersSummary := fmt.Sprintf("%d folders", len(data.Folders))
24- tagsSummary := fmt.Sprintf("%d tags", len(data.Tags))
25- sections := []string{
26- favoritesStatusHTML(data),
27- sectionHTML("sv-favorites-controls", "Manage favorites", favoritesControlsHTML(data)),
28- sectionHTML("sv-favorites-list", "Favorites", metaHTML(favoritesSummary)+listHTML(favoriteItemsHTML(data.Favorites, data.Folders, data.Tags), "No favorites")),
29- sectionHTML("sv-folders-list", "Folders", metaHTML(foldersSummary)+folderCreateHTML()+listHTML(folderItemsHTML(data.Folders), "No folders")),
30- sectionHTML("sv-tags-list", "Tags", metaHTML(tagsSummary)+tagCreateHTML()+listHTML(tagItemsHTML(data.Tags), "No tags")),
31- }
32- return strings.Join(sections, "")
33 }22 }
34 23
35-func favoritesStatusHTML(data favoritesRenderData) string {24+func favoritesSummary(data favoritesRenderData) string {
36- var out strings.Builder25+ return fmt.Sprintf("%d favorites · %d folders · %d tags", len(data.Favorites), len(data.Folders), len(data.Tags))
37- if strings.TrimSpace(data.Notice) != "" {
38- out.WriteString(fmt.Sprintf(`<div class="sv-alert sv-alert-success" role="status">%s</div>`, html.EscapeString(data.Notice)))
39- }
40- if strings.TrimSpace(data.Error) != "" {
41- out.WriteString(fmt.Sprintf(`<div class="sv-alert sv-alert-error" role="alert">%s</div>`, html.EscapeString(data.Error)))
42- }
43- return out.String()
44 }26 }
45 27
46-func favoritesControlsHTML(data favoritesRenderData) string {28+func foldersSummary(data favoritesRenderData) string {
47- var out strings.Builder29+ return fmt.Sprintf("%d folders", len(data.Folders))
48- out.WriteString(`<form class="sv-card-form" data-sv-action="favorite.create">`)30+}
49- out.WriteString(`<label><span>URL</span><input name="url" type="url" placeholder="https://example.com" required></label>`)
50- out.WriteString(`<label><span>Title</span><input name="title" type="text" placeholder="Optional title"></label>`)
51- out.WriteString(folderSelectHTML("folder_id", data.Folders, nil, "Folder"))
52- out.WriteString(`<button class="sv-button" type="submit">Add favorite</button>`)
53- out.WriteString(`</form>`)
54-
55- out.WriteString(`<div class="sv-filter-panel">`)
56- out.WriteString(`<div class="sv-button-row"><span class="sv-meta">Folders</span>`)
57- out.WriteString(favoriteFilterButton("All", "favorite.clearFilters", nil, "", data.FolderFilter == nil && data.TagFilter == nil))
58- out.WriteString(favoriteFilterButton("Unfiled", "favorite.filterFolder", map[string]string{"folder_id": "root"}, "", data.FolderFilter != nil && *data.FolderFilter == 0))
59- for _, folder := range data.Folders {
60- if folder == nil {
61- continue
62- }
63- active := data.FolderFilter != nil && *data.FolderFilter == folder.ID
64- out.WriteString(favoriteFilterButton(folderDisplayName(folder), "favorite.filterFolder", map[string]string{"folder_id": fmt.Sprintf("%d", folder.ID)}, "", active))
65- }
66- out.WriteString(`</div>`)
67 31
68- out.WriteString(`<div class="sv-button-row"><span class="sv-meta">Tags</span>`)32+func tagsSummary(data favoritesRenderData) string {
69- for _, tag := range data.Tags {33+ return fmt.Sprintf("%d tags", len(data.Tags))
70- if tag == nil {
71- continue
72- }
73- active := data.TagFilter != nil && *data.TagFilter == tag.ID
74- out.WriteString(favoriteFilterButton(tag.Name, "favorite.filterTag", map[string]string{"tag_id": fmt.Sprintf("%d", tag.ID)}, tag.Color, active))
75- }
76- out.WriteString(`</div></div>`)
77- out.WriteString(`<p class="sv-meta sv-key-hints">Keys: <kbd>j</kbd>/<kbd>k</kbd> move, <kbd>Enter</kbd> open, <kbd>e</kbd> edit title, <kbd>d</kbd> delete focused favorite.</p>`)
78- return out.String()
79 }34 }
80 35
81-func favoriteFilterButton(label, action string, data map[string]string, color string, active bool) string {36+func favoriteFilterClass(active bool) string {
82 classes := "sv-button sv-button-secondary"37 classes := "sv-button sv-button-secondary"
83 if active {38 if active {
84 classes += " sv-button-active"39 classes += " sv-button-active"
85 }40 }
86- attrs := []string{`type="button"`, fmt.Sprintf(`class="%s"`, html.EscapeString(classes)), fmt.Sprintf(`data-sv-action="%s"`, html.EscapeString(action))}41+ return classes
87- for key, value := range data {
88- attrs = append(attrs, fmt.Sprintf(`data-%s="%s"`, html.EscapeString(kebabDataKey(key)), html.EscapeString(value)))
89- }
90- style := ""
91- if strings.TrimSpace(color) != "" {
92- style = fmt.Sprintf(`<span class="sv-tag-dot" style="background:%s"></span>`, html.EscapeString(color))
93- }
94- return fmt.Sprintf(`<button %s>%s%s</button>`, strings.Join(attrs, " "), style, html.EscapeString(label))
95 }42 }
96 43
97-func favoriteItemsHTML(favorites []*entity.Favorite, folders []*entity.Folder, tags []*entity.Tag) string {44+func favoriteFolderFilterActive(data favoritesRenderData, folderID entity.FolderID) bool {
98- var items strings.Builder45+ return data.FolderFilter != nil && *data.FolderFilter == folderID
99- for _, favorite := range favorites {46+}
100- if favorite == nil {47+
101- continue48+func favoriteAllFilterActive(data favoritesRenderData) bool {
102- }49+ return data.FolderFilter == nil && data.TagFilter == nil
103- items.WriteString(listRowHTML(favoriteItemHTML(favorite, folders, tags)))50+}
104- }51+
105- return items.String()52+func favoriteTagFilterActive(data favoritesRenderData, tagID entity.TagID) bool {
53+ return data.TagFilter != nil && *data.TagFilter == tagID
106 }54 }
107 55
108-func favoriteItemHTML(favorite *entity.Favorite, folders []*entity.Folder, tags []*entity.Tag) string {56+func favoriteItemLabel(favorite *entity.Favorite) string {
57+ if favorite == nil {
58+ return ""
59+ }
109 label := strings.TrimSpace(favorite.Title)60 label := strings.TrimSpace(favorite.Title)
110 if label == "" {61 if label == "" {
111 label = favorite.URL62 label = favorite.URL
112 }63 }
113-64+ return label
114- return fmt.Sprintf(`<article class="sv-favorite-item" data-sv-favorite-row data-favorite-id="%d"><div class="sv-favorite-header"><div><a class="sv-link sv-favorite-open" href="%s">%s</a><p class="sv-history-url">%s</p>%s</div><div class="sv-history-row-actions"><a class="sv-button sv-button-secondary" href="%s">Open</a><button type="button" class="sv-button sv-button-secondary sv-button-danger" data-sv-action="favorite.delete" data-id="%d" data-sv-confirm="Delete this favorite?">Delete</button></div></div>%s%s</article>`,
115- favorite.ID,
116- html.EscapeString(sanitizeHref(favorite.URL)),
117- html.EscapeString(label),
118- html.EscapeString(favorite.URL),
119- favoriteMetaHTML(favorite, folders),
120- html.EscapeString(sanitizeHref(favorite.URL)),
121- favorite.ID,
122- favoriteEditHTML(favorite, folders),
123- favoriteTagControlsHTML(favorite, tags),
124- )
125 }65 }
126 66
127-func favoriteMetaHTML(favorite *entity.Favorite, folders []*entity.Folder) string {67+func favoriteMetaText(favorite *entity.Favorite, folders []*entity.Folder) string {
68+ if favorite == nil {
69+ return ""
70+ }
128 parts := []string{}71 parts := []string{}
129 if favorite.ShortcutKey != nil {72 if favorite.ShortcutKey != nil {
130 parts = append(parts, fmt.Sprintf("Shortcut %d", *favorite.ShortcutKey))73 parts = append(parts, fmt.Sprintf("Shortcut %d", *favorite.ShortcutKey))
@@ -132,131 +75,32 @@ func favoriteMetaHTML(favorite *entity.Favorite, folders []*entity.Folder) strin
132 if name := folderNameForID(folders, favorite.FolderID); name != "" {75 if name := folderNameForID(folders, favorite.FolderID); name != "" {
133 parts = append(parts, "Folder "+name)76 parts = append(parts, "Folder "+name)
134 }77 }
135- if len(parts) == 0 {78+ return strings.Join(parts, " · ")
136- return ""
137- }
138- return `<p class="sv-meta">` + html.EscapeString(strings.Join(parts, " · ")) + `</p>`
139-}
140-
141-func favoriteEditHTML(favorite *entity.Favorite, folders []*entity.Folder) string {
142- return fmt.Sprintf(`<form class="sv-inline-form" data-sv-action="favorite.update"><input type="hidden" name="id" value="%d"><label><span>Title</span><input name="title" type="text" value="%s"></label><label><span>Favicon</span><input name="favicon_url" type="url" value="%s"></label>%s%s<button class="sv-button sv-button-secondary" type="submit">Save</button></form>`,
143- favorite.ID,
144- html.EscapeString(favorite.Title),
145- html.EscapeString(favorite.FaviconURL),
146- folderSelectHTML("folder_id", folders, favorite.FolderID, "Folder"),
147- shortcutSelectHTML(favorite.ShortcutKey),
148- )
149-}
150-
151-func favoriteTagControlsHTML(favorite *entity.Favorite, tags []*entity.Tag) string {
152- if len(tags) == 0 {
153- return ""
154- }
155- var out strings.Builder
156- out.WriteString(`<div class="sv-tag-controls"><span class="sv-meta">Tags</span>`)
157- for _, tag := range tags {
158- if tag == nil {
159- continue
160- }
161- action := "tag.assign"
162- label := "+ " + tag.Name
163- classes := "sv-button sv-button-secondary"
164- if favorite.HasTag(tag.ID) {
165- action = "tag.remove"
166- label = "✓ " + tag.Name
167- classes += " sv-button-active"
168- }
169- out.WriteString(fmt.Sprintf(`<button type="button" class="%s" data-sv-action="%s" data-favorite-id="%d" data-tag-id="%d"><span class="sv-tag-dot" style="background:%s"></span>%s</button>`,
170- html.EscapeString(classes),
171- html.EscapeString(action),
172- favorite.ID,
173- tag.ID,
174- html.EscapeString(tag.Color),
175- html.EscapeString(label),
176- ))
177- }
178- out.WriteString(`</div>`)
179- return out.String()
180-}
181-
182-func folderCreateHTML() string {
183- return `<form class="sv-inline-form" data-sv-action="folder.create"><label><span>Name</span><input name="name" type="text" required></label><label><span>Icon</span><input name="icon" type="text" placeholder="Optional"></label><button class="sv-button sv-button-secondary" type="submit">Create folder</button></form>`
184 }79 }
185 80
186-func folderItemsHTML(folders []*entity.Folder) string {81+func favoriteTagButtonAction(favorite *entity.Favorite, tag *entity.Tag) string {
187- var items strings.Builder82+ if favorite != nil && tag != nil && favorite.HasTag(tag.ID) {
188- for _, folder := range folders {83+ return tagActionRemove
189- if folder == nil {
190- continue
191- }
192- items.WriteString(listRowHTML(folderItemHTML(folder)))
193 }84 }
194- return items.String()85+ return tagActionAssign
195-}
196-
197-func folderItemHTML(folder *entity.Folder) string {
198- return fmt.Sprintf(`<form class="sv-inline-form" data-sv-action="folder.update"><input type="hidden" name="id" value="%d"><label><span>Name</span><input name="name" type="text" value="%s" required></label><label><span>Icon</span><input name="icon" type="text" value="%s"></label><button class="sv-button sv-button-secondary" type="submit">Save</button><button type="button" class="sv-button sv-button-secondary sv-button-danger" data-sv-action="folder.delete" data-id="%d" data-sv-confirm="Delete this folder? Favorites will move out of it.">Delete</button></form>`,
199- folder.ID,
200- html.EscapeString(folder.Name),
201- html.EscapeString(folder.Icon),
202- folder.ID,
203- )
204 }86 }
205 87
206-func tagCreateHTML() string {88+func favoriteTagButtonLabel(favorite *entity.Favorite, tag *entity.Tag) string {
207- return `<form class="sv-inline-form" data-sv-action="tag.create"><label><span>Name</span><input name="name" type="text" required></label><label><span>Color</span><input name="color" type="text" placeholder="#808080"></label><button class="sv-button sv-button-secondary" type="submit">Create tag</button></form>`89+ if tag == nil {
208-}90+ return ""
209-
210-func tagItemsHTML(tags []*entity.Tag) string {
211- var items strings.Builder
212- for _, tag := range tags {
213- if tag == nil {
214- continue
215- }
216- items.WriteString(listRowHTML(tagItemHTML(tag)))
217 }91 }
218- return items.String()92+ if favorite != nil && favorite.HasTag(tag.ID) {
219-}93+ return "✓ " + tag.Name
220-
221-func tagItemHTML(tag *entity.Tag) string {
222- return fmt.Sprintf(`<form class="sv-inline-form" data-sv-action="tag.update"><input type="hidden" name="id" value="%d"><span class="sv-tag-dot" style="background:%s"></span><label><span>Name</span><input name="name" type="text" value="%s" required></label><label><span>Color</span><input name="color" type="text" value="%s"></label><button class="sv-button sv-button-secondary" type="submit">Save</button><button type="button" class="sv-button sv-button-secondary sv-button-danger" data-sv-action="tag.delete" data-id="%d" data-sv-confirm="Delete this tag?">Delete</button></form>`,
223- tag.ID,
224- html.EscapeString(tag.Color),
225- html.EscapeString(tag.Name),
226- html.EscapeString(tag.Color),
227- tag.ID,
228- )
229-}
230-
231-func folderSelectHTML(name string, folders []*entity.Folder, selected *entity.FolderID, label string) string {
232- var out strings.Builder
233- out.WriteString(fmt.Sprintf(`<label><span>%s</span><select name="%s"><option value="">Unfiled</option>`, html.EscapeString(label), html.EscapeString(name)))
234- for _, folder := range folders {
235- if folder == nil {
236- continue
237- }
238- selectedAttr := ""
239- if selected != nil && *selected == folder.ID {
240- selectedAttr = ` selected`
241- }
242- out.WriteString(fmt.Sprintf(`<option value="%d"%s>%s</option>`, folder.ID, selectedAttr, html.EscapeString(folderDisplayName(folder))))
243 }94 }
244- out.WriteString(`</select></label>`)95+ return "+ " + tag.Name
245- return out.String()
246 }96 }
247 97
248-func shortcutSelectHTML(selected *int) string {98+func favoriteTagButtonClass(favorite *entity.Favorite, tag *entity.Tag) string {
249- var out strings.Builder99+ classes := "sv-button sv-button-secondary"
250- out.WriteString(`<label><span>Shortcut</span><select name="shortcut_key"><option value="">None</option>`)100+ if favorite != nil && tag != nil && favorite.HasTag(tag.ID) {
251- for i := 1; i <= 9; i++ {101+ classes += " sv-button-active"
252- selectedAttr := ""
253- if selected != nil && *selected == i {
254- selectedAttr = ` selected`
255- }
256- out.WriteString(fmt.Sprintf(`<option value="%d"%s>%d</option>`, i, selectedAttr, i))
257 }102 }
258- out.WriteString(`</select></label>`)103+ return classes
259- return out.String()
260 }104 }
261 105
262 func folderDisplayName(folder *entity.Folder) string {106 func folderDisplayName(folder *entity.Folder) string {
@@ -281,6 +125,14 @@ func folderNameForID(folders []*entity.Folder, id *entity.FolderID) string {
281 return "Unknown"125 return "Unknown"
282 }126 }
283 127
128+func folderSelected(selected *entity.FolderID, folderID entity.FolderID) bool {
129+ return selected != nil && *selected == folderID
130+}
131+
132+func shortcutSelected(selected *int, value int) bool {
133+ return selected != nil && *selected == value
134+}
135+
284 func filterFavorites(favorites []*entity.Favorite, folderID *entity.FolderID, tagID *entity.TagID) []*entity.Favorite {136 func filterFavorites(favorites []*entity.Favorite, folderID *entity.FolderID, tagID *entity.TagID) []*entity.Favorite {
285 if folderID == nil && tagID == nil {137 if folderID == nil && tagID == nil {
286 return favorites138 return favorites
A internal/ui/systemviews/history.templ +126 -0
@@ -0,0 +1,126 @@
1+package systemviews
2+
3+import (
4+ "fmt"
5+ "github.com/bnema/dumber/internal/domain/entity"
6+)
7+
8+templ HistoryView(data historyRenderData) {
9+ @Alert("success", data.Notice)
10+ @Alert("error", data.Error)
11+ @Section("sv-history-controls", "Manage history") {
12+ @HistoryControls(data)
13+ }
14+ @Section("sv-history-summary", "History") {
15+ @HistorySummary(data)
16+ }
17+ @Section("sv-history-domains", "Top domains") {
18+ @HistoryDomains(data)
19+ }
20+ @Section("sv-history-timeline", "Recent visits") {
21+ @HistoryTimeline(data)
22+ }
23+}
24+
25+templ HistoryControls(data historyRenderData) {
26+ <form class="sv-toolbar sv-history-search" data-sv-action="history.search">
27+ <label class="sv-search-label">
28+ <span>Search</span>
29+ <input data-sv-history-search name="query" type="search" value={ data.Query } placeholder="Search title or URL" autocomplete="off"/>
30+ </label>
31+ <button class="sv-button" type="submit">Search</button>
32+ <button class="sv-button sv-button-secondary" type="button" data-sv-action="history.clear">Clear</button>
33+ </form>
34+ if showHistoryFilters(data) {
35+ <div class="sv-active-filters">
36+ if data.Query != "" {
37+ <span class="sv-filter-chip">Query: { data.Query }</span>
38+ }
39+ if data.DomainFilter != "" {
40+ <span class="sv-filter-chip">Domain: { data.DomainFilter } <button type="button" data-sv-action="history.clearDomain" aria-label="Clear domain filter">×</button></span>
41+ }
42+ </div>
43+ }
44+ <div class="sv-history-actions-panel">
45+ <div class="sv-button-row" aria-label="History pagination">
46+ <button type="button" class="sv-button sv-button-secondary" data-sv-action="history.page" data-offset={ fmt.Sprintf("%d", previousHistoryOffset(data)) } disabled?={ disableHistoryPrev(data) }>Previous</button>
47+ <span class="sv-meta">Showing { fmt.Sprintf("%d", countHistoryEntries(data.Entries)) } item{ pluralSuffix(countHistoryEntries(data.Entries)) }{ historyOffsetLabel(data.Offset, data.Query) }</span>
48+ <button type="button" class="sv-button sv-button-secondary" data-sv-action="history.page" data-offset={ fmt.Sprintf("%d", nextHistoryOffset(data)) } disabled?={ disableHistoryNext(data) }>Next</button>
49+ </div>
50+ <div class="sv-button-row" aria-label="History cleanup">
51+ <span class="sv-meta">Clean up</span>
52+ for _, item := range historyCleanupItems() {
53+ <button type="button" class="sv-button sv-button-secondary sv-button-danger" data-sv-action="history.deleteRange" data-range={ item.RangeID } data-sv-confirm={ item.Confirm }>{ item.Label }</button>
54+ }
55+ </div>
56+ </div>
57+ <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>
58+}
59+
60+templ HistorySummary(data historyRenderData) {
61+ {{ entries, visits, days := historySummaryValues(data) }}
62+ <div class="sv-stat-grid">
63+ @HistoryStatCard("Entries", entries)
64+ @HistoryStatCard("Visits", visits)
65+ @HistoryStatCard("Days", days)
66+ </div>
67+}
68+
69+templ HistoryStatCard(label string, value int64) {
70+ <div class="sv-stat-card"><span class="sv-stat-value">{ fmt.Sprintf("%d", value) }</span><span class="sv-stat-label">{ label }</span></div>
71+}
72+
73+templ HistoryDomains(data historyRenderData) {
74+ if len(data.Domains) == 0 {
75+ @EmptyState("No domain statistics yet")
76+ } else {
77+ <div class="sv-domain-list">
78+ for _, domain := range data.Domains {
79+ if domain != nil && domain.Domain != "" {
80+ {{ displayDomain := displayHistoryDomain(domain.Domain) }}
81+ <span class="sv-domain-chip">
82+ <button type="button" class="sv-domain-filter" data-sv-action="history.filterDomain" data-domain={ displayDomain }>{ displayDomain }</button>
83+ <span class="sv-domain-meta">{ fmt.Sprintf("%d pages · %d visits", domain.PageCount, domain.TotalVisits) }</span>
84+ <button type="button" class="sv-icon-button sv-danger" data-sv-action="history.deleteDomain" data-domain={ displayDomain } data-sv-confirm={ "Delete all history for " + displayDomain + "?" } aria-label={ "Delete history for " + displayDomain }>×</button>
85+ </span>
86+ }
87+ }
88+ </div>
89+ }
90+}
91+
92+templ HistoryTimeline(data historyRenderData) {
93+ {{ groups := groupHistoryEntries(data.Entries) }}
94+ if len(groups) == 0 {
95+ @EmptyState(historyTimelineEmptyMessage(data))
96+ } else {
97+ for _, group := range groups {
98+ <section class="sv-timeline-group">
99+ <h3>{ group.Label }</h3>
100+ <ul class="sv-list">
101+ for _, entry := range group.Entries {
102+ if entry != nil {
103+ @HistoryItem(entry)
104+ }
105+ }
106+ </ul>
107+ </section>
108+ }
109+ }
110+}
111+
112+templ HistoryItem(entry *entity.HistoryEntry) {
113+ <li class="sv-list-row">
114+ <article class="sv-history-item" data-sv-history-row data-history-id={ fmt.Sprintf("%d", entry.ID) }>
115+ <div class="sv-history-main">
116+ <a class="sv-link sv-history-open" href={ templ.SafeURL(sanitizeHref(entry.URL)) }>{ historyItemLabel(entry) }</a>
117+ <p class="sv-history-url">{ entry.URL }</p>
118+ <p class="sv-history-meta">{ historyItemMeta(entry) }</p>
119+ </div>
120+ <div class="sv-history-row-actions">
121+ <a class="sv-button sv-button-secondary" href={ templ.SafeURL(sanitizeHref(entry.URL)) }>Open</a>
122+ <button type="button" class="sv-button sv-button-secondary sv-button-danger" data-sv-action="history.deleteEntry" data-id={ fmt.Sprintf("%d", entry.ID) } data-sv-confirm="Delete this history entry?" disabled?={ entry.ID <= 0 }>Delete</button>
123+ </div>
124+ </article>
125+ </li>
126+}
@@ -0,0 +1,126 @@
1+package systemviews
2+
3+import (
4+ "fmt"
5+ "github.com/bnema/dumber/internal/domain/entity"
6+)
7+
8+templ HistoryView(data historyRenderData) {
9+ @Alert("success", data.Notice)
10+ @Alert("error", data.Error)
11+ @Section("sv-history-controls", "Manage history") {
12+ @HistoryControls(data)
13+ }
14+ @Section("sv-history-summary", "History") {
15+ @HistorySummary(data)
16+ }
17+ @Section("sv-history-domains", "Top domains") {
18+ @HistoryDomains(data)
19+ }
20+ @Section("sv-history-timeline", "Recent visits") {
21+ @HistoryTimeline(data)
22+ }
23+}
24+
25+templ HistoryControls(data historyRenderData) {
26+ <form class="sv-toolbar sv-history-search" data-sv-action="history.search">
27+ <label class="sv-search-label">
28+ <span>Search</span>
29+ <input data-sv-history-search name="query" type="search" value={ data.Query } placeholder="Search title or URL" autocomplete="off"/>
30+ </label>
31+ <button class="sv-button" type="submit">Search</button>
32+ <button class="sv-button sv-button-secondary" type="button" data-sv-action="history.clear">Clear</button>
33+ </form>
34+ if showHistoryFilters(data) {
35+ <div class="sv-active-filters">
36+ if data.Query != "" {
37+ <span class="sv-filter-chip">Query: { data.Query }</span>
38+ }
39+ if data.DomainFilter != "" {
40+ <span class="sv-filter-chip">Domain: { data.DomainFilter } <button type="button" data-sv-action="history.clearDomain" aria-label="Clear domain filter">×</button></span>
41+ }
42+ </div>
43+ }
44+ <div class="sv-history-actions-panel">
45+ <div class="sv-button-row" aria-label="History pagination">
46+ <button type="button" class="sv-button sv-button-secondary" data-sv-action="history.page" data-offset={ fmt.Sprintf("%d", previousHistoryOffset(data)) } disabled?={ disableHistoryPrev(data) }>Previous</button>
47+ <span class="sv-meta">Showing { fmt.Sprintf("%d", countHistoryEntries(data.Entries)) } item{ pluralSuffix(countHistoryEntries(data.Entries)) }{ historyOffsetLabel(data.Offset, data.Query) }</span>
48+ <button type="button" class="sv-button sv-button-secondary" data-sv-action="history.page" data-offset={ fmt.Sprintf("%d", nextHistoryOffset(data)) } disabled?={ disableHistoryNext(data) }>Next</button>
49+ </div>
50+ <div class="sv-button-row" aria-label="History cleanup">
51+ <span class="sv-meta">Clean up</span>
52+ for _, item := range historyCleanupItems() {
53+ <button type="button" class="sv-button sv-button-secondary sv-button-danger" data-sv-action="history.deleteRange" data-range={ item.RangeID } data-sv-confirm={ item.Confirm }>{ item.Label }</button>
54+ }
55+ </div>
56+ </div>
57+ <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>
58+}
59+
60+templ HistorySummary(data historyRenderData) {
61+ {{ entries, visits, days := historySummaryValues(data) }}
62+ <div class="sv-stat-grid">
63+ @HistoryStatCard("Entries", entries)
64+ @HistoryStatCard("Visits", visits)
65+ @HistoryStatCard("Days", days)
66+ </div>
67+}
68+
69+templ HistoryStatCard(label string, value int64) {
70+ <div class="sv-stat-card"><span class="sv-stat-value">{ fmt.Sprintf("%d", value) }</span><span class="sv-stat-label">{ label }</span></div>
71+}
72+
73+templ HistoryDomains(data historyRenderData) {
74+ if len(data.Domains) == 0 {
75+ @EmptyState("No domain statistics yet")
76+ } else {
77+ <div class="sv-domain-list">
78+ for _, domain := range data.Domains {
79+ if domain != nil && domain.Domain != "" {
80+ {{ displayDomain := displayHistoryDomain(domain.Domain) }}
81+ <span class="sv-domain-chip">
82+ <button type="button" class="sv-domain-filter" data-sv-action="history.filterDomain" data-domain={ displayDomain }>{ displayDomain }</button>
83+ <span class="sv-domain-meta">{ fmt.Sprintf("%d pages · %d visits", domain.PageCount, domain.TotalVisits) }</span>
84+ <button type="button" class="sv-icon-button sv-danger" data-sv-action="history.deleteDomain" data-domain={ displayDomain } data-sv-confirm={ "Delete all history for " + displayDomain + "?" } aria-label={ "Delete history for " + displayDomain }>×</button>
85+ </span>
86+ }
87+ }
88+ </div>
89+ }
90+}
91+
92+templ HistoryTimeline(data historyRenderData) {
93+ {{ groups := groupHistoryEntries(data.Entries) }}
94+ if len(groups) == 0 {
95+ @EmptyState(historyTimelineEmptyMessage(data))
96+ } else {
97+ for _, group := range groups {
98+ <section class="sv-timeline-group">
99+ <h3>{ group.Label }</h3>
100+ <ul class="sv-list">
101+ for _, entry := range group.Entries {
102+ if entry != nil {
103+ @HistoryItem(entry)
104+ }
105+ }
106+ </ul>
107+ </section>
108+ }
109+ }
110+}
111+
112+templ HistoryItem(entry *entity.HistoryEntry) {
113+ <li class="sv-list-row">
114+ <article class="sv-history-item" data-sv-history-row data-history-id={ fmt.Sprintf("%d", entry.ID) }>
115+ <div class="sv-history-main">
116+ <a class="sv-link sv-history-open" href={ templ.SafeURL(sanitizeHref(entry.URL)) }>{ historyItemLabel(entry) }</a>
117+ <p class="sv-history-url">{ entry.URL }</p>
118+ <p class="sv-history-meta">{ historyItemMeta(entry) }</p>
119+ </div>
120+ <div class="sv-history-row-actions">
121+ <a class="sv-button sv-button-secondary" href={ templ.SafeURL(sanitizeHref(entry.URL)) }>Open</a>
122+ <button type="button" class="sv-button sv-button-secondary sv-button-danger" data-sv-action="history.deleteEntry" data-id={ fmt.Sprintf("%d", entry.ID) } data-sv-confirm="Delete this history entry?" disabled?={ entry.ID <= 0 }>Delete</button>
123+ </div>
124+ </article>
125+ </li>
126+}
A internal/ui/systemviews/history_templ.go +782 -0 generated file: collapsed
M internal/ui/systemviews/history_view.go +79 -206
@@ -2,7 +2,6 @@ package systemviews
22
33 import (
44 "fmt"
5- "html"
65 "net/url"
76 "strings"
87 "time"
@@ -22,92 +21,61 @@ type historyRenderData struct {
2221 Error string
2322 }
2423
24+type historyTimelineGroup struct {
25+ Date string
26+ Label string
27+ Entries []*entity.HistoryEntry
28+}
29+
30+type historyCleanupItem struct {
31+ Label string
32+ RangeID string
33+ Confirm string
34+}
35+
2536 func historyHTML(data historyRenderData) string {
26- sections := []string{
27- historyStatusHTML(data),
28- sectionHTML("sv-history-controls", "Manage history", historyControlsHTML(data)),
29- sectionHTML("sv-history-summary", "History", historySummaryHTML(data)),
30- sectionHTML("sv-history-domains", "Top domains", historyDomainsHTML(data.Domains)),
31- sectionHTML("sv-history-timeline", "Recent visits", historyTimelineHTML(data)),
32- }
33- return strings.Join(sections, "")
37+ return mustRenderComponent(HistoryView(data))
3438 }
3539
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+func historyCleanupItems() []historyCleanupItem {
41+ return []historyCleanupItem{
42+ {Label: "Last hour", RangeID: "hour", Confirm: "Delete history for last hour?"},
43+ {Label: "Today", RangeID: "day", Confirm: "Delete history for today?"},
44+ {Label: "Week", RangeID: "week", Confirm: "Delete history for week?"},
45+ {Label: "Month", RangeID: "month", Confirm: "Delete history for month?"},
46+ {Label: "All", RangeID: "all", Confirm: "Delete all browsing history?"},
4047 }
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()
4548 }
4649
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
50+func historyLimit(data historyRenderData) int {
51+ if data.Limit <= 0 {
52+ return historyTimelineLimit
5353 }
54- offset := data.Offset
54+ return data.Limit
55+}
56+
57+func previousHistoryOffset(data historyRenderData) int {
58+ offset := data.Offset - historyLimit(data)
5559 if offset < 0 {
56- offset = 0
60+ return 0
5761 }
62+ return offset
63+}
5864
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- }
65+func nextHistoryOffset(data historyRenderData) int {
66+ return max(data.Offset, 0) + historyLimit(data)
67+}
7668
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>`)
69+func disableHistoryPrev(data historyRenderData) bool {
70+ return data.Offset <= 0 || strings.TrimSpace(data.Query) != ""
71+}
8772
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>`)
73+func disableHistoryNext(data historyRenderData) bool {
74+ return strings.TrimSpace(data.Query) != "" || countHistoryEntries(data.Entries) < historyLimit(data)
75+}
10876
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()
77+func showHistoryFilters(data historyRenderData) bool {
78+ return strings.TrimSpace(data.Query) != "" || strings.TrimSpace(data.DomainFilter) != ""
11179 }
11280
11381 func historyOffsetLabel(offset int, query string) string {
@@ -120,29 +88,6 @@ func historyOffsetLabel(offset int, query string) string {
12088 return fmt.Sprintf(" from offset %d", offset)
12189 }
12290
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-
14691 func pluralSuffix(count int) string {
14792 if count == 1 {
14893 return ""
@@ -150,124 +95,24 @@ func pluralSuffix(count int) string {
15095 return "s"
15196 }
15297
153-func historySummaryHTML(data historyRenderData) string {
154- entries := int64(countHistoryEntries(data.Entries))
155- visits := sumHistoryVisits(data.Entries)
156- uniqueDays := countUniqueHistoryDays(data.Entries)
98+func historySummaryValues(data historyRenderData) (entries, visits, uniqueDays int64) {
99+ entries = int64(countHistoryEntries(data.Entries))
100+ visits = sumHistoryVisits(data.Entries)
101+ uniqueDays = countUniqueHistoryDays(data.Entries)
157102
158103 if data.Analytics != nil {
159104 entries = data.Analytics.TotalEntries
160105 visits = data.Analytics.TotalVisits
161106 uniqueDays = data.Analytics.UniqueDays
162107 }
163-
164- return fmt.Sprintf(`<div class="sv-stat-grid">%s%s%s</div>`,
165- historyStatCardHTML("Entries", entries),
166- historyStatCardHTML("Visits", visits),
167- historyStatCardHTML("Days", uniqueDays),
168- )
169-}
170-
171-func historyStatCardHTML(label string, value int64) string {
172- return fmt.Sprintf(`<div class="sv-stat-card"><span class="sv-stat-value">%d</span><span class="sv-stat-label">%s</span></div>`, value, html.EscapeString(label))
108+ return entries, visits, uniqueDays
173109 }
174110
175-func historyDomainsHTML(domains []*entity.DomainStat) string {
176- var chips strings.Builder
177- for _, domain := range domains {
178- if domain == nil || strings.TrimSpace(domain.Domain) == "" {
179- continue
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))
189- chips.WriteString(fmt.Sprintf(
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),
194- ))
195- chips.WriteString(`</span>`)
196- }
197- if chips.Len() == 0 {
198- return emptyStateHTML("No domain statistics yet")
111+func historyTimelineEmptyMessage(data historyRenderData) string {
112+ if strings.TrimSpace(data.Query) != "" || strings.TrimSpace(data.DomainFilter) != "" {
113+ return "No history entries match the current filters"
199114 }
200- return `<div class="sv-domain-list">` + chips.String() + `</div>`
201-}
202-
203-func historyTimelineHTML(data historyRenderData) string {
204- groups := groupHistoryEntries(data.Entries)
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- }
209- return emptyStateHTML("No history entries")
210- }
211-
212- var body strings.Builder
213- for _, group := range groups {
214- body.WriteString(`<section class="sv-timeline-group">`)
215- body.WriteString(`<h3>` + html.EscapeString(group.Label) + `</h3>`)
216- body.WriteString(listHTML(historyItemsHTML(group.Entries), "No history entries"))
217- body.WriteString(`</section>`)
218- }
219- return body.String()
220-}
221-
222-func historyItemsHTML(entries []*entity.HistoryEntry) string {
223- var items strings.Builder
224- for _, entry := range entries {
225- if entry == nil {
226- continue
227- }
228- items.WriteString(listRowHTML(historyItemHTML(entry)))
229- }
230-
231- return items.String()
232-}
233-
234-func historyItemHTML(entry *entity.HistoryEntry) string {
235- label := strings.TrimSpace(entry.Title)
236- if label == "" {
237- label = displayHistoryDomain(entry.URL)
238- }
239- if label == "" {
240- label = entry.URL
241- }
242-
243- meta := []string{displayHistoryDomain(entry.URL)}
244- if entry.VisitCount > 1 {
245- meta = append(meta, fmt.Sprintf("%dx", entry.VisitCount))
246- }
247- if !entry.LastVisited.IsZero() {
248- meta = append(meta, formatHistoryTime(entry.LastVisited))
249- }
250-
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),
260- html.EscapeString(entry.URL),
261- html.EscapeString(strings.Join(nonEmptyStrings(meta), " · ")),
262- html.EscapeString(sanitizeHref(entry.URL)),
263- deleteButton,
264- )
265-}
266-
267-type historyTimelineGroup struct {
268- Date string
269- Label string
270- Entries []*entity.HistoryEntry
115+ return "No history entries"
271116 }
272117
273118 func groupHistoryEntries(entries []*entity.HistoryEntry) []historyTimelineGroup {
@@ -380,6 +225,34 @@ func formatHistoryTime(ts time.Time) string {
380225 return ts.Local().Format("15:04")
381226 }
382227
228+func historyItemLabel(entry *entity.HistoryEntry) string {
229+ if entry == nil {
230+ return ""
231+ }
232+ label := strings.TrimSpace(entry.Title)
233+ if label == "" {
234+ label = displayHistoryDomain(entry.URL)
235+ }
236+ if label == "" {
237+ label = entry.URL
238+ }
239+ return label
240+}
241+
242+func historyItemMeta(entry *entity.HistoryEntry) string {
243+ if entry == nil {
244+ return ""
245+ }
246+ meta := []string{displayHistoryDomain(entry.URL)}
247+ if entry.VisitCount > 1 {
248+ meta = append(meta, fmt.Sprintf("%dx", entry.VisitCount))
249+ }
250+ if !entry.LastVisited.IsZero() {
251+ meta = append(meta, formatHistoryTime(entry.LastVisited))
252+ }
253+ return strings.Join(nonEmptyStrings(meta), " · ")
254+}
255+
383256 func nonEmptyStrings(values []string) []string {
384257 out := values[:0]
385258 for _, value := range values {
@@ -2,7 +2,6 @@ package systemviews
2 2
3 import (3 import (
4 "fmt"4 "fmt"
5- "html"
6 "net/url"5 "net/url"
7 "strings"6 "strings"
8 "time"7 "time"
@@ -22,92 +21,61 @@ type historyRenderData struct {
22 Error string21 Error string
23 }22 }
24 23
24+type historyTimelineGroup struct {
25+ Date string
26+ Label string
27+ Entries []*entity.HistoryEntry
28+}
29+
30+type historyCleanupItem struct {
31+ Label string
32+ RangeID string
33+ Confirm string
34+}
35+
25 func historyHTML(data historyRenderData) string {36 func historyHTML(data historyRenderData) string {
26- sections := []string{37+ return mustRenderComponent(HistoryView(data))
27- historyStatusHTML(data),
28- sectionHTML("sv-history-controls", "Manage history", historyControlsHTML(data)),
29- sectionHTML("sv-history-summary", "History", historySummaryHTML(data)),
30- sectionHTML("sv-history-domains", "Top domains", historyDomainsHTML(data.Domains)),
31- sectionHTML("sv-history-timeline", "Recent visits", historyTimelineHTML(data)),
32- }
33- return strings.Join(sections, "")
34 }38 }
35 39
36-func historyStatusHTML(data historyRenderData) string {40+func historyCleanupItems() []historyCleanupItem {
37- var out strings.Builder41+ return []historyCleanupItem{
38- if strings.TrimSpace(data.Notice) != "" {42+ {Label: "Last hour", RangeID: "hour", Confirm: "Delete history for last hour?"},
39- out.WriteString(fmt.Sprintf(`<div class="sv-alert sv-alert-success" role="status">%s</div>`, html.EscapeString(data.Notice)))43+ {Label: "Today", RangeID: "day", Confirm: "Delete history for today?"},
44+ {Label: "Week", RangeID: "week", Confirm: "Delete history for week?"},
45+ {Label: "Month", RangeID: "month", Confirm: "Delete history for month?"},
46+ {Label: "All", RangeID: "all", Confirm: "Delete all browsing history?"},
40 }47 }
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 }48 }
46 49
47-func historyControlsHTML(data historyRenderData) string {50+func historyLimit(data historyRenderData) int {
48- query := strings.TrimSpace(data.Query)51+ if data.Limit <= 0 {
49- domain := strings.TrimSpace(data.DomainFilter)52+ return historyTimelineLimit
50- limit := data.Limit
51- if limit <= 0 {
52- limit = historyTimelineLimit
53 }53 }
54- offset := data.Offset54+ return data.Limit
55+}
56+
57+func previousHistoryOffset(data historyRenderData) int {
58+ offset := data.Offset - historyLimit(data)
55 if offset < 0 {59 if offset < 0 {
56- offset = 060+ return 0
57 }61 }
62+ return offset
63+}
58 64
59- var out strings.Builder65+func nextHistoryOffset(data historyRenderData) int {
60- out.WriteString(`<form class="sv-toolbar sv-history-search" data-sv-action="history.search">`)66+ return max(data.Offset, 0) + historyLimit(data)
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)))67+}
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 68
77- out.WriteString(`<div class="sv-history-actions-panel">`)69+func disableHistoryPrev(data historyRenderData) bool {
78- out.WriteString(`<div class="sv-button-row" aria-label="History pagination">`)70+ return data.Offset <= 0 || strings.TrimSpace(data.Query) != ""
79- prevOffset := offset - limit71+}
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 72
88- out.WriteString(`<div class="sv-button-row" aria-label="History cleanup">`)73+func disableHistoryNext(data historyRenderData) bool {
89- out.WriteString(`<span class="sv-meta">Clean up</span>`)74+ return strings.TrimSpace(data.Query) != "" || countHistoryEntries(data.Entries) < historyLimit(data)
90- for _, item := range []struct {75+}
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 76
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>`)77+func showHistoryFilters(data historyRenderData) bool {
110- return out.String()78+ return strings.TrimSpace(data.Query) != "" || strings.TrimSpace(data.DomainFilter) != ""
111 }79 }
112 80
113 func historyOffsetLabel(offset int, query string) string {81 func historyOffsetLabel(offset int, query string) string {
@@ -120,29 +88,6 @@ func historyOffsetLabel(offset int, query string) string {
120 return fmt.Sprintf(" from offset %d", offset)88 return fmt.Sprintf(" from offset %d", offset)
121 }89 }
122 90
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 {91 func pluralSuffix(count int) string {
147 if count == 1 {92 if count == 1 {
148 return ""93 return ""
@@ -150,124 +95,24 @@ func pluralSuffix(count int) string {
150 return "s"95 return "s"
151 }96 }
152 97
153-func historySummaryHTML(data historyRenderData) string {98+func historySummaryValues(data historyRenderData) (entries, visits, uniqueDays int64) {
154- entries := int64(countHistoryEntries(data.Entries))99+ entries = int64(countHistoryEntries(data.Entries))
155- visits := sumHistoryVisits(data.Entries)100+ visits = sumHistoryVisits(data.Entries)
156- uniqueDays := countUniqueHistoryDays(data.Entries)101+ uniqueDays = countUniqueHistoryDays(data.Entries)
157 102
158 if data.Analytics != nil {103 if data.Analytics != nil {
159 entries = data.Analytics.TotalEntries104 entries = data.Analytics.TotalEntries
160 visits = data.Analytics.TotalVisits105 visits = data.Analytics.TotalVisits
161 uniqueDays = data.Analytics.UniqueDays106 uniqueDays = data.Analytics.UniqueDays
162 }107 }
163-108+ return entries, visits, uniqueDays
164- return fmt.Sprintf(`<div class="sv-stat-grid">%s%s%s</div>`,
165- historyStatCardHTML("Entries", entries),
166- historyStatCardHTML("Visits", visits),
167- historyStatCardHTML("Days", uniqueDays),
168- )
169-}
170-
171-func historyStatCardHTML(label string, value int64) string {
172- return fmt.Sprintf(`<div class="sv-stat-card"><span class="sv-stat-value">%d</span><span class="sv-stat-label">%s</span></div>`, value, html.EscapeString(label))
173 }109 }
174 110
175-func historyDomainsHTML(domains []*entity.DomainStat) string {111+func historyTimelineEmptyMessage(data historyRenderData) string {
176- var chips strings.Builder112+ if strings.TrimSpace(data.Query) != "" || strings.TrimSpace(data.DomainFilter) != "" {
177- for _, domain := range domains {113+ return "No history entries match the current filters"
178- if domain == nil || strings.TrimSpace(domain.Domain) == "" {
179- continue
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))
189- chips.WriteString(fmt.Sprintf(
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),
194- ))
195- chips.WriteString(`</span>`)
196- }
197- if chips.Len() == 0 {
198- return emptyStateHTML("No domain statistics yet")
199 }114 }
200- return `<div class="sv-domain-list">` + chips.String() + `</div>`115+ return "No history entries"
201-}
202-
203-func historyTimelineHTML(data historyRenderData) string {
204- groups := groupHistoryEntries(data.Entries)
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- }
209- return emptyStateHTML("No history entries")
210- }
211-
212- var body strings.Builder
213- for _, group := range groups {
214- body.WriteString(`<section class="sv-timeline-group">`)
215- body.WriteString(`<h3>` + html.EscapeString(group.Label) + `</h3>`)
216- body.WriteString(listHTML(historyItemsHTML(group.Entries), "No history entries"))
217- body.WriteString(`</section>`)
218- }
219- return body.String()
220-}
221-
222-func historyItemsHTML(entries []*entity.HistoryEntry) string {
223- var items strings.Builder
224- for _, entry := range entries {
225- if entry == nil {
226- continue
227- }
228- items.WriteString(listRowHTML(historyItemHTML(entry)))
229- }
230-
231- return items.String()
232-}
233-
234-func historyItemHTML(entry *entity.HistoryEntry) string {
235- label := strings.TrimSpace(entry.Title)
236- if label == "" {
237- label = displayHistoryDomain(entry.URL)
238- }
239- if label == "" {
240- label = entry.URL
241- }
242-
243- meta := []string{displayHistoryDomain(entry.URL)}
244- if entry.VisitCount > 1 {
245- meta = append(meta, fmt.Sprintf("%dx", entry.VisitCount))
246- }
247- if !entry.LastVisited.IsZero() {
248- meta = append(meta, formatHistoryTime(entry.LastVisited))
249- }
250-
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),
260- html.EscapeString(entry.URL),
261- html.EscapeString(strings.Join(nonEmptyStrings(meta), " · ")),
262- html.EscapeString(sanitizeHref(entry.URL)),
263- deleteButton,
264- )
265-}
266-
267-type historyTimelineGroup struct {
268- Date string
269- Label string
270- Entries []*entity.HistoryEntry
271 }116 }
272 117
273 func groupHistoryEntries(entries []*entity.HistoryEntry) []historyTimelineGroup {118 func groupHistoryEntries(entries []*entity.HistoryEntry) []historyTimelineGroup {
@@ -380,6 +225,34 @@ func formatHistoryTime(ts time.Time) string {
380 return ts.Local().Format("15:04")225 return ts.Local().Format("15:04")
381 }226 }
382 227
228+func historyItemLabel(entry *entity.HistoryEntry) string {
229+ if entry == nil {
230+ return ""
231+ }
232+ label := strings.TrimSpace(entry.Title)
233+ if label == "" {
234+ label = displayHistoryDomain(entry.URL)
235+ }
236+ if label == "" {
237+ label = entry.URL
238+ }
239+ return label
240+}
241+
242+func historyItemMeta(entry *entity.HistoryEntry) string {
243+ if entry == nil {
244+ return ""
245+ }
246+ meta := []string{displayHistoryDomain(entry.URL)}
247+ if entry.VisitCount > 1 {
248+ meta = append(meta, fmt.Sprintf("%dx", entry.VisitCount))
249+ }
250+ if !entry.LastVisited.IsZero() {
251+ meta = append(meta, formatHistoryTime(entry.LastVisited))
252+ }
253+ return strings.Join(nonEmptyStrings(meta), " · ")
254+}
255+
383 func nonEmptyStrings(values []string) []string {256 func nonEmptyStrings(values []string) []string {
384 out := values[:0]257 out := values[:0]
385 for _, value := range values {258 for _, value := range values {
M internal/ui/systemviews/href_test.go +2 -2
@@ -24,10 +24,10 @@ func TestHistoryHTMLSanitizesHrefSchemes(t *testing.T) {
2424 func TestFavoriteItemsHTMLSanitizesHrefSchemes(t *testing.T) {
2525 t.Parallel()
2626
27- html := favoriteItemsHTML([]*entity.Favorite{
27+ html := favoritesHTML(favoritesRenderData{Favorites: []*entity.Favorite{
2828 {URL: "javascript:alert(1)", Title: "Bad"},
2929 {URL: "https://example.com", Title: "Good"},
30- }, nil, nil)
30+ }})
3131
3232 require.NotContains(t, html, `href="javascript:alert(1)"`)
3333 assert.Contains(t, html, `href="#"`)
@@ -24,10 +24,10 @@ func TestHistoryHTMLSanitizesHrefSchemes(t *testing.T) {
24 func TestFavoriteItemsHTMLSanitizesHrefSchemes(t *testing.T) {24 func TestFavoriteItemsHTMLSanitizesHrefSchemes(t *testing.T) {
25 t.Parallel()25 t.Parallel()
26 26
27- html := favoriteItemsHTML([]*entity.Favorite{27+ html := favoritesHTML(favoritesRenderData{Favorites: []*entity.Favorite{
28 {URL: "javascript:alert(1)", Title: "Bad"},28 {URL: "javascript:alert(1)", Title: "Bad"},
29 {URL: "https://example.com", Title: "Good"},29 {URL: "https://example.com", Title: "Good"},
30- }, nil, nil)30+ }})
31 31
32 require.NotContains(t, html, `href="javascript:alert(1)"`)32 require.NotContains(t, html, `href="javascript:alert(1)"`)
33 assert.Contains(t, html, `href="#"`)33 assert.Contains(t, html, `href="#"`)
A internal/ui/systemviews/render.go +27 -0
@@ -0,0 +1,27 @@
1+package systemviews
2+
3+import (
4+ "bytes"
5+ "context"
6+
7+ "github.com/a-h/templ"
8+)
9+
10+func renderComponent(ctx context.Context, component templ.Component) (string, error) {
11+ var buf bytes.Buffer
12+ if component == nil {
13+ return "", nil
14+ }
15+ if err := component.Render(ctx, &buf); err != nil {
16+ return "", err
17+ }
18+ return buf.String(), nil
19+}
20+
21+func mustRenderComponent(component templ.Component) string {
22+ html, err := renderComponent(context.Background(), component)
23+ if err != nil {
24+ return errorStateHTML(err.Error())
25+ }
26+ return html
27+}
@@ -0,0 +1,27 @@
1+package systemviews
2+
3+import (
4+ "bytes"
5+ "context"
6+
7+ "github.com/a-h/templ"
8+)
9+
10+func renderComponent(ctx context.Context, component templ.Component) (string, error) {
11+ var buf bytes.Buffer
12+ if component == nil {
13+ return "", nil
14+ }
15+ if err := component.Render(ctx, &buf); err != nil {
16+ return "", err
17+ }
18+ return buf.String(), nil
19+}
20+
21+func mustRenderComponent(component templ.Component) string {
22+ html, err := renderComponent(context.Background(), component)
23+ if err != nil {
24+ return errorStateHTML(err.Error())
25+ }
26+ return html
27+}
M internal/ui/systemviews/shell.go +36 -18
@@ -4,6 +4,8 @@ import (
44 "fmt"
55 "html"
66 "strings"
7+
8+ "github.com/a-h/templ"
79 )
810
911 type kvPair struct {
@@ -22,34 +24,50 @@ type renderedPage struct {
2224 // renderAppFrame produces a self-contained HTML fragment (no <html>, <head>,
2325 // or <body>) that wraps route content in the shared shell.
2426 func renderAppFrame(page renderedPage, theme shellTheme) string {
25- rootClass := "sv-app"
27+ return mustRenderComponent(appFrameComponent(page, theme, templ.Raw(page.body)))
28+}
29+
30+func appRootClass(theme shellTheme) string {
2631 themeClass := theme.RootClass
2732 if themeClass == "" {
2833 themeClass = "sv-dark"
2934 }
30- rootClass += " " + themeClass
35+ return "sv-app " + themeClass
36+}
3137
32- var styleAttr string
33- if theme.InlineVars != "" {
34- styleAttr = fmt.Sprintf(` style=%q`, html.EscapeString(theme.InlineVars))
38+func sectionClass(className string) string {
39+ classes := "sv-section"
40+ if strings.TrimSpace(className) != "" {
41+ classes += " " + strings.TrimSpace(className)
3542 }
43+ return classes
44+}
3645
37- routeAttr := html.EscapeString(string(page.route))
46+func alertClass(kind string) string {
47+ classes := "sv-alert"
48+ switch kind {
49+ case "error":
50+ return classes + " sv-alert-error"
51+ case "success":
52+ return classes + " sv-alert-success"
53+ default:
54+ return classes
55+ }
56+}
3857
39- var subtitle string
40- if page.subtitle != "" {
41- subtitle = fmt.Sprintf(`<h1 class="sv-title">%s</h1>`, html.EscapeString(page.subtitle))
58+func alertRole(kind string) string {
59+ if kind == "error" {
60+ return "alert"
4261 }
62+ return "status"
63+}
4364
44- return fmt.Sprintf(`<div class=%q data-route=%q%s><div class=%q>%s<div class=%q>%s</div></div></div>`,
45- html.EscapeString(rootClass),
46- routeAttr,
47- styleAttr,
48- "sv-shell",
49- subtitle,
50- "sv-content",
51- page.body,
52- )
65+func buttonClass(extra string) string {
66+ classes := strings.TrimSpace("sv-button " + strings.TrimSpace(extra))
67+ if classes == "" {
68+ return "sv-button"
69+ }
70+ return classes
5371 }
5472
5573 func listHTML(rows, emptyMessage string) string {
@@ -4,6 +4,8 @@ import (
4 "fmt"4 "fmt"
5 "html"5 "html"
6 "strings"6 "strings"
7+
8+ "github.com/a-h/templ"
7 )9 )
8 10
9 type kvPair struct {11 type kvPair struct {
@@ -22,34 +24,50 @@ type renderedPage struct {
22 // renderAppFrame produces a self-contained HTML fragment (no <html>, <head>,24 // renderAppFrame produces a self-contained HTML fragment (no <html>, <head>,
23 // or <body>) that wraps route content in the shared shell.25 // or <body>) that wraps route content in the shared shell.
24 func renderAppFrame(page renderedPage, theme shellTheme) string {26 func renderAppFrame(page renderedPage, theme shellTheme) string {
25- rootClass := "sv-app"27+ return mustRenderComponent(appFrameComponent(page, theme, templ.Raw(page.body)))
28+}
29+
30+func appRootClass(theme shellTheme) string {
26 themeClass := theme.RootClass31 themeClass := theme.RootClass
27 if themeClass == "" {32 if themeClass == "" {
28 themeClass = "sv-dark"33 themeClass = "sv-dark"
29 }34 }
30- rootClass += " " + themeClass35+ return "sv-app " + themeClass
36+}
31 37
32- var styleAttr string38+func sectionClass(className string) string {
33- if theme.InlineVars != "" {39+ classes := "sv-section"
34- styleAttr = fmt.Sprintf(` style=%q`, html.EscapeString(theme.InlineVars))40+ if strings.TrimSpace(className) != "" {
41+ classes += " " + strings.TrimSpace(className)
35 }42 }
43+ return classes
44+}
36 45
37- routeAttr := html.EscapeString(string(page.route))46+func alertClass(kind string) string {
47+ classes := "sv-alert"
48+ switch kind {
49+ case "error":
50+ return classes + " sv-alert-error"
51+ case "success":
52+ return classes + " sv-alert-success"
53+ default:
54+ return classes
55+ }
56+}
38 57
39- var subtitle string58+func alertRole(kind string) string {
40- if page.subtitle != "" {59+ if kind == "error" {
41- subtitle = fmt.Sprintf(`<h1 class="sv-title">%s</h1>`, html.EscapeString(page.subtitle))60+ return "alert"
42 }61 }
62+ return "status"
63+}
43 64
44- return fmt.Sprintf(`<div class=%q data-route=%q%s><div class=%q>%s<div class=%q>%s</div></div></div>`,65+func buttonClass(extra string) string {
45- html.EscapeString(rootClass),66+ classes := strings.TrimSpace("sv-button " + strings.TrimSpace(extra))
46- routeAttr,67+ if classes == "" {
47- styleAttr,68+ return "sv-button"
48- "sv-shell",69+ }
49- subtitle,70+ return classes
50- "sv-content",
51- page.body,
52- )
53 }71 }
54 72
55 func listHTML(rows, emptyMessage string) string {73 func listHTML(rows, emptyMessage string) string {

Keyboard shortcuts

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

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

Branches 8

Tags 42

↑↓ moveenter openesc close