tangled
alpha
login
or
join now
nonbinary.computer
/
weaver
atproto blogging
26
fork
atom
overview
issues
2
pulls
pipelines
Compare changes
Choose any two refs to compare.
base:
main
no tags found
compare:
main
no tags found
go
+3167
-367
18 changed files
expand all
collapse all
unified
split
crates
weaver-app
Cargo.toml
assets
styling
inline-theme-editor.css
notebook-editor.css
notebook-settings.css
theme-editor.css
src
components
css.rs
dialog
dialog.css
hex_colour_input.rs
inline_theme_editor.rs
mod.rs
notebook_editor.rs
theme_editor.rs
theme_preview.rs
custom_domain_app.rs
lib.rs
views
footer.rs
mod.rs
notebook_settings.rs
+1
-1
crates/weaver-app/Cargo.toml
···
57
57
jacquard-axum = { workspace = true, optional = true }
58
58
weaver-api = { path = "../weaver-api", features = ["com_whtwnd"] }
59
59
markdown-weaver = { workspace = true }
60
60
-
weaver-renderer = { path = "../weaver-renderer", features = ["themes"] }
60
60
+
weaver-renderer = { path = "../weaver-renderer", features = ["themes", "syntax-highlighting", "syntax-css"] }
61
61
n0-future = { workspace = true }
62
62
dioxus-primitives = { git = "https://github.com/DioxusLabs/components", version = "0.0.1", default-features = false, features = ["router"] }
63
63
axum = { version = "0.8.6", optional = true }
+107
-1
crates/weaver-app/assets/styling/inline-theme-editor.css
···
1
1
/* Inline theme editor for notebook settings */
2
2
3
3
.inline-theme-editor {
4
4
+
container-type: inline-size;
4
5
display: flex;
5
6
flex-direction: column;
6
7
gap: 1rem;
···
43
44
border-color: var(--color-primary);
44
45
}
45
46
47
47
+
/* Main theme controls section - two columns when wide */
48
48
+
.inline-theme-editor-main {
49
49
+
display: flex;
50
50
+
flex-direction: column;
51
51
+
gap: 1rem;
52
52
+
}
53
53
+
54
54
+
@container (min-width: 32rem) {
55
55
+
.inline-theme-editor-main {
56
56
+
display: grid;
57
57
+
grid-template-columns: 1fr 1fr;
58
58
+
gap: 1rem 1.5rem;
59
59
+
}
60
60
+
}
61
61
+
62
62
+
/* Left: colours and mode toggle */
63
63
+
.inline-theme-editor-main-left {
64
64
+
display: flex;
65
65
+
flex-direction: column;
66
66
+
gap: 0.75rem;
67
67
+
}
68
68
+
69
69
+
/* Right: dropdowns */
70
70
+
.inline-theme-editor-main-right {
71
71
+
display: flex;
72
72
+
flex-direction: column;
73
73
+
gap: 0.75rem;
74
74
+
}
75
75
+
46
76
.inline-theme-editor-colours {
47
77
display: grid;
48
48
-
grid-template-columns: repeat(auto-fit, minmax(10rem, 1fr));
78
78
+
grid-template-columns: 1fr;
49
79
gap: 0.75rem;
50
80
}
51
81
82
82
+
@container (min-width: 20rem) {
83
83
+
.inline-theme-editor-colours {
84
84
+
grid-template-columns: repeat(2, 1fr);
85
85
+
}
86
86
+
}
87
87
+
52
88
.inline-theme-editor-code-theme,
53
89
.inline-theme-editor-mode {
54
90
display: flex;
···
81
117
gap: 0.5rem;
82
118
}
83
119
120
120
+
/* Advanced colour options - hidden by default in narrow containers */
121
121
+
.inline-theme-editor-advanced {
122
122
+
display: none;
123
123
+
}
124
124
+
125
125
+
/* Show advanced when container is wide enough */
126
126
+
@container (min-width: 40rem) {
127
127
+
.inline-theme-editor-advanced {
128
128
+
display: block;
129
129
+
}
130
130
+
}
131
131
+
132
132
+
/* Force show advanced via class (for prop override) */
133
133
+
.inline-theme-editor-advanced.force-show {
134
134
+
display: block;
135
135
+
}
136
136
+
137
137
+
/* Force hide advanced via class (for prop override) */
138
138
+
.inline-theme-editor-advanced.force-hide {
139
139
+
display: none;
140
140
+
}
141
141
+
84
142
.inline-theme-editor-advanced-content {
85
143
display: flex;
86
144
flex-direction: column;
···
91
149
border-radius: 0;
92
150
}
93
151
152
152
+
/* Side-by-side variants when wide */
153
153
+
@container (min-width: 40rem) {
154
154
+
.inline-theme-editor-advanced-content {
155
155
+
display: grid;
156
156
+
grid-template-columns: 1fr 1fr;
157
157
+
gap: 1.5rem;
158
158
+
}
159
159
+
}
160
160
+
94
161
.inline-theme-editor-variant {
95
162
display: flex;
96
163
flex-direction: column;
···
113
180
.inline-theme-editor-full-link:hover {
114
181
text-decoration: underline;
115
182
}
183
183
+
184
184
+
/* Preview section */
185
185
+
.inline-theme-editor-preview {
186
186
+
display: flex;
187
187
+
flex-direction: column;
188
188
+
gap: 0.5rem;
189
189
+
margin-top: 0.5rem;
190
190
+
border-top: 1px solid var(--color-border);
191
191
+
padding-top: 1rem;
192
192
+
}
193
193
+
194
194
+
.inline-theme-editor-preview-header {
195
195
+
display: flex;
196
196
+
align-items: center;
197
197
+
justify-content: space-between;
198
198
+
gap: 1rem;
199
199
+
}
200
200
+
201
201
+
.inline-theme-editor-preview-header h5 {
202
202
+
margin: 0;
203
203
+
font-size: 0.875rem;
204
204
+
font-weight: 600;
205
205
+
color: var(--color-muted);
206
206
+
}
207
207
+
208
208
+
.inline-theme-editor-preview .theme-preview {
209
209
+
border: 1px solid var(--color-border);
210
210
+
border-radius: 0;
211
211
+
max-height: 20rem;
212
212
+
overflow-y: auto;
213
213
+
padding: 1rem;
214
214
+
}
215
215
+
216
216
+
.inline-theme-editor-preview .theme-preview--loading,
217
217
+
.inline-theme-editor-preview .theme-preview--error {
218
218
+
padding: 1rem;
219
219
+
font-size: 0.875rem;
220
220
+
color: var(--color-muted);
221
221
+
}
+82
-3
crates/weaver-app/assets/styling/notebook-editor.css
···
1
1
/* Notebook editor form component */
2
2
3
3
.notebook-editor {
4
4
+
container-type: inline-size;
4
5
display: flex;
5
6
flex-direction: column;
6
7
gap: 1rem;
···
18
19
gap: 1rem;
19
20
}
20
21
22
22
+
/* Top fields section - two columns when wide */
23
23
+
.notebook-editor-top {
24
24
+
display: flex;
25
25
+
flex-direction: column;
26
26
+
gap: 1rem;
27
27
+
}
28
28
+
29
29
+
@container (min-width: 40rem) {
30
30
+
.notebook-editor-top {
31
31
+
display: grid;
32
32
+
grid-template-columns: 1fr 1fr;
33
33
+
gap: 1rem 1.5rem;
34
34
+
}
35
35
+
}
36
36
+
37
37
+
/* Left column: title, path */
38
38
+
.notebook-editor-top-left {
39
39
+
display: flex;
40
40
+
flex-direction: column;
41
41
+
gap: 1rem;
42
42
+
}
43
43
+
44
44
+
/* Right column: publish globally, tags */
45
45
+
.notebook-editor-top-right {
46
46
+
display: flex;
47
47
+
flex-direction: column;
48
48
+
gap: 1rem;
49
49
+
}
50
50
+
21
51
.notebook-editor-field {
22
52
display: flex;
23
53
flex-direction: column;
···
49
79
}
50
80
51
81
.notebook-editor-toggle {
52
52
-
flex-direction: row;
53
53
-
align-items: flex-start;
54
54
-
gap: 0.5rem;
82
82
+
/* Keep column direction from .notebook-editor-field, just adjust gap */
83
83
+
gap: 0.25rem;
55
84
}
56
85
57
86
.notebook-editor-toggle label {
···
150
179
justify-content: flex-end;
151
180
padding-top: 0.5rem;
152
181
}
182
182
+
183
183
+
/* Content settings section */
184
184
+
.notebook-editor-content-settings {
185
185
+
display: flex;
186
186
+
flex-direction: column;
187
187
+
gap: 1rem;
188
188
+
padding-top: 1rem;
189
189
+
border-top: 1px solid var(--color-border);
190
190
+
}
191
191
+
192
192
+
.notebook-editor-content-settings h4 {
193
193
+
margin: 0;
194
194
+
font-size: 0.875rem;
195
195
+
font-weight: 600;
196
196
+
color: var(--color-muted);
197
197
+
text-transform: uppercase;
198
198
+
letter-spacing: 0.05em;
199
199
+
}
200
200
+
201
201
+
.notebook-editor-warning {
202
202
+
background: color-mix(in srgb, var(--color-warning) 15%, var(--color-surface));
203
203
+
border: 1px solid color-mix(in srgb, var(--color-warning) 40%, transparent);
204
204
+
}
205
205
+
206
206
+
.notebook-editor-field select {
207
207
+
padding: 0.5rem;
208
208
+
padding-right: 2rem;
209
209
+
border: 1px solid var(--color-border);
210
210
+
border-radius: 0;
211
211
+
background: var(--color-base);
212
212
+
color: var(--color-text);
213
213
+
font-size: 0.875rem;
214
214
+
font-family: inherit;
215
215
+
appearance: none;
216
216
+
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%239ca3af' d='M6 8L2 4h8z'/%3E%3C/svg%3E");
217
217
+
background-repeat: no-repeat;
218
218
+
background-position: right 0.5rem center;
219
219
+
cursor: pointer;
220
220
+
}
221
221
+
222
222
+
.notebook-editor-field select:focus {
223
223
+
outline: 2px solid var(--color-primary);
224
224
+
outline-offset: -1px;
225
225
+
}
226
226
+
227
227
+
.notebook-editor-field select option {
228
228
+
background: var(--color-surface);
229
229
+
color: var(--color-text);
230
230
+
padding: 0.5rem;
231
231
+
}
+321
crates/weaver-app/assets/styling/notebook-settings.css
···
1
1
+
/* Notebook settings page */
2
2
+
3
3
+
.notebook-settings {
4
4
+
display: grid;
5
5
+
grid-template-columns: 200px 1fr;
6
6
+
gap: 2rem;
7
7
+
max-width: 100rem;
8
8
+
margin: 0 auto;
9
9
+
padding: 1.5rem;
10
10
+
min-height: calc(100vh - 4rem);
11
11
+
}
12
12
+
13
13
+
@media (max-width: 768px) {
14
14
+
.notebook-settings {
15
15
+
grid-template-columns: 1fr;
16
16
+
gap: 1rem;
17
17
+
}
18
18
+
}
19
19
+
20
20
+
/* Sidebar navigation */
21
21
+
.notebook-settings-nav {
22
22
+
display: flex;
23
23
+
flex-direction: column;
24
24
+
gap: 0.25rem;
25
25
+
position: sticky;
26
26
+
top: 1rem;
27
27
+
align-self: start;
28
28
+
}
29
29
+
30
30
+
@media (max-width: 768px) {
31
31
+
.notebook-settings-nav {
32
32
+
flex-direction: row;
33
33
+
flex-wrap: wrap;
34
34
+
position: static;
35
35
+
border-bottom: 1px solid var(--color-border);
36
36
+
padding-bottom: 1rem;
37
37
+
}
38
38
+
}
39
39
+
40
40
+
.notebook-settings-nav button {
41
41
+
padding: 0.75rem 1rem;
42
42
+
text-align: left;
43
43
+
background: transparent;
44
44
+
border: none;
45
45
+
color: var(--color-muted);
46
46
+
font-size: 0.875rem;
47
47
+
cursor: pointer;
48
48
+
transition: color 0.15s, background 0.15s;
49
49
+
}
50
50
+
51
51
+
.notebook-settings-nav button:hover {
52
52
+
color: var(--color-text);
53
53
+
background: var(--color-surface);
54
54
+
}
55
55
+
56
56
+
.notebook-settings-nav button.active {
57
57
+
color: var(--color-text);
58
58
+
background: var(--color-surface);
59
59
+
font-weight: 600;
60
60
+
}
61
61
+
62
62
+
/* Content area */
63
63
+
.notebook-settings-content {
64
64
+
min-width: 0;
65
65
+
}
66
66
+
67
67
+
/* Section styling */
68
68
+
.notebook-settings-section {
69
69
+
display: flex;
70
70
+
flex-direction: column;
71
71
+
gap: 1.5rem;
72
72
+
}
73
73
+
74
74
+
.notebook-settings-section h2 {
75
75
+
margin: 0;
76
76
+
font-size: 1.5rem;
77
77
+
font-weight: 600;
78
78
+
color: var(--color-text);
79
79
+
padding-bottom: 0.75rem;
80
80
+
border-bottom: 1px solid var(--color-border);
81
81
+
}
82
82
+
83
83
+
.notebook-settings-description {
84
84
+
margin: 0;
85
85
+
color: var(--color-muted);
86
86
+
font-size: 0.875rem;
87
87
+
}
88
88
+
89
89
+
/* Form fields */
90
90
+
.notebook-settings-field {
91
91
+
display: flex;
92
92
+
flex-direction: column;
93
93
+
gap: 0.5rem;
94
94
+
}
95
95
+
96
96
+
.notebook-settings-field label {
97
97
+
font-size: 0.875rem;
98
98
+
font-weight: 500;
99
99
+
color: var(--color-text);
100
100
+
}
101
101
+
102
102
+
.notebook-settings-field input[type="text"],
103
103
+
.notebook-settings-field select {
104
104
+
padding: 0.625rem 0.75rem;
105
105
+
border: 1px solid var(--color-border);
106
106
+
background: var(--color-base);
107
107
+
color: var(--color-text);
108
108
+
font-family: var(--font-ui);
109
109
+
font-size: 0.875rem;
110
110
+
}
111
111
+
112
112
+
.notebook-settings-field input:focus,
113
113
+
.notebook-settings-field select:focus {
114
114
+
outline: none;
115
115
+
border-color: var(--color-primary);
116
116
+
}
117
117
+
118
118
+
.notebook-settings-hint {
119
119
+
font-size: 0.75rem;
120
120
+
color: var(--color-muted);
121
121
+
}
122
122
+
123
123
+
/* Toggle field */
124
124
+
.notebook-settings-toggle label {
125
125
+
display: flex;
126
126
+
align-items: center;
127
127
+
gap: 0.5rem;
128
128
+
cursor: pointer;
129
129
+
}
130
130
+
131
131
+
.notebook-settings-toggle input[type="checkbox"] {
132
132
+
width: 1rem;
133
133
+
height: 1rem;
134
134
+
}
135
135
+
136
136
+
/* Tags */
137
137
+
.notebook-settings-tags {
138
138
+
display: flex;
139
139
+
flex-wrap: wrap;
140
140
+
gap: 0.5rem;
141
141
+
padding: 0.5rem;
142
142
+
border: 1px solid var(--color-border);
143
143
+
background: var(--color-base);
144
144
+
min-height: 2.5rem;
145
145
+
}
146
146
+
147
147
+
.notebook-settings-tag {
148
148
+
display: inline-flex;
149
149
+
align-items: center;
150
150
+
gap: 0.25rem;
151
151
+
padding: 0.25rem 0.5rem;
152
152
+
background: var(--color-surface);
153
153
+
border: 1px solid var(--color-border);
154
154
+
font-size: 0.75rem;
155
155
+
color: var(--color-text);
156
156
+
}
157
157
+
158
158
+
.notebook-settings-tag-remove {
159
159
+
background: none;
160
160
+
border: none;
161
161
+
padding: 0;
162
162
+
margin-left: 0.25rem;
163
163
+
color: var(--color-muted);
164
164
+
cursor: pointer;
165
165
+
font-size: 1rem;
166
166
+
line-height: 1;
167
167
+
}
168
168
+
169
169
+
.notebook-settings-tag-remove:hover {
170
170
+
color: var(--color-error);
171
171
+
}
172
172
+
173
173
+
.notebook-settings-tags-input {
174
174
+
flex: 1;
175
175
+
min-width: 100px;
176
176
+
border: none !important;
177
177
+
background: transparent !important;
178
178
+
padding: 0.25rem !important;
179
179
+
}
180
180
+
181
181
+
.notebook-settings-tags-input:focus {
182
182
+
outline: none;
183
183
+
}
184
184
+
185
185
+
/* Actions */
186
186
+
.notebook-settings-actions {
187
187
+
display: flex;
188
188
+
justify-content: flex-start;
189
189
+
gap: 0.75rem;
190
190
+
padding-top: 1rem;
191
191
+
border-top: 1px solid var(--color-border);
192
192
+
}
193
193
+
194
194
+
/* Error display */
195
195
+
.notebook-settings-error {
196
196
+
padding: 0.75rem 1rem;
197
197
+
background: color-mix(in srgb, var(--color-error) 10%, transparent);
198
198
+
border: 1px solid var(--color-error);
199
199
+
color: var(--color-error);
200
200
+
font-size: 0.875rem;
201
201
+
}
202
202
+
203
203
+
/* Theme section - needs full width */
204
204
+
.notebook-settings-theme {
205
205
+
container-type: inline-size;
206
206
+
}
207
207
+
208
208
+
.notebook-settings-theme .theme-editor-page {
209
209
+
padding: 0;
210
210
+
max-width: none;
211
211
+
}
212
212
+
213
213
+
/* On smaller screens, stack the theme editor */
214
214
+
@container (max-width: 50rem) {
215
215
+
.notebook-settings-theme .theme-editor-page {
216
216
+
grid-template-columns: 1fr;
217
217
+
}
218
218
+
219
219
+
.notebook-settings-theme .theme-editor-preview {
220
220
+
position: static;
221
221
+
max-height: none;
222
222
+
}
223
223
+
}
224
224
+
225
225
+
/* Danger zone */
226
226
+
.notebook-settings-danger h2 {
227
227
+
color: var(--color-error);
228
228
+
border-color: var(--color-error);
229
229
+
}
230
230
+
231
231
+
.notebook-settings-danger-item {
232
232
+
display: flex;
233
233
+
align-items: center;
234
234
+
justify-content: space-between;
235
235
+
gap: 1.5rem;
236
236
+
padding: 1rem;
237
237
+
background: color-mix(in srgb, var(--color-error) 5%, transparent);
238
238
+
border: 1px solid color-mix(in srgb, var(--color-error) 30%, transparent);
239
239
+
}
240
240
+
241
241
+
.notebook-settings-danger-info h3 {
242
242
+
margin: 0 0 0.25rem;
243
243
+
font-size: 1rem;
244
244
+
font-weight: 600;
245
245
+
color: var(--color-text);
246
246
+
}
247
247
+
248
248
+
.notebook-settings-danger-info p {
249
249
+
margin: 0;
250
250
+
font-size: 0.875rem;
251
251
+
color: var(--color-muted);
252
252
+
}
253
253
+
254
254
+
/* Confirm dialog */
255
255
+
.notebook-settings-confirm-overlay {
256
256
+
position: fixed;
257
257
+
inset: 0;
258
258
+
background: rgba(0, 0, 0, 0.5);
259
259
+
display: flex;
260
260
+
align-items: center;
261
261
+
justify-content: center;
262
262
+
z-index: 1000;
263
263
+
}
264
264
+
265
265
+
.notebook-settings-confirm-dialog {
266
266
+
background: var(--color-surface);
267
267
+
border: 1px solid var(--color-border);
268
268
+
padding: 1.5rem;
269
269
+
max-width: 400px;
270
270
+
width: 90%;
271
271
+
}
272
272
+
273
273
+
.notebook-settings-confirm-dialog h3 {
274
274
+
margin: 0 0 0.5rem;
275
275
+
font-size: 1.125rem;
276
276
+
font-weight: 600;
277
277
+
color: var(--color-text);
278
278
+
}
279
279
+
280
280
+
.notebook-settings-confirm-dialog p {
281
281
+
margin: 0 0 1.5rem;
282
282
+
color: var(--color-muted);
283
283
+
font-size: 0.875rem;
284
284
+
}
285
285
+
286
286
+
.notebook-settings-confirm-actions {
287
287
+
display: flex;
288
288
+
justify-content: flex-end;
289
289
+
gap: 0.75rem;
290
290
+
}
291
291
+
292
292
+
/* Unauthorized state */
293
293
+
.notebook-settings-unauthorized {
294
294
+
display: flex;
295
295
+
flex-direction: column;
296
296
+
align-items: center;
297
297
+
justify-content: center;
298
298
+
min-height: 50vh;
299
299
+
text-align: center;
300
300
+
padding: 2rem;
301
301
+
}
302
302
+
303
303
+
.notebook-settings-unauthorized h1 {
304
304
+
margin: 0 0 0.5rem;
305
305
+
font-size: 1.5rem;
306
306
+
color: var(--color-text);
307
307
+
}
308
308
+
309
309
+
.notebook-settings-unauthorized p {
310
310
+
margin: 0;
311
311
+
color: var(--color-muted);
312
312
+
}
313
313
+
314
314
+
/* Placeholder */
315
315
+
.notebook-settings-placeholder {
316
316
+
padding: 2rem;
317
317
+
text-align: center;
318
318
+
color: var(--color-muted);
319
319
+
background: var(--color-surface);
320
320
+
border: 1px dashed var(--color-border);
321
321
+
}
+336
crates/weaver-app/assets/styling/theme-editor.css
···
1
1
+
/* Full theme editor page */
2
2
+
3
3
+
.theme-editor-page {
4
4
+
display: grid;
5
5
+
grid-template-columns: 1fr;
6
6
+
gap: 2rem;
7
7
+
padding: 1.5rem;
8
8
+
max-width: 100rem;
9
9
+
}
10
10
+
11
11
+
@media (min-width: 60rem) {
12
12
+
.theme-editor-page {
13
13
+
grid-template-columns: 1fr 400px;
14
14
+
}
15
15
+
}
16
16
+
17
17
+
/* Left column: controls */
18
18
+
.theme-editor-controls {
19
19
+
display: flex;
20
20
+
flex-direction: column;
21
21
+
gap: 1.5rem;
22
22
+
}
23
23
+
24
24
+
/* Right column: preview */
25
25
+
.theme-editor-preview {
26
26
+
position: sticky;
27
27
+
top: 1rem;
28
28
+
align-self: start;
29
29
+
max-height: calc(100vh - 2rem);
30
30
+
overflow-y: auto;
31
31
+
}
32
32
+
33
33
+
/* Section styling */
34
34
+
.theme-editor-section {
35
35
+
display: flex;
36
36
+
flex-direction: column;
37
37
+
gap: 0.75rem;
38
38
+
padding: 1rem;
39
39
+
background: var(--color-surface);
40
40
+
border: 1px solid var(--color-border);
41
41
+
}
42
42
+
43
43
+
.theme-editor-section-header {
44
44
+
display: flex;
45
45
+
align-items: center;
46
46
+
justify-content: space-between;
47
47
+
gap: 1rem;
48
48
+
}
49
49
+
50
50
+
.theme-editor-scheme-preset {
51
51
+
padding: 0.375rem 0.5rem;
52
52
+
padding-right: 1.75rem;
53
53
+
border: 1px solid var(--color-border);
54
54
+
background: var(--color-base);
55
55
+
color: var(--color-text);
56
56
+
font-family: var(--font-ui);
57
57
+
font-size: 0.75rem;
58
58
+
appearance: none;
59
59
+
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='10' viewBox='0 0 12 12'%3E%3Cpath fill='%239ca3af' d='M6 8L2 4h8z'/%3E%3C/svg%3E");
60
60
+
background-repeat: no-repeat;
61
61
+
background-position: right 0.375rem center;
62
62
+
cursor: pointer;
63
63
+
}
64
64
+
65
65
+
.theme-editor-scheme-preset:focus {
66
66
+
outline: none;
67
67
+
border-color: var(--color-primary);
68
68
+
}
69
69
+
70
70
+
.theme-editor-scheme-preset option {
71
71
+
background: var(--color-surface);
72
72
+
color: var(--color-text);
73
73
+
}
74
74
+
75
75
+
.theme-editor-section h3 {
76
76
+
margin: 0;
77
77
+
font-size: 1rem;
78
78
+
font-weight: 600;
79
79
+
color: var(--color-text);
80
80
+
}
81
81
+
82
82
+
/* Colour grid - 4 columns */
83
83
+
.theme-editor-colours {
84
84
+
display: grid;
85
85
+
grid-template-columns: repeat(2, 1fr);
86
86
+
gap: 0.75rem;
87
87
+
}
88
88
+
89
89
+
@media (min-width: 30rem) {
90
90
+
.theme-editor-colours {
91
91
+
grid-template-columns: repeat(4, 1fr);
92
92
+
}
93
93
+
}
94
94
+
95
95
+
/* Colour groups */
96
96
+
.theme-editor-colour-group {
97
97
+
display: flex;
98
98
+
flex-direction: column;
99
99
+
gap: 0.5rem;
100
100
+
}
101
101
+
102
102
+
.theme-editor-colour-group-label {
103
103
+
font-size: 0.75rem;
104
104
+
font-weight: 600;
105
105
+
color: var(--color-muted);
106
106
+
text-transform: uppercase;
107
107
+
letter-spacing: 0.05em;
108
108
+
}
109
109
+
110
110
+
.theme-editor-colour-group-items {
111
111
+
display: grid;
112
112
+
grid-template-columns: repeat(2, 1fr);
113
113
+
gap: 0.5rem;
114
114
+
}
115
115
+
116
116
+
@container (min-width: 30rem) {
117
117
+
.theme-editor-colour-group-items {
118
118
+
grid-template-columns: repeat(4, 1fr);
119
119
+
}
120
120
+
}
121
121
+
122
122
+
/* Fonts section */
123
123
+
.theme-editor-fonts {
124
124
+
display: grid;
125
125
+
grid-template-columns: 1fr;
126
126
+
gap: 0.75rem;
127
127
+
}
128
128
+
129
129
+
@container (min-width: 30rem) {
130
130
+
.theme-editor-fonts {
131
131
+
grid-template-columns: repeat(3, 1fr);
132
132
+
}
133
133
+
}
134
134
+
135
135
+
.theme-editor-font-field {
136
136
+
display: flex;
137
137
+
flex-direction: column;
138
138
+
gap: 0.25rem;
139
139
+
}
140
140
+
141
141
+
.theme-editor-font-field label {
142
142
+
font-size: 0.875rem;
143
143
+
color: var(--color-muted);
144
144
+
}
145
145
+
146
146
+
.theme-editor-font-field input,
147
147
+
.theme-editor-font-field select {
148
148
+
padding: 0.5rem;
149
149
+
border: 1px solid var(--color-border);
150
150
+
background: var(--color-base);
151
151
+
color: var(--color-text);
152
152
+
font-family: var(--font-ui);
153
153
+
font-size: 0.875rem;
154
154
+
}
155
155
+
156
156
+
.theme-editor-font-field input:focus,
157
157
+
.theme-editor-font-field select:focus {
158
158
+
outline: none;
159
159
+
border-color: var(--color-primary);
160
160
+
}
161
161
+
162
162
+
/* Spacing section */
163
163
+
.theme-editor-spacing {
164
164
+
display: grid;
165
165
+
grid-template-columns: repeat(3, 1fr);
166
166
+
gap: 0.75rem;
167
167
+
}
168
168
+
169
169
+
.theme-editor-spacing-field {
170
170
+
display: flex;
171
171
+
flex-direction: column;
172
172
+
gap: 0.25rem;
173
173
+
}
174
174
+
175
175
+
.theme-editor-spacing-field label {
176
176
+
font-size: 0.875rem;
177
177
+
color: var(--color-muted);
178
178
+
}
179
179
+
180
180
+
.theme-editor-spacing-field input {
181
181
+
padding: 0.5rem;
182
182
+
border: 1px solid var(--color-border);
183
183
+
background: var(--color-base);
184
184
+
color: var(--color-text);
185
185
+
font-family: var(--font-mono);
186
186
+
font-size: 0.875rem;
187
187
+
text-align: center;
188
188
+
}
189
189
+
190
190
+
.theme-editor-spacing-field input:focus {
191
191
+
outline: none;
192
192
+
border-color: var(--color-primary);
193
193
+
}
194
194
+
195
195
+
/* Code themes */
196
196
+
.theme-editor-code-themes {
197
197
+
display: grid;
198
198
+
grid-template-columns: 1fr 1fr;
199
199
+
gap: 0.75rem;
200
200
+
}
201
201
+
202
202
+
.theme-editor-code-theme {
203
203
+
display: flex;
204
204
+
flex-direction: column;
205
205
+
gap: 0.25rem;
206
206
+
}
207
207
+
208
208
+
.theme-editor-code-theme label {
209
209
+
font-size: 0.875rem;
210
210
+
color: var(--color-muted);
211
211
+
}
212
212
+
213
213
+
.theme-editor-code-theme select {
214
214
+
padding: 0.5rem;
215
215
+
border: 1px solid var(--color-border);
216
216
+
background: var(--color-base);
217
217
+
color: var(--color-text);
218
218
+
font-family: var(--font-ui);
219
219
+
font-size: 0.875rem;
220
220
+
}
221
221
+
222
222
+
.theme-editor-code-theme select:focus {
223
223
+
outline: none;
224
224
+
border-color: var(--color-primary);
225
225
+
}
226
226
+
227
227
+
/* Mode toggle */
228
228
+
.theme-editor-mode {
229
229
+
display: flex;
230
230
+
align-items: center;
231
231
+
gap: 1rem;
232
232
+
}
233
233
+
234
234
+
.theme-editor-mode label {
235
235
+
font-size: 0.875rem;
236
236
+
color: var(--color-muted);
237
237
+
}
238
238
+
239
239
+
/* Actions row */
240
240
+
.theme-editor-actions {
241
241
+
display: flex;
242
242
+
justify-content: flex-end;
243
243
+
gap: 0.75rem;
244
244
+
padding-top: 1rem;
245
245
+
border-top: 1px solid var(--color-border);
246
246
+
}
247
247
+
248
248
+
/* Preview header */
249
249
+
.theme-editor-preview-header {
250
250
+
display: flex;
251
251
+
align-items: center;
252
252
+
justify-content: space-between;
253
253
+
gap: 1rem;
254
254
+
margin-bottom: 0.5rem;
255
255
+
}
256
256
+
257
257
+
.theme-editor-preview-header h3 {
258
258
+
margin: 0;
259
259
+
font-size: 1rem;
260
260
+
font-weight: 600;
261
261
+
color: var(--color-muted);
262
262
+
}
263
263
+
264
264
+
.theme-editor-preview .theme-preview {
265
265
+
padding: 1rem;
266
266
+
border: 1px solid var(--color-border);
267
267
+
max-width: none;
268
268
+
}
269
269
+
270
270
+
.theme-editor-preview .theme-preview--loading,
271
271
+
.theme-editor-preview .theme-preview--error {
272
272
+
padding: 2rem;
273
273
+
text-align: center;
274
274
+
color: var(--color-muted);
275
275
+
}
276
276
+
277
277
+
/* Preset selector */
278
278
+
.theme-editor-preset {
279
279
+
display: flex;
280
280
+
flex-direction: column;
281
281
+
gap: 0.25rem;
282
282
+
}
283
283
+
284
284
+
.theme-editor-preset label {
285
285
+
font-size: 0.875rem;
286
286
+
color: var(--color-muted);
287
287
+
}
288
288
+
289
289
+
.theme-editor-preset select {
290
290
+
padding: 0.5rem;
291
291
+
border: 1px solid var(--color-border);
292
292
+
background: var(--color-base);
293
293
+
color: var(--color-text);
294
294
+
font-family: var(--font-ui);
295
295
+
font-size: 0.875rem;
296
296
+
}
297
297
+
298
298
+
.theme-editor-preset select:focus {
299
299
+
outline: none;
300
300
+
border-color: var(--color-primary);
301
301
+
}
302
302
+
303
303
+
/* Back link */
304
304
+
.theme-editor-back {
305
305
+
display: inline-flex;
306
306
+
align-items: center;
307
307
+
gap: 0.5rem;
308
308
+
color: var(--color-link);
309
309
+
text-decoration: none;
310
310
+
font-size: 0.875rem;
311
311
+
margin-bottom: 1rem;
312
312
+
}
313
313
+
314
314
+
.theme-editor-back:hover {
315
315
+
text-decoration: underline;
316
316
+
}
317
317
+
318
318
+
/* Select dropdown styling */
319
319
+
.theme-editor-section select,
320
320
+
.theme-editor-preset select,
321
321
+
.theme-editor-code-theme select {
322
322
+
appearance: none;
323
323
+
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%239ca3af' d='M6 8L2 4h8z'/%3E%3C/svg%3E");
324
324
+
background-repeat: no-repeat;
325
325
+
background-position: right 0.5rem center;
326
326
+
padding-right: 2rem;
327
327
+
cursor: pointer;
328
328
+
}
329
329
+
330
330
+
.theme-editor-section select option,
331
331
+
.theme-editor-preset select option,
332
332
+
.theme-editor-code-theme select option {
333
333
+
background: var(--color-surface);
334
334
+
color: var(--color-text);
335
335
+
padding: 0.5rem;
336
336
+
}
+172
crates/weaver-app/src/components/css.rs
···
144
144
Ok(([(CONTENT_TYPE, "text/css")], css).into_response())
145
145
}
146
146
147
147
+
/// Input for generating theme preview data from 4 base colours.
148
148
+
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
149
149
+
pub struct ThemePreviewInput {
150
150
+
pub background: String,
151
151
+
pub text: String,
152
152
+
pub primary: String,
153
153
+
pub link: String,
154
154
+
pub light_background: Option<String>,
155
155
+
pub light_text: Option<String>,
156
156
+
pub light_primary: Option<String>,
157
157
+
pub light_link: Option<String>,
158
158
+
pub dark_background: Option<String>,
159
159
+
pub dark_text: Option<String>,
160
160
+
pub dark_primary: Option<String>,
161
161
+
pub dark_link: Option<String>,
162
162
+
pub light_code_theme: String,
163
163
+
pub dark_code_theme: String,
164
164
+
}
165
165
+
166
166
+
/// Generated 16-colour palette for a single variant.
167
167
+
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
168
168
+
pub struct ColourPalette {
169
169
+
pub base: String,
170
170
+
pub surface: String,
171
171
+
pub overlay: String,
172
172
+
pub text: String,
173
173
+
pub muted: String,
174
174
+
pub subtle: String,
175
175
+
pub emphasis: String,
176
176
+
pub primary: String,
177
177
+
pub secondary: String,
178
178
+
pub tertiary: String,
179
179
+
pub error: String,
180
180
+
pub warning: String,
181
181
+
pub success: String,
182
182
+
pub border: String,
183
183
+
pub link: String,
184
184
+
pub highlight: String,
185
185
+
}
186
186
+
187
187
+
impl ColourPalette {
188
188
+
pub fn to_css_vars(&self) -> String {
189
189
+
// Ensure all colours have # prefix.
190
190
+
let fmt = |c: &str| if c.starts_with('#') { c.to_string() } else { format!("#{}", c) };
191
191
+
format!(
192
192
+
"--color-base: {}; --color-surface: {}; --color-overlay: {}; \
193
193
+
--color-text: {}; --color-muted: {}; --color-subtle: {}; \
194
194
+
--color-emphasis: {}; --color-primary: {}; --color-secondary: {}; \
195
195
+
--color-tertiary: {}; --color-error: {}; --color-warning: {}; \
196
196
+
--color-success: {}; --color-border: {}; --color-link: {}; \
197
197
+
--color-highlight: {};",
198
198
+
fmt(&self.base), fmt(&self.surface), fmt(&self.overlay),
199
199
+
fmt(&self.text), fmt(&self.muted), fmt(&self.subtle),
200
200
+
fmt(&self.emphasis), fmt(&self.primary), fmt(&self.secondary),
201
201
+
fmt(&self.tertiary), fmt(&self.error), fmt(&self.warning),
202
202
+
fmt(&self.success), fmt(&self.border), fmt(&self.link),
203
203
+
fmt(&self.highlight)
204
204
+
)
205
205
+
}
206
206
+
}
207
207
+
208
208
+
/// Generated theme preview data: palettes + syntax CSS.
209
209
+
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
210
210
+
pub struct ThemePreviewOutput {
211
211
+
pub light_palette: ColourPalette,
212
212
+
pub dark_palette: ColourPalette,
213
213
+
pub syntax_css: String,
214
214
+
}
215
215
+
216
216
+
/// Generate theme preview data from 4 base colours.
217
217
+
#[post("/api/theme-preview")]
218
218
+
pub async fn generate_theme_preview(
219
219
+
input: ThemePreviewInput,
220
220
+
) -> Result<ThemePreviewOutput, ServerFnError> {
221
221
+
use weaver_renderer::colour_gen::{ThemeInputs, ThemeVariant, detect_variant, generate_palette, generate_counterpart_palette};
222
222
+
use weaver_renderer::css::generate_syntax_css;
223
223
+
use weaver_renderer::theme::{ResolvedTheme, ThemeDefault, default_fonts, default_spacing, ColourSchemeColours};
224
224
+
225
225
+
let light_inputs = ThemeInputs {
226
226
+
background: input.light_background.unwrap_or_else(|| input.background.clone()),
227
227
+
text: input.light_text.unwrap_or_else(|| input.text.clone()),
228
228
+
primary: input.light_primary.unwrap_or_else(|| input.primary.clone()),
229
229
+
link: input.light_link.unwrap_or_else(|| input.link.clone()),
230
230
+
};
231
231
+
232
232
+
let dark_inputs = ThemeInputs {
233
233
+
background: input.dark_background.unwrap_or_else(|| input.background.clone()),
234
234
+
text: input.dark_text.unwrap_or_else(|| input.text.clone()),
235
235
+
primary: input.dark_primary.unwrap_or_else(|| input.primary.clone()),
236
236
+
link: input.dark_link.unwrap_or_else(|| input.link.clone()),
237
237
+
};
238
238
+
239
239
+
let primary_variant = detect_variant(&input.background)
240
240
+
.map_err(|e| ServerFnError::new(format!("Invalid background colour: {}", e)))?;
241
241
+
242
242
+
let (light_scheme, dark_scheme): (ColourSchemeColours, ColourSchemeColours) = match primary_variant {
243
243
+
ThemeVariant::Light => {
244
244
+
let light = generate_palette(&light_inputs, ThemeVariant::Light)
245
245
+
.map_err(|e| ServerFnError::new(format!("Failed to generate light palette: {}", e)))?;
246
246
+
let dark = generate_counterpart_palette(&dark_inputs, ThemeVariant::Light)
247
247
+
.map_err(|e| ServerFnError::new(format!("Failed to generate dark palette: {}", e)))?;
248
248
+
(light, dark)
249
249
+
}
250
250
+
ThemeVariant::Dark => {
251
251
+
let dark = generate_palette(&dark_inputs, ThemeVariant::Dark)
252
252
+
.map_err(|e| ServerFnError::new(format!("Failed to generate dark palette: {}", e)))?;
253
253
+
let light = generate_counterpart_palette(&light_inputs, ThemeVariant::Dark)
254
254
+
.map_err(|e| ServerFnError::new(format!("Failed to generate light palette: {}", e)))?;
255
255
+
(light, dark)
256
256
+
}
257
257
+
};
258
258
+
259
259
+
let light_code_theme = weaver_renderer::theme::ThemeLightCodeTheme::CodeThemeName(Box::new(input.light_code_theme.into()));
260
260
+
let dark_code_theme = weaver_renderer::theme::ThemeDarkCodeTheme::CodeThemeName(Box::new(input.dark_code_theme.into()));
261
261
+
262
262
+
let resolved = ResolvedTheme {
263
263
+
default: ThemeDefault::Auto,
264
264
+
dark_scheme: dark_scheme.clone(),
265
265
+
light_scheme: light_scheme.clone(),
266
266
+
fonts: default_fonts(),
267
267
+
spacing: default_spacing(),
268
268
+
dark_code_theme,
269
269
+
light_code_theme,
270
270
+
};
271
271
+
272
272
+
let syntax_css = generate_syntax_css(&resolved).await.unwrap_or_default();
273
273
+
274
274
+
let light_palette = ColourPalette {
275
275
+
base: light_scheme.base.to_string(),
276
276
+
surface: light_scheme.surface.to_string(),
277
277
+
overlay: light_scheme.overlay.to_string(),
278
278
+
text: light_scheme.text.to_string(),
279
279
+
muted: light_scheme.muted.to_string(),
280
280
+
subtle: light_scheme.subtle.to_string(),
281
281
+
emphasis: light_scheme.emphasis.to_string(),
282
282
+
primary: light_scheme.primary.to_string(),
283
283
+
secondary: light_scheme.secondary.to_string(),
284
284
+
tertiary: light_scheme.tertiary.to_string(),
285
285
+
error: light_scheme.error.to_string(),
286
286
+
warning: light_scheme.warning.to_string(),
287
287
+
success: light_scheme.success.to_string(),
288
288
+
border: light_scheme.border.to_string(),
289
289
+
link: light_scheme.link.to_string(),
290
290
+
highlight: light_scheme.highlight.to_string(),
291
291
+
};
292
292
+
293
293
+
let dark_palette = ColourPalette {
294
294
+
base: dark_scheme.base.to_string(),
295
295
+
surface: dark_scheme.surface.to_string(),
296
296
+
overlay: dark_scheme.overlay.to_string(),
297
297
+
text: dark_scheme.text.to_string(),
298
298
+
muted: dark_scheme.muted.to_string(),
299
299
+
subtle: dark_scheme.subtle.to_string(),
300
300
+
emphasis: dark_scheme.emphasis.to_string(),
301
301
+
primary: dark_scheme.primary.to_string(),
302
302
+
secondary: dark_scheme.secondary.to_string(),
303
303
+
tertiary: dark_scheme.tertiary.to_string(),
304
304
+
error: dark_scheme.error.to_string(),
305
305
+
warning: dark_scheme.warning.to_string(),
306
306
+
success: dark_scheme.success.to_string(),
307
307
+
border: dark_scheme.border.to_string(),
308
308
+
link: dark_scheme.link.to_string(),
309
309
+
highlight: dark_scheme.highlight.to_string(),
310
310
+
};
311
311
+
312
312
+
Ok(ThemePreviewOutput {
313
313
+
light_palette,
314
314
+
dark_palette,
315
315
+
syntax_css,
316
316
+
})
317
317
+
}
318
318
+
147
319
#[cfg(feature = "server")]
148
320
fn minify_css(css: &str) -> Option<String> {
149
321
use lightningcss::printer::PrinterOptions;
+1
-1
crates/weaver-app/src/components/dialog/dialog.css
···
80
80
81
81
@media (width >= 40rem) {
82
82
.dialog {
83
83
-
max-width: 32rem;
83
83
+
max-width: 56rem;
84
84
text-align: left;
85
85
}
86
86
}
+8
crates/weaver-app/src/components/hex_colour_input.rs
···
15
15
/// Placeholder text.
16
16
#[props(default = "000000".to_string())]
17
17
pub placeholder: String,
18
18
+
/// Callback when input receives focus.
19
19
+
#[props(default)]
20
20
+
pub onfocus: Option<EventHandler<FocusEvent>>,
18
21
}
19
22
20
23
/// A hex colour input with a colour preview swatch.
···
62
65
.to_uppercase();
63
66
props.onchange.call(filtered);
64
67
},
68
68
+
onfocus: move |e| {
69
69
+
if let Some(handler) = &props.onfocus {
70
70
+
handler.call(e);
71
71
+
}
72
72
+
},
65
73
}
66
74
}
67
75
}
+228
-275
crates/weaver-app/src/components/inline_theme_editor.rs
···
5
5
use dioxus::prelude::*;
6
6
use weaver_renderer::themes::{BUILTIN_CODE_THEMES, BUILTIN_COLOUR_SCHEMES};
7
7
8
8
-
use crate::components::collapsible::{Collapsible, CollapsibleContent, CollapsibleTrigger};
9
8
use crate::components::HexColourInput;
9
9
+
use crate::components::collapsible::{Collapsible, CollapsibleContent, CollapsibleTrigger};
10
10
+
use crate::components::theme_preview::ThemePreview;
10
11
use crate::components::toggle_group::{ToggleGroup, ToggleItem};
11
12
12
13
/// Strip leading # or 0x from hex colour strings.
···
103
104
/// Props for InlineThemeEditor.
104
105
#[derive(Props, Clone, PartialEq)]
105
106
pub struct InlineThemeEditorProps {
106
106
-
/// Current theme values.
107
107
-
pub values: InlineThemeValues,
108
108
-
/// Callback when any value changes.
109
109
-
pub onchange: EventHandler<InlineThemeValues>,
107
107
+
/// Current theme values (signal for reactivity).
108
108
+
pub values: Signal<InlineThemeValues>,
109
109
+
/// Control advanced options visibility.
110
110
+
/// None = auto (container query), Some(true) = always show, Some(false) = always hide.
111
111
+
#[props(default)]
112
112
+
pub show_advanced: Option<bool>,
113
113
+
/// Show live preview of theme.
114
114
+
#[props(default = false)]
115
115
+
pub show_preview: bool,
110
116
}
111
117
112
118
/// Inline theme editor with core colour pickers and code theme selector.
113
119
#[component]
114
120
pub fn InlineThemeEditor(props: InlineThemeEditorProps) -> Element {
115
115
-
let values = props.values.clone();
121
121
+
let mut values = props.values;
122
122
+
123
123
+
let advanced_class = match props.show_advanced {
124
124
+
Some(true) => "inline-theme-editor-advanced force-show",
125
125
+
Some(false) => "inline-theme-editor-advanced force-hide",
126
126
+
None => "inline-theme-editor-advanced",
127
127
+
};
116
128
117
129
rsx! {
118
130
document::Stylesheet { href: INLINE_THEME_EDITOR_CSS }
···
120
132
div { class: "inline-theme-editor",
121
133
h4 { class: "inline-theme-editor-heading", "Theme" }
122
134
123
123
-
// Preset selector.
124
124
-
div { class: "inline-theme-editor-presets",
125
125
-
label { "Start from preset:" }
126
126
-
select {
127
127
-
onchange: {
128
128
-
let onchange = props.onchange.clone();
129
129
-
let values = props.values.clone();
130
130
-
move |e: Event<FormData>| {
131
131
-
let preset_id = e.value();
132
132
-
if let Some(scheme) = BUILTIN_COLOUR_SCHEMES.iter().find(|s| s.id == preset_id) {
133
133
-
onchange.call(InlineThemeValues {
134
134
-
background: strip_hex_prefix(&scheme.colours.base),
135
135
-
text: strip_hex_prefix(&scheme.colours.text),
136
136
-
primary: strip_hex_prefix(&scheme.colours.primary),
137
137
-
link: strip_hex_prefix(&scheme.colours.link),
138
138
-
light_code_theme: values.light_code_theme.clone(),
139
139
-
dark_code_theme: values.dark_code_theme.clone(),
140
140
-
default_mode: scheme.variant.to_string(),
141
141
-
..Default::default()
142
142
-
});
143
143
-
}
135
135
+
// Main theme controls - two columns when wide.
136
136
+
div { class: "inline-theme-editor-main",
137
137
+
// Left column: colours and mode toggle.
138
138
+
div { class: "inline-theme-editor-main-left",
139
139
+
// Colour pickers (2x2 grid).
140
140
+
div { class: "inline-theme-editor-colours",
141
141
+
HexColourInput {
142
142
+
label: Some("Background".to_string()),
143
143
+
value: values().background.clone(),
144
144
+
onchange: move |val| values.write().background = val,
144
145
}
145
145
-
},
146
146
-
option { value: "", "Custom" }
147
147
-
for scheme in BUILTIN_COLOUR_SCHEMES.iter() {
148
148
-
option {
149
149
-
value: "{scheme.id}",
150
150
-
"{scheme.name}"
146
146
+
HexColourInput {
147
147
+
label: Some("Text".to_string()),
148
148
+
value: values().text.clone(),
149
149
+
onchange: move |val| values.write().text = val,
151
150
}
152
152
-
}
153
153
-
}
154
154
-
}
155
155
-
156
156
-
// Colour pickers.
157
157
-
div { class: "inline-theme-editor-colours",
158
158
-
HexColourInput {
159
159
-
label: Some("Background".to_string()),
160
160
-
value: values.background.clone(),
161
161
-
onchange: {
162
162
-
let values = props.values.clone();
163
163
-
let onchange = props.onchange.clone();
164
164
-
move |val| {
165
165
-
let mut v = values.clone();
166
166
-
v.background = val;
167
167
-
onchange.call(v);
168
168
-
}
169
169
-
},
170
170
-
}
171
171
-
HexColourInput {
172
172
-
label: Some("Text".to_string()),
173
173
-
value: values.text.clone(),
174
174
-
onchange: {
175
175
-
let values = props.values.clone();
176
176
-
let onchange = props.onchange.clone();
177
177
-
move |val| {
178
178
-
let mut v = values.clone();
179
179
-
v.text = val;
180
180
-
onchange.call(v);
151
151
+
HexColourInput {
152
152
+
label: Some("Primary".to_string()),
153
153
+
value: values().primary.clone(),
154
154
+
onchange: move |val| values.write().primary = val,
181
155
}
182
182
-
},
183
183
-
}
184
184
-
HexColourInput {
185
185
-
label: Some("Primary".to_string()),
186
186
-
value: values.primary.clone(),
187
187
-
onchange: {
188
188
-
let values = props.values.clone();
189
189
-
let onchange = props.onchange.clone();
190
190
-
move |val| {
191
191
-
let mut v = values.clone();
192
192
-
v.primary = val;
193
193
-
onchange.call(v);
156
156
+
HexColourInput {
157
157
+
label: Some("Link".to_string()),
158
158
+
value: values().link.clone(),
159
159
+
onchange: move |val| values.write().link = val,
194
160
}
195
195
-
},
196
196
-
}
197
197
-
HexColourInput {
198
198
-
label: Some("Link".to_string()),
199
199
-
value: values.link.clone(),
200
200
-
onchange: {
201
201
-
let values = props.values.clone();
202
202
-
let onchange = props.onchange.clone();
203
203
-
move |val| {
204
204
-
let mut v = values.clone();
205
205
-
v.link = val;
206
206
-
onchange.call(v);
207
207
-
}
208
208
-
},
209
209
-
}
210
210
-
}
161
161
+
}
211
162
212
212
-
// Code theme selectors (light and dark).
213
213
-
div { class: "inline-theme-editor-code-theme",
214
214
-
label { "Light code theme:" }
215
215
-
select {
216
216
-
value: "{values.light_code_theme}",
217
217
-
onchange: {
218
218
-
let values = props.values.clone();
219
219
-
let onchange = props.onchange.clone();
220
220
-
move |e: Event<FormData>| {
221
221
-
let mut v = values.clone();
222
222
-
v.light_code_theme = e.value();
223
223
-
onchange.call(v);
224
224
-
}
225
225
-
},
226
226
-
for theme in BUILTIN_CODE_THEMES.iter().filter(|t| t.variant == "light") {
227
227
-
option {
228
228
-
value: "{theme.id}",
229
229
-
"{theme.name}"
163
163
+
// Default mode toggle group.
164
164
+
div { class: "inline-theme-editor-mode",
165
165
+
label { "Default mode:" }
166
166
+
ToggleGroup {
167
167
+
horizontal: true,
168
168
+
default_pressed: mode_to_index(&values().default_mode),
169
169
+
on_pressed_change: move |pressed: std::collections::HashSet<usize>| {
170
170
+
if let Some(&idx) = pressed.iter().next() {
171
171
+
values.write().default_mode = index_to_mode(idx).to_string();
172
172
+
}
173
173
+
},
174
174
+
ToggleItem { index: 0usize, "Auto" }
175
175
+
ToggleItem { index: 1usize, "Light" }
176
176
+
ToggleItem { index: 2usize, "Dark" }
230
177
}
231
178
}
232
179
}
233
233
-
}
234
234
-
div { class: "inline-theme-editor-code-theme",
235
235
-
label { "Dark code theme:" }
236
236
-
select {
237
237
-
value: "{values.dark_code_theme}",
238
238
-
onchange: {
239
239
-
let values = props.values.clone();
240
240
-
let onchange = props.onchange.clone();
241
241
-
move |e: Event<FormData>| {
242
242
-
let mut v = values.clone();
243
243
-
v.dark_code_theme = e.value();
244
244
-
onchange.call(v);
245
245
-
}
246
246
-
},
247
247
-
for theme in BUILTIN_CODE_THEMES.iter().filter(|t| t.variant == "dark") {
248
248
-
option {
249
249
-
value: "{theme.id}",
250
250
-
"{theme.name}"
180
180
+
181
181
+
// Right column: preset and code theme dropdowns.
182
182
+
div { class: "inline-theme-editor-main-right",
183
183
+
// Preset selector.
184
184
+
div { class: "inline-theme-editor-presets",
185
185
+
label { "Preset:" }
186
186
+
select {
187
187
+
onchange: move |e: Event<FormData>| {
188
188
+
let preset_id = e.value();
189
189
+
if let Some(scheme) = BUILTIN_COLOUR_SCHEMES.iter().find(|s| s.id == preset_id) {
190
190
+
let mut v = values.write();
191
191
+
v.background = strip_hex_prefix(&scheme.colours.base);
192
192
+
v.text = strip_hex_prefix(&scheme.colours.text);
193
193
+
v.primary = strip_hex_prefix(&scheme.colours.primary);
194
194
+
v.link = strip_hex_prefix(&scheme.colours.link);
195
195
+
v.default_mode = scheme.variant.to_string();
196
196
+
// Clear overrides when selecting preset.
197
197
+
v.light_background = None;
198
198
+
v.light_text = None;
199
199
+
v.light_primary = None;
200
200
+
v.light_link = None;
201
201
+
v.dark_background = None;
202
202
+
v.dark_text = None;
203
203
+
v.dark_primary = None;
204
204
+
v.dark_link = None;
205
205
+
}
206
206
+
},
207
207
+
option { value: "", "Custom" }
208
208
+
for scheme in BUILTIN_COLOUR_SCHEMES.iter() {
209
209
+
option {
210
210
+
value: "{scheme.id}",
211
211
+
"{scheme.name}"
212
212
+
}
213
213
+
}
251
214
}
252
215
}
253
253
-
}
254
254
-
}
255
216
256
256
-
// Default mode toggle group.
257
257
-
// Indexes: 0 = auto, 1 = light, 2 = dark
258
258
-
div { class: "inline-theme-editor-mode",
259
259
-
label { "Default mode:" }
260
260
-
ToggleGroup {
261
261
-
horizontal: true,
262
262
-
default_pressed: mode_to_index(&values.default_mode),
263
263
-
on_pressed_change: {
264
264
-
let values = props.values.clone();
265
265
-
let onchange = props.onchange.clone();
266
266
-
move |pressed: std::collections::HashSet<usize>| {
267
267
-
if let Some(&idx) = pressed.iter().next() {
268
268
-
let mut v = values.clone();
269
269
-
v.default_mode = index_to_mode(idx).to_string();
270
270
-
onchange.call(v);
217
217
+
// Code theme selectors.
218
218
+
div { class: "inline-theme-editor-code-theme",
219
219
+
label { "Light code theme:" }
220
220
+
select {
221
221
+
value: "{values().light_code_theme}",
222
222
+
onchange: move |e: Event<FormData>| {
223
223
+
values.write().light_code_theme = e.value();
224
224
+
},
225
225
+
for theme in BUILTIN_CODE_THEMES.iter().filter(|t| t.variant == "light") {
226
226
+
option {
227
227
+
value: "{theme.id}",
228
228
+
"{theme.name}"
229
229
+
}
271
230
}
272
231
}
273
273
-
},
274
274
-
ToggleItem { index: 0usize, "Auto" }
275
275
-
ToggleItem { index: 1usize, "Light" }
276
276
-
ToggleItem { index: 2usize, "Dark" }
232
232
+
}
233
233
+
div { class: "inline-theme-editor-code-theme",
234
234
+
label { "Dark code theme:" }
235
235
+
select {
236
236
+
value: "{values().dark_code_theme}",
237
237
+
onchange: move |e: Event<FormData>| {
238
238
+
values.write().dark_code_theme = e.value();
239
239
+
},
240
240
+
for theme in BUILTIN_CODE_THEMES.iter().filter(|t| t.variant == "dark") {
241
241
+
option {
242
242
+
value: "{theme.id}",
243
243
+
"{theme.name}"
244
244
+
}
245
245
+
}
246
246
+
}
247
247
+
}
277
248
}
278
249
}
279
250
280
251
// Advanced section for per-variant colour customization.
281
281
-
Collapsible {
282
282
-
CollapsibleTrigger { "Advanced colour options" }
283
283
-
CollapsibleContent {
284
284
-
div { class: "inline-theme-editor-advanced-content",
285
285
-
// Light variant colours.
286
286
-
div { class: "inline-theme-editor-variant",
287
287
-
h5 { "Light variant" }
288
288
-
div { class: "inline-theme-editor-colours",
289
289
-
HexColourInput {
290
290
-
label: Some("Background".to_string()),
291
291
-
value: values.light_background.clone().unwrap_or_default(),
292
292
-
placeholder: values.background.clone(),
293
293
-
onchange: {
294
294
-
let values = props.values.clone();
295
295
-
let onchange = props.onchange.clone();
296
296
-
move |val: String| {
297
297
-
let mut v = values.clone();
298
298
-
v.light_background = if val.is_empty() { None } else { Some(val) };
299
299
-
onchange.call(v);
300
300
-
}
301
301
-
},
302
302
-
}
303
303
-
HexColourInput {
304
304
-
label: Some("Text".to_string()),
305
305
-
value: values.light_text.clone().unwrap_or_default(),
306
306
-
placeholder: values.text.clone(),
307
307
-
onchange: {
308
308
-
let values = props.values.clone();
309
309
-
let onchange = props.onchange.clone();
310
310
-
move |val: String| {
311
311
-
let mut v = values.clone();
312
312
-
v.light_text = if val.is_empty() { None } else { Some(val) };
313
313
-
onchange.call(v);
314
314
-
}
315
315
-
},
316
316
-
}
317
317
-
HexColourInput {
318
318
-
label: Some("Primary".to_string()),
319
319
-
value: values.light_primary.clone().unwrap_or_default(),
320
320
-
placeholder: values.primary.clone(),
321
321
-
onchange: {
322
322
-
let values = props.values.clone();
323
323
-
let onchange = props.onchange.clone();
324
324
-
move |val: String| {
325
325
-
let mut v = values.clone();
326
326
-
v.light_primary = if val.is_empty() { None } else { Some(val) };
327
327
-
onchange.call(v);
328
328
-
}
329
329
-
},
330
330
-
}
331
331
-
HexColourInput {
332
332
-
label: Some("Link".to_string()),
333
333
-
value: values.light_link.clone().unwrap_or_default(),
334
334
-
placeholder: values.link.clone(),
335
335
-
onchange: {
336
336
-
let values = props.values.clone();
337
337
-
let onchange = props.onchange.clone();
338
338
-
move |val: String| {
339
339
-
let mut v = values.clone();
340
340
-
v.light_link = if val.is_empty() { None } else { Some(val) };
341
341
-
onchange.call(v);
342
342
-
}
343
343
-
},
252
252
+
div { class: "{advanced_class}",
253
253
+
Collapsible {
254
254
+
CollapsibleTrigger { "Advanced colour options" }
255
255
+
CollapsibleContent {
256
256
+
div { class: "inline-theme-editor-advanced-content",
257
257
+
// Light variant colours.
258
258
+
div { class: "inline-theme-editor-variant",
259
259
+
h5 { "Light variant" }
260
260
+
div { class: "inline-theme-editor-colours",
261
261
+
HexColourInput {
262
262
+
label: Some("Background".to_string()),
263
263
+
value: values().light_background.clone().unwrap_or_default(),
264
264
+
placeholder: values().background.clone(),
265
265
+
onchange: move |val: String| {
266
266
+
values.write().light_background = if val.is_empty() { None } else { Some(val) };
267
267
+
},
268
268
+
}
269
269
+
HexColourInput {
270
270
+
label: Some("Text".to_string()),
271
271
+
value: values().light_text.clone().unwrap_or_default(),
272
272
+
placeholder: values().text.clone(),
273
273
+
onchange: move |val: String| {
274
274
+
values.write().light_text = if val.is_empty() { None } else { Some(val) };
275
275
+
},
276
276
+
}
277
277
+
HexColourInput {
278
278
+
label: Some("Primary".to_string()),
279
279
+
value: values().light_primary.clone().unwrap_or_default(),
280
280
+
placeholder: values().primary.clone(),
281
281
+
onchange: move |val: String| {
282
282
+
values.write().light_primary = if val.is_empty() { None } else { Some(val) };
283
283
+
},
284
284
+
}
285
285
+
HexColourInput {
286
286
+
label: Some("Link".to_string()),
287
287
+
value: values().light_link.clone().unwrap_or_default(),
288
288
+
placeholder: values().link.clone(),
289
289
+
onchange: move |val: String| {
290
290
+
values.write().light_link = if val.is_empty() { None } else { Some(val) };
291
291
+
},
292
292
+
}
344
293
}
345
294
}
346
346
-
}
347
295
348
348
-
// Dark variant colours.
349
349
-
div { class: "inline-theme-editor-variant",
350
350
-
h5 { "Dark variant" }
351
351
-
div { class: "inline-theme-editor-colours",
352
352
-
HexColourInput {
353
353
-
label: Some("Background".to_string()),
354
354
-
value: values.dark_background.clone().unwrap_or_default(),
355
355
-
placeholder: values.background.clone(),
356
356
-
onchange: {
357
357
-
let values = props.values.clone();
358
358
-
let onchange = props.onchange.clone();
359
359
-
move |val: String| {
360
360
-
let mut v = values.clone();
361
361
-
v.dark_background = if val.is_empty() { None } else { Some(val) };
362
362
-
onchange.call(v);
363
363
-
}
364
364
-
},
365
365
-
}
366
366
-
HexColourInput {
367
367
-
label: Some("Text".to_string()),
368
368
-
value: values.dark_text.clone().unwrap_or_default(),
369
369
-
placeholder: values.text.clone(),
370
370
-
onchange: {
371
371
-
let values = props.values.clone();
372
372
-
let onchange = props.onchange.clone();
373
373
-
move |val: String| {
374
374
-
let mut v = values.clone();
375
375
-
v.dark_text = if val.is_empty() { None } else { Some(val) };
376
376
-
onchange.call(v);
377
377
-
}
378
378
-
},
379
379
-
}
380
380
-
HexColourInput {
381
381
-
label: Some("Primary".to_string()),
382
382
-
value: values.dark_primary.clone().unwrap_or_default(),
383
383
-
placeholder: values.primary.clone(),
384
384
-
onchange: {
385
385
-
let values = props.values.clone();
386
386
-
let onchange = props.onchange.clone();
387
387
-
move |val: String| {
388
388
-
let mut v = values.clone();
389
389
-
v.dark_primary = if val.is_empty() { None } else { Some(val) };
390
390
-
onchange.call(v);
391
391
-
}
392
392
-
},
393
393
-
}
394
394
-
HexColourInput {
395
395
-
label: Some("Link".to_string()),
396
396
-
value: values.dark_link.clone().unwrap_or_default(),
397
397
-
placeholder: values.link.clone(),
398
398
-
onchange: {
399
399
-
let values = props.values.clone();
400
400
-
let onchange = props.onchange.clone();
401
401
-
move |val: String| {
402
402
-
let mut v = values.clone();
403
403
-
v.dark_link = if val.is_empty() { None } else { Some(val) };
404
404
-
onchange.call(v);
405
405
-
}
406
406
-
},
296
296
+
// Dark variant colours.
297
297
+
div { class: "inline-theme-editor-variant",
298
298
+
h5 { "Dark variant" }
299
299
+
div { class: "inline-theme-editor-colours",
300
300
+
HexColourInput {
301
301
+
label: Some("Background".to_string()),
302
302
+
value: values().dark_background.clone().unwrap_or_default(),
303
303
+
placeholder: values().background.clone(),
304
304
+
onchange: move |val: String| {
305
305
+
values.write().dark_background = if val.is_empty() { None } else { Some(val) };
306
306
+
},
307
307
+
}
308
308
+
HexColourInput {
309
309
+
label: Some("Text".to_string()),
310
310
+
value: values().dark_text.clone().unwrap_or_default(),
311
311
+
placeholder: values().text.clone(),
312
312
+
onchange: move |val: String| {
313
313
+
values.write().dark_text = if val.is_empty() { None } else { Some(val) };
314
314
+
},
315
315
+
}
316
316
+
HexColourInput {
317
317
+
label: Some("Primary".to_string()),
318
318
+
value: values().dark_primary.clone().unwrap_or_default(),
319
319
+
placeholder: values().primary.clone(),
320
320
+
onchange: move |val: String| {
321
321
+
values.write().dark_primary = if val.is_empty() { None } else { Some(val) };
322
322
+
},
323
323
+
}
324
324
+
HexColourInput {
325
325
+
label: Some("Link".to_string()),
326
326
+
value: values().dark_link.clone().unwrap_or_default(),
327
327
+
placeholder: values().link.clone(),
328
328
+
onchange: move |val: String| {
329
329
+
values.write().dark_link = if val.is_empty() { None } else { Some(val) };
330
330
+
},
331
331
+
}
407
332
}
408
333
}
409
334
}
···
411
336
}
412
337
}
413
338
414
414
-
// Link to full editor.
415
415
-
// TODO: Enable once theme page exists
416
416
-
// a { href: "/{ident}/themes", class: "inline-theme-editor-full-link",
417
417
-
// "Edit full theme ->"
418
418
-
// }
339
339
+
// Live preview section.
340
340
+
if props.show_preview {
341
341
+
PreviewSection { values }
342
342
+
}
343
343
+
}
344
344
+
}
345
345
+
}
346
346
+
347
347
+
/// Preview section with light/dark toggle.
348
348
+
#[component]
349
349
+
fn PreviewSection(values: Signal<InlineThemeValues>) -> Element {
350
350
+
let mut preview_dark = use_signal(|| false);
351
351
+
352
352
+
rsx! {
353
353
+
div { class: "inline-theme-editor-preview",
354
354
+
div { class: "inline-theme-editor-preview-header",
355
355
+
h5 { "Preview" }
356
356
+
ToggleGroup {
357
357
+
horizontal: true,
358
358
+
default_pressed: std::collections::HashSet::from([0usize]),
359
359
+
on_pressed_change: move |pressed: std::collections::HashSet<usize>| {
360
360
+
if let Some(&idx) = pressed.iter().next() {
361
361
+
preview_dark.set(idx == 1);
362
362
+
}
363
363
+
},
364
364
+
ToggleItem { index: 0usize, "Light" }
365
365
+
ToggleItem { index: 1usize, "Dark" }
366
366
+
}
367
367
+
}
368
368
+
ThemePreview {
369
369
+
values,
370
370
+
dark: preview_dark(),
371
371
+
}
419
372
}
420
373
}
421
374
}
+7
-1
crates/weaver-app/src/components/mod.rs
···
9
9
};
10
10
11
11
pub mod css;
12
12
-
pub use css::NotebookCss;
12
12
+
pub use css::{ColourPalette, NotebookCss, ThemePreviewInput, ThemePreviewOutput, generate_theme_preview};
13
13
14
14
mod entry;
15
15
#[allow(unused_imports)]
···
366
366
mod inline_theme_editor;
367
367
pub use inline_theme_editor::{InlineThemeEditor, InlineThemeValues};
368
368
369
369
+
mod theme_preview;
370
370
+
pub use theme_preview::ThemePreview;
371
371
+
372
372
+
mod theme_editor;
373
373
+
pub use theme_editor::{ThemeEditor, ThemeEditorValues, ColourSchemeValues};
374
374
+
369
375
mod notebook_editor;
370
376
pub use notebook_editor::{NotebookEditor, NotebookEditorMode, NotebookFormState};
371
377
+174
-79
crates/weaver-app/src/components/notebook_editor.rs
···
25
25
pub tags: Vec<String>,
26
26
pub tags_input: String,
27
27
pub content_warnings: Vec<String>,
28
28
+
pub content_warnings_input: String,
28
29
pub rating: Option<String>,
29
30
pub theme: InlineThemeValues,
30
31
}
···
52
53
.as_ref()
53
54
.map(|cw| cw.iter().map(|s| s.to_string()).collect())
54
55
.unwrap_or_default(),
56
56
+
content_warnings_input: String::new(),
55
57
rating: book.rating.as_ref().map(|r| r.to_string()),
56
58
theme: theme.unwrap_or_default(),
57
59
}
···
76
78
/// Error message to display.
77
79
#[props(default)]
78
80
pub error: Option<String>,
81
81
+
/// Control advanced theme options visibility.
82
82
+
/// None = auto (container query), Some(true) = always show, Some(false) = always hide.
83
83
+
#[props(default)]
84
84
+
pub show_advanced_theme: Option<bool>,
85
85
+
/// Show content settings (content warnings, rating).
86
86
+
#[props(default = false)]
87
87
+
pub show_content_settings: bool,
79
88
}
80
89
81
90
/// Notebook create/edit form.
···
83
92
pub fn NotebookEditor(props: NotebookEditorProps) -> Element {
84
93
let mut state = use_signal(|| props.initial_state.clone().unwrap_or_default());
85
94
95
95
+
// Separate signal for theme editor (InlineThemeEditor writes directly to this).
96
96
+
let theme_values = use_signal(|| state.read().theme.clone());
97
97
+
86
98
let save_label = match props.mode {
87
99
NotebookEditorMode::Create => "Create",
88
100
NotebookEditorMode::Edit => "Save",
···
108
120
109
121
div { class: "notebook-editor",
110
122
div { class: "notebook-editor-form",
111
111
-
// Title field.
112
112
-
div { class: "notebook-editor-field",
113
113
-
label { "Title" }
114
114
-
input {
115
115
-
r#type: "text",
116
116
-
value: "{state.read().title}",
117
117
-
placeholder: "My Notebook",
118
118
-
oninput: move |e| {
119
119
-
state.write().title = e.value();
120
120
-
},
121
121
-
}
122
122
-
}
123
123
-
124
124
-
// Path field.
125
125
-
div { class: "notebook-editor-field",
126
126
-
label { "Path" }
127
127
-
input {
128
128
-
r#type: "text",
129
129
-
value: "{auto_path}",
130
130
-
placeholder: "my-notebook",
131
131
-
oninput: move |e| {
132
132
-
state.write().path = e.value();
133
133
-
},
134
134
-
}
135
135
-
span { class: "notebook-editor-hint",
136
136
-
"URL-friendly identifier. Auto-generated from title if empty."
137
137
-
}
138
138
-
}
123
123
+
// Top section - two columns when wide.
124
124
+
div { class: "notebook-editor-top",
125
125
+
// Left column: title, path.
126
126
+
div { class: "notebook-editor-top-left",
127
127
+
// Title field.
128
128
+
div { class: "notebook-editor-field",
129
129
+
label { "Title" }
130
130
+
input {
131
131
+
r#type: "text",
132
132
+
value: "{state.read().title}",
133
133
+
placeholder: "My Notebook",
134
134
+
oninput: move |e| {
135
135
+
state.write().title = e.value();
136
136
+
},
137
137
+
}
138
138
+
}
139
139
140
140
-
// Publish global toggle.
141
141
-
div { class: "notebook-editor-field notebook-editor-toggle",
142
142
-
label {
143
143
-
input {
144
144
-
r#type: "checkbox",
145
145
-
checked: state.read().publish_global,
146
146
-
onchange: move |e| {
147
147
-
state.write().publish_global = e.checked();
148
148
-
},
140
140
+
// Path field.
141
141
+
div { class: "notebook-editor-field",
142
142
+
label { "Path" }
143
143
+
input {
144
144
+
r#type: "text",
145
145
+
value: "{auto_path}",
146
146
+
placeholder: "my-notebook",
147
147
+
oninput: move |e| {
148
148
+
state.write().path = e.value();
149
149
+
},
150
150
+
}
151
151
+
span { class: "notebook-editor-hint",
152
152
+
"URL-friendly identifier. Auto-generated from title if empty."
153
153
+
}
149
154
}
150
150
-
" Publish globally"
151
151
-
}
152
152
-
span { class: "notebook-editor-hint",
153
153
-
"Enable site.standard.* records for cross-platform discovery."
154
155
}
155
155
-
}
156
156
157
157
-
// Tags field.
158
158
-
div { class: "notebook-editor-field",
159
159
-
label { "Tags" }
160
160
-
div { class: "notebook-editor-tags",
161
161
-
for (i, tag) in state.read().tags.iter().enumerate() {
162
162
-
span {
163
163
-
key: "{i}",
164
164
-
class: "notebook-editor-tag",
165
165
-
"{tag}"
166
166
-
button {
167
167
-
class: "notebook-editor-tag-remove",
168
168
-
onclick: move |_| {
169
169
-
state.write().tags.remove(i);
157
157
+
// Right column: publish globally, tags.
158
158
+
div { class: "notebook-editor-top-right",
159
159
+
// Publish global toggle.
160
160
+
div { class: "notebook-editor-field notebook-editor-toggle",
161
161
+
label {
162
162
+
input {
163
163
+
r#type: "checkbox",
164
164
+
checked: state.read().publish_global,
165
165
+
onchange: move |e| {
166
166
+
state.write().publish_global = e.checked();
170
167
},
171
171
-
"ร"
172
168
}
169
169
+
" Publish globally"
170
170
+
}
171
171
+
span { class: "notebook-editor-hint",
172
172
+
"Enable site.standard.* records for cross-platform discovery."
173
173
}
174
174
}
175
175
-
input {
176
176
-
r#type: "text",
177
177
-
class: "notebook-editor-tags-input",
178
178
-
value: "{state.read().tags_input}",
179
179
-
placeholder: "Add tag...",
180
180
-
oninput: move |e| {
181
181
-
state.write().tags_input = e.value();
182
182
-
},
183
183
-
onkeydown: move |e| {
184
184
-
if e.key() == Key::Enter || e.key() == Key::Character(",".to_string()) {
185
185
-
e.prevent_default();
186
186
-
let tag = state.read().tags_input.trim().to_string();
187
187
-
if !tag.is_empty() {
188
188
-
let mut s = state.write();
189
189
-
if !s.tags.contains(&tag) {
190
190
-
s.tags.push(tag);
175
175
+
176
176
+
// Tags field.
177
177
+
div { class: "notebook-editor-field",
178
178
+
label { "Tags" }
179
179
+
div { class: "notebook-editor-tags",
180
180
+
for (i, tag) in state.read().tags.iter().enumerate() {
181
181
+
span {
182
182
+
key: "{i}",
183
183
+
class: "notebook-editor-tag",
184
184
+
"{tag}"
185
185
+
button {
186
186
+
class: "notebook-editor-tag-remove",
187
187
+
onclick: move |_| {
188
188
+
state.write().tags.remove(i);
189
189
+
},
190
190
+
"ร"
191
191
}
192
192
-
s.tags_input.clear();
193
192
}
194
193
}
195
195
-
},
194
194
+
input {
195
195
+
r#type: "text",
196
196
+
class: "notebook-editor-tags-input",
197
197
+
value: "{state.read().tags_input}",
198
198
+
placeholder: "Add tag...",
199
199
+
oninput: move |e| {
200
200
+
state.write().tags_input = e.value();
201
201
+
},
202
202
+
onkeydown: move |e| {
203
203
+
if e.key() == Key::Enter || e.key() == Key::Character(",".to_string()) {
204
204
+
e.prevent_default();
205
205
+
let tag = state.read().tags_input.trim().to_string();
206
206
+
if !tag.is_empty() {
207
207
+
let mut s = state.write();
208
208
+
if !s.tags.contains(&tag) {
209
209
+
s.tags.push(tag);
210
210
+
}
211
211
+
s.tags_input.clear();
212
212
+
}
213
213
+
}
214
214
+
},
215
215
+
}
216
216
+
}
196
217
}
197
218
}
198
219
}
199
220
200
221
// Theme editor.
201
222
InlineThemeEditor {
202
202
-
values: state.read().theme.clone(),
203
203
-
onchange: move |v| {
204
204
-
state.write().theme = v;
205
205
-
},
223
223
+
values: theme_values,
224
224
+
show_advanced: props.show_advanced_theme,
225
225
+
show_preview: true,
226
226
+
}
227
227
+
228
228
+
// Content settings (optional).
229
229
+
if props.show_content_settings {
230
230
+
div { class: "notebook-editor-content-settings",
231
231
+
h4 { "Content Settings" }
232
232
+
233
233
+
// Content warnings field.
234
234
+
div { class: "notebook-editor-field",
235
235
+
label { "Content Warnings" }
236
236
+
div { class: "notebook-editor-tags",
237
237
+
for (i, warning) in state.read().content_warnings.iter().enumerate() {
238
238
+
span {
239
239
+
key: "{i}",
240
240
+
class: "notebook-editor-tag notebook-editor-warning",
241
241
+
"{warning}"
242
242
+
button {
243
243
+
class: "notebook-editor-tag-remove",
244
244
+
onclick: move |_| {
245
245
+
state.write().content_warnings.remove(i);
246
246
+
},
247
247
+
"ร"
248
248
+
}
249
249
+
}
250
250
+
}
251
251
+
input {
252
252
+
r#type: "text",
253
253
+
class: "notebook-editor-tags-input",
254
254
+
value: "{state.read().content_warnings_input}",
255
255
+
placeholder: "Add warning...",
256
256
+
oninput: move |e| {
257
257
+
state.write().content_warnings_input = e.value();
258
258
+
},
259
259
+
onkeydown: move |e| {
260
260
+
if e.key() == Key::Enter || e.key() == Key::Character(",".to_string()) {
261
261
+
e.prevent_default();
262
262
+
let warning = state.read().content_warnings_input.trim().to_string();
263
263
+
if !warning.is_empty() {
264
264
+
let mut s = state.write();
265
265
+
if !s.content_warnings.contains(&warning) {
266
266
+
s.content_warnings.push(warning);
267
267
+
}
268
268
+
s.content_warnings_input.clear();
269
269
+
}
270
270
+
}
271
271
+
},
272
272
+
}
273
273
+
}
274
274
+
span { class: "notebook-editor-hint",
275
275
+
"Add content warnings for sensitive material (e.g., violence, adult themes)."
276
276
+
}
277
277
+
}
278
278
+
279
279
+
// Rating field.
280
280
+
div { class: "notebook-editor-field",
281
281
+
label { "Content Rating" }
282
282
+
select {
283
283
+
value: state.read().rating.clone().unwrap_or_default(),
284
284
+
onchange: move |e: Event<FormData>| {
285
285
+
let val = e.value();
286
286
+
state.write().rating = if val.is_empty() { None } else { Some(val) };
287
287
+
},
288
288
+
option { value: "", "None" }
289
289
+
option { value: "general", "General" }
290
290
+
option { value: "teen", "Teen" }
291
291
+
option { value: "mature", "Mature" }
292
292
+
option { value: "adult", "Adult" }
293
293
+
}
294
294
+
span { class: "notebook-editor-hint",
295
295
+
"Age-appropriateness rating for your notebook's content."
296
296
+
}
297
297
+
}
298
298
+
}
206
299
}
207
300
208
301
// Error display.
···
215
308
Button {
216
309
variant: ButtonVariant::Primary,
217
310
onclick: move |_| {
218
218
-
props.on_save.call(state.read().clone());
311
311
+
let mut form_state = state.read().clone();
312
312
+
form_state.theme = theme_values();
313
313
+
props.on_save.call(form_state);
219
314
},
220
315
disabled: !can_save || props.saving,
221
316
if props.saving { "Saving..." } else { "{save_label}" }
+608
crates/weaver-app/src/components/theme_editor.rs
···
1
1
+
//! Full theme editor component with all 16 colours, fonts, spacing.
2
2
+
3
3
+
use dioxus::prelude::*;
4
4
+
use weaver_renderer::themes::{BUILTIN_CODE_THEMES, BUILTIN_COLOUR_SCHEMES};
5
5
+
6
6
+
use crate::components::HexColourInput;
7
7
+
use crate::components::button::{Button, ButtonVariant};
8
8
+
use crate::components::inline_theme_editor::InlineThemeValues;
9
9
+
use crate::components::select::{Select, SelectList, SelectOption, SelectTrigger, SelectValue};
10
10
+
use crate::components::theme_preview::ThemePreview;
11
11
+
use crate::components::toggle_group::{ToggleGroup, ToggleItem};
12
12
+
13
13
+
const THEME_EDITOR_CSS: Asset = asset!("/assets/styling/theme-editor.css");
14
14
+
15
15
+
/// Full theme values for the theme editor.
16
16
+
#[derive(Debug, Clone, PartialEq, Default)]
17
17
+
pub struct ThemeEditorValues {
18
18
+
// Light scheme colours (all 16).
19
19
+
pub light: ColourSchemeValues,
20
20
+
// Dark scheme colours (all 16).
21
21
+
pub dark: ColourSchemeValues,
22
22
+
// Fonts.
23
23
+
pub font_body: String,
24
24
+
pub font_heading: String,
25
25
+
pub font_mono: String,
26
26
+
// Spacing.
27
27
+
pub spacing_base: String,
28
28
+
pub spacing_line_height: String,
29
29
+
pub spacing_scale: String,
30
30
+
// Code themes.
31
31
+
pub light_code_theme: String,
32
32
+
pub dark_code_theme: String,
33
33
+
// Default mode.
34
34
+
pub default_mode: String,
35
35
+
}
36
36
+
37
37
+
/// All 16 colour values for a single scheme.
38
38
+
#[derive(Debug, Clone, PartialEq, Default)]
39
39
+
pub struct ColourSchemeValues {
40
40
+
pub base: String,
41
41
+
pub surface: String,
42
42
+
pub overlay: String,
43
43
+
pub text: String,
44
44
+
pub muted: String,
45
45
+
pub subtle: String,
46
46
+
pub emphasis: String,
47
47
+
pub primary: String,
48
48
+
pub secondary: String,
49
49
+
pub tertiary: String,
50
50
+
pub error: String,
51
51
+
pub warning: String,
52
52
+
pub success: String,
53
53
+
pub border: String,
54
54
+
pub link: String,
55
55
+
pub highlight: String,
56
56
+
}
57
57
+
58
58
+
impl ThemeEditorValues {
59
59
+
/// Convert to InlineThemeValues for preview (uses primary 4 colours from light scheme).
60
60
+
pub fn to_inline_values(&self) -> InlineThemeValues {
61
61
+
InlineThemeValues {
62
62
+
background: self.light.base.clone(),
63
63
+
text: self.light.text.clone(),
64
64
+
primary: self.light.primary.clone(),
65
65
+
link: self.light.link.clone(),
66
66
+
light_background: Some(self.light.base.clone()),
67
67
+
light_text: Some(self.light.text.clone()),
68
68
+
light_primary: Some(self.light.primary.clone()),
69
69
+
light_link: Some(self.light.link.clone()),
70
70
+
dark_background: Some(self.dark.base.clone()),
71
71
+
dark_text: Some(self.dark.text.clone()),
72
72
+
dark_primary: Some(self.dark.primary.clone()),
73
73
+
dark_link: Some(self.dark.link.clone()),
74
74
+
light_code_theme: self.light_code_theme.clone(),
75
75
+
dark_code_theme: self.dark_code_theme.clone(),
76
76
+
default_mode: self.default_mode.clone(),
77
77
+
}
78
78
+
}
79
79
+
80
80
+
/// Create from a preset colour scheme.
81
81
+
pub fn from_preset(scheme_id: &str) -> Option<Self> {
82
82
+
let scheme = BUILTIN_COLOUR_SCHEMES.iter().find(|s| s.id == scheme_id)?;
83
83
+
let colours = &scheme.colours;
84
84
+
85
85
+
let scheme_values = ColourSchemeValues {
86
86
+
base: strip_hex_prefix(&colours.base),
87
87
+
surface: strip_hex_prefix(&colours.surface),
88
88
+
overlay: strip_hex_prefix(&colours.overlay),
89
89
+
text: strip_hex_prefix(&colours.text),
90
90
+
muted: strip_hex_prefix(&colours.muted),
91
91
+
subtle: strip_hex_prefix(&colours.subtle),
92
92
+
emphasis: strip_hex_prefix(&colours.emphasis),
93
93
+
primary: strip_hex_prefix(&colours.primary),
94
94
+
secondary: strip_hex_prefix(&colours.secondary),
95
95
+
tertiary: strip_hex_prefix(&colours.tertiary),
96
96
+
error: strip_hex_prefix(&colours.error),
97
97
+
warning: strip_hex_prefix(&colours.warning),
98
98
+
success: strip_hex_prefix(&colours.success),
99
99
+
border: strip_hex_prefix(&colours.border),
100
100
+
link: strip_hex_prefix(&colours.link),
101
101
+
highlight: strip_hex_prefix(&colours.highlight),
102
102
+
};
103
103
+
104
104
+
// Find counterpart scheme for dark/light.
105
105
+
let is_dark = scheme.variant == "dark";
106
106
+
let counterpart_id = if is_dark {
107
107
+
"rose-pine-dawn"
108
108
+
} else {
109
109
+
"rose-pine"
110
110
+
};
111
111
+
let counterpart = BUILTIN_COLOUR_SCHEMES
112
112
+
.iter()
113
113
+
.find(|s| s.id == counterpart_id);
114
114
+
115
115
+
let counterpart_values = counterpart
116
116
+
.map(|c| {
117
117
+
let colours = &c.colours;
118
118
+
ColourSchemeValues {
119
119
+
base: strip_hex_prefix(&colours.base),
120
120
+
surface: strip_hex_prefix(&colours.surface),
121
121
+
overlay: strip_hex_prefix(&colours.overlay),
122
122
+
text: strip_hex_prefix(&colours.text),
123
123
+
muted: strip_hex_prefix(&colours.muted),
124
124
+
subtle: strip_hex_prefix(&colours.subtle),
125
125
+
emphasis: strip_hex_prefix(&colours.emphasis),
126
126
+
primary: strip_hex_prefix(&colours.primary),
127
127
+
secondary: strip_hex_prefix(&colours.secondary),
128
128
+
tertiary: strip_hex_prefix(&colours.tertiary),
129
129
+
error: strip_hex_prefix(&colours.error),
130
130
+
warning: strip_hex_prefix(&colours.warning),
131
131
+
success: strip_hex_prefix(&colours.success),
132
132
+
border: strip_hex_prefix(&colours.border),
133
133
+
link: strip_hex_prefix(&colours.link),
134
134
+
highlight: strip_hex_prefix(&colours.highlight),
135
135
+
}
136
136
+
})
137
137
+
.unwrap_or_default();
138
138
+
139
139
+
let (light, dark) = if is_dark {
140
140
+
(counterpart_values, scheme_values)
141
141
+
} else {
142
142
+
(scheme_values, counterpart_values)
143
143
+
};
144
144
+
145
145
+
Some(Self {
146
146
+
light,
147
147
+
dark,
148
148
+
font_body: String::new(),
149
149
+
font_heading: String::new(),
150
150
+
font_mono: String::new(),
151
151
+
spacing_base: "16".to_string(),
152
152
+
spacing_line_height: "1.6".to_string(),
153
153
+
spacing_scale: "1.25".to_string(),
154
154
+
light_code_theme: "rose-pine-dawn".to_string(),
155
155
+
dark_code_theme: "rose-pine".to_string(),
156
156
+
default_mode: scheme.variant.to_string(),
157
157
+
})
158
158
+
}
159
159
+
}
160
160
+
161
161
+
fn strip_hex_prefix(s: &str) -> String {
162
162
+
s.trim_start_matches('#')
163
163
+
.trim_start_matches("0x")
164
164
+
.trim_start_matches("0X")
165
165
+
.to_uppercase()
166
166
+
}
167
167
+
168
168
+
fn mode_to_index(mode: &str) -> std::collections::HashSet<usize> {
169
169
+
let idx = match mode {
170
170
+
"light" => 1,
171
171
+
"dark" => 2,
172
172
+
_ => 0,
173
173
+
};
174
174
+
std::collections::HashSet::from([idx])
175
175
+
}
176
176
+
177
177
+
fn index_to_mode(idx: usize) -> &'static str {
178
178
+
match idx {
179
179
+
1 => "light",
180
180
+
2 => "dark",
181
181
+
_ => "auto",
182
182
+
}
183
183
+
}
184
184
+
185
185
+
/// Props for ThemeEditor.
186
186
+
#[derive(Props, Clone, PartialEq)]
187
187
+
pub struct ThemeEditorProps {
188
188
+
/// Current theme values (signal for reactivity).
189
189
+
pub values: Signal<ThemeEditorValues>,
190
190
+
/// Callback on save.
191
191
+
pub on_save: EventHandler<ThemeEditorValues>,
192
192
+
/// Callback on cancel.
193
193
+
pub on_cancel: EventHandler<()>,
194
194
+
/// Whether save is in progress.
195
195
+
#[props(default = false)]
196
196
+
pub saving: bool,
197
197
+
}
198
198
+
199
199
+
/// Full theme editor with all 16 colours, fonts, spacing.
200
200
+
#[component]
201
201
+
pub fn ThemeEditor(props: ThemeEditorProps) -> Element {
202
202
+
let mut values = props.values;
203
203
+
204
204
+
// Preview values derived from editor values.
205
205
+
let mut preview_values = use_signal(|| values().to_inline_values());
206
206
+
207
207
+
// Sync preview when values change.
208
208
+
use_effect(move || {
209
209
+
let v = values();
210
210
+
preview_values.set(v.to_inline_values());
211
211
+
});
212
212
+
213
213
+
// Which variant is being edited (for preview).
214
214
+
let mut editing_dark = use_signal(|| false);
215
215
+
216
216
+
// Derived signals for light/dark schemes.
217
217
+
let light_scheme = use_memo(move || values().light.clone());
218
218
+
let dark_scheme = use_memo(move || values().dark.clone());
219
219
+
220
220
+
rsx! {
221
221
+
document::Stylesheet { href: THEME_EDITOR_CSS }
222
222
+
223
223
+
div { class: "theme-editor-page",
224
224
+
// Left column: controls.
225
225
+
div { class: "theme-editor-controls",
226
226
+
// Mode toggle.
227
227
+
ModeSection { values }
228
228
+
229
229
+
// Fonts section.
230
230
+
FontsSection { values }
231
231
+
232
232
+
// Spacing section.
233
233
+
SpacingSection { values }
234
234
+
235
235
+
// Code themes.
236
236
+
CodeThemesSection { values }
237
237
+
238
238
+
// Light scheme colours.
239
239
+
ColourSchemeSection {
240
240
+
title: "Light scheme",
241
241
+
variant: "light",
242
242
+
scheme: light_scheme,
243
243
+
on_change: move |new_scheme: ColourSchemeValues| {
244
244
+
values.write().light = new_scheme;
245
245
+
},
246
246
+
on_focus: move |_| editing_dark.set(false),
247
247
+
}
248
248
+
249
249
+
// Dark scheme colours.
250
250
+
ColourSchemeSection {
251
251
+
title: "Dark scheme",
252
252
+
variant: "dark",
253
253
+
scheme: dark_scheme,
254
254
+
on_change: move |new_scheme: ColourSchemeValues| {
255
255
+
values.write().dark = new_scheme;
256
256
+
},
257
257
+
on_focus: move |_| editing_dark.set(true),
258
258
+
}
259
259
+
260
260
+
// Actions.
261
261
+
div { class: "theme-editor-actions",
262
262
+
Button {
263
263
+
variant: ButtonVariant::Primary,
264
264
+
onclick: move |_| {
265
265
+
props.on_save.call(values());
266
266
+
},
267
267
+
disabled: props.saving,
268
268
+
if props.saving { "Saving..." } else { "Save" }
269
269
+
}
270
270
+
Button {
271
271
+
variant: ButtonVariant::Ghost,
272
272
+
onclick: move |_| {
273
273
+
props.on_cancel.call(());
274
274
+
},
275
275
+
disabled: props.saving,
276
276
+
"Cancel"
277
277
+
}
278
278
+
}
279
279
+
}
280
280
+
281
281
+
// Right column: preview.
282
282
+
div { class: "theme-editor-preview",
283
283
+
div { class: "theme-editor-preview-header",
284
284
+
h3 { "Preview" }
285
285
+
ToggleGroup {
286
286
+
horizontal: true,
287
287
+
default_pressed: std::collections::HashSet::from([0usize]),
288
288
+
on_pressed_change: move |pressed: std::collections::HashSet<usize>| {
289
289
+
if let Some(&idx) = pressed.iter().next() {
290
290
+
editing_dark.set(idx == 1);
291
291
+
}
292
292
+
},
293
293
+
ToggleItem { index: 0usize, "Light" }
294
294
+
ToggleItem { index: 1usize, "Dark" }
295
295
+
}
296
296
+
}
297
297
+
ThemePreview {
298
298
+
values: preview_values,
299
299
+
dark: editing_dark(),
300
300
+
}
301
301
+
}
302
302
+
}
303
303
+
}
304
304
+
}
305
305
+
306
306
+
/// Colour scheme section with all 16 colours and its own preset selector.
307
307
+
#[component]
308
308
+
fn ColourSchemeSection(
309
309
+
title: &'static str,
310
310
+
variant: &'static str,
311
311
+
scheme: ReadSignal<ColourSchemeValues>,
312
312
+
on_change: EventHandler<ColourSchemeValues>,
313
313
+
on_focus: EventHandler<FocusEvent>,
314
314
+
) -> Element {
315
315
+
// Helper to create colour input with change handler.
316
316
+
let colour_input =
317
317
+
move |label: &'static str, value: String, setter: fn(&mut ColourSchemeValues, String)| {
318
318
+
let on_change = on_change.clone();
319
319
+
let on_focus = on_focus.clone();
320
320
+
let scheme = scheme.clone();
321
321
+
rsx! {
322
322
+
HexColourInput {
323
323
+
label: Some(label.to_string()),
324
324
+
value: value,
325
325
+
onchange: move |val: String| {
326
326
+
let mut new_scheme = scheme();
327
327
+
setter(&mut new_scheme, val);
328
328
+
on_change.call(new_scheme);
329
329
+
},
330
330
+
onfocus: move |e| on_focus.call(e),
331
331
+
}
332
332
+
}
333
333
+
};
334
334
+
335
335
+
let s = scheme();
336
336
+
337
337
+
// Filter presets by variant.
338
338
+
let presets: Vec<_> = BUILTIN_COLOUR_SCHEMES
339
339
+
.iter()
340
340
+
.filter(|p| p.variant == variant)
341
341
+
.collect();
342
342
+
343
343
+
rsx! {
344
344
+
div { class: "theme-editor-section",
345
345
+
div { class: "theme-editor-section-header",
346
346
+
h3 { "{title}" }
347
347
+
Select::<String> {
348
348
+
placeholder: "Custom",
349
349
+
on_value_change: move |val: Option<String>| {
350
350
+
let Some(val) = val else { return };
351
351
+
let Some(preset) = BUILTIN_COLOUR_SCHEMES.iter().find(|p| p.id == val) else { return };
352
352
+
let colours = &preset.colours;
353
353
+
on_change.call(ColourSchemeValues {
354
354
+
base: strip_hex_prefix(&colours.base),
355
355
+
surface: strip_hex_prefix(&colours.surface),
356
356
+
overlay: strip_hex_prefix(&colours.overlay),
357
357
+
text: strip_hex_prefix(&colours.text),
358
358
+
muted: strip_hex_prefix(&colours.muted),
359
359
+
subtle: strip_hex_prefix(&colours.subtle),
360
360
+
emphasis: strip_hex_prefix(&colours.emphasis),
361
361
+
primary: strip_hex_prefix(&colours.primary),
362
362
+
secondary: strip_hex_prefix(&colours.secondary),
363
363
+
tertiary: strip_hex_prefix(&colours.tertiary),
364
364
+
error: strip_hex_prefix(&colours.error),
365
365
+
warning: strip_hex_prefix(&colours.warning),
366
366
+
success: strip_hex_prefix(&colours.success),
367
367
+
border: strip_hex_prefix(&colours.border),
368
368
+
link: strip_hex_prefix(&colours.link),
369
369
+
highlight: strip_hex_prefix(&colours.highlight),
370
370
+
});
371
371
+
},
372
372
+
SelectTrigger {
373
373
+
SelectValue {}
374
374
+
}
375
375
+
SelectList {
376
376
+
for (idx, preset) in presets.iter().enumerate() {
377
377
+
SelectOption::<String> {
378
378
+
index: idx,
379
379
+
value: preset.id.to_string(),
380
380
+
text_value: preset.name.to_string(),
381
381
+
"{preset.name}"
382
382
+
}
383
383
+
}
384
384
+
}
385
385
+
}
386
386
+
}
387
387
+
388
388
+
// Background colours.
389
389
+
div { class: "theme-editor-colour-group",
390
390
+
span { class: "theme-editor-colour-group-label", "Background" }
391
391
+
div { class: "theme-editor-colour-group-items",
392
392
+
{colour_input("Base", s.base.clone(), |s, v| s.base = v)}
393
393
+
{colour_input("Surface", s.surface.clone(), |s, v| s.surface = v)}
394
394
+
{colour_input("Overlay", s.overlay.clone(), |s, v| s.overlay = v)}
395
395
+
}
396
396
+
}
397
397
+
398
398
+
// Text colours.
399
399
+
div { class: "theme-editor-colour-group",
400
400
+
span { class: "theme-editor-colour-group-label", "Text" }
401
401
+
div { class: "theme-editor-colour-group-items",
402
402
+
{colour_input("Text", s.text.clone(), |s, v| s.text = v)}
403
403
+
{colour_input("Muted", s.muted.clone(), |s, v| s.muted = v)}
404
404
+
{colour_input("Subtle", s.subtle.clone(), |s, v| s.subtle = v)}
405
405
+
{colour_input("Emphasis", s.emphasis.clone(), |s, v| s.emphasis = v)}
406
406
+
}
407
407
+
}
408
408
+
409
409
+
// Accent colours.
410
410
+
div { class: "theme-editor-colour-group",
411
411
+
span { class: "theme-editor-colour-group-label", "Accents" }
412
412
+
div { class: "theme-editor-colour-group-items",
413
413
+
{colour_input("Primary", s.primary.clone(), |s, v| s.primary = v)}
414
414
+
{colour_input("Secondary", s.secondary.clone(), |s, v| s.secondary = v)}
415
415
+
{colour_input("Tertiary", s.tertiary.clone(), |s, v| s.tertiary = v)}
416
416
+
{colour_input("Link", s.link.clone(), |s, v| s.link = v)}
417
417
+
}
418
418
+
}
419
419
+
420
420
+
// Status colours.
421
421
+
div { class: "theme-editor-colour-group",
422
422
+
span { class: "theme-editor-colour-group-label", "Status" }
423
423
+
div { class: "theme-editor-colour-group-items",
424
424
+
{colour_input("Error", s.error.clone(), |s, v| s.error = v)}
425
425
+
{colour_input("Warning", s.warning.clone(), |s, v| s.warning = v)}
426
426
+
{colour_input("Success", s.success.clone(), |s, v| s.success = v)}
427
427
+
}
428
428
+
}
429
429
+
430
430
+
// UI colours.
431
431
+
div { class: "theme-editor-colour-group",
432
432
+
span { class: "theme-editor-colour-group-label", "UI" }
433
433
+
div { class: "theme-editor-colour-group-items",
434
434
+
{colour_input("Border", s.border.clone(), |s, v| s.border = v)}
435
435
+
{colour_input("Highlight", s.highlight.clone(), |s, v| s.highlight = v)}
436
436
+
}
437
437
+
}
438
438
+
}
439
439
+
}
440
440
+
}
441
441
+
442
442
+
/// Fonts section.
443
443
+
#[component]
444
444
+
fn FontsSection(values: Signal<ThemeEditorValues>) -> Element {
445
445
+
rsx! {
446
446
+
div { class: "theme-editor-section",
447
447
+
h3 { "Fonts" }
448
448
+
div { class: "theme-editor-fonts",
449
449
+
div { class: "theme-editor-font-field",
450
450
+
label { "Body" }
451
451
+
input {
452
452
+
r#type: "text",
453
453
+
value: "{values().font_body}",
454
454
+
placeholder: "IBM Plex Serif",
455
455
+
oninput: move |e| values.write().font_body = e.value(),
456
456
+
}
457
457
+
}
458
458
+
div { class: "theme-editor-font-field",
459
459
+
label { "Heading" }
460
460
+
input {
461
461
+
r#type: "text",
462
462
+
value: "{values().font_heading}",
463
463
+
placeholder: "IBM Plex Sans",
464
464
+
oninput: move |e| values.write().font_heading = e.value(),
465
465
+
}
466
466
+
}
467
467
+
div { class: "theme-editor-font-field",
468
468
+
label { "Monospace" }
469
469
+
input {
470
470
+
r#type: "text",
471
471
+
value: "{values().font_mono}",
472
472
+
placeholder: "IBM Plex Mono",
473
473
+
oninput: move |e| values.write().font_mono = e.value(),
474
474
+
}
475
475
+
}
476
476
+
}
477
477
+
}
478
478
+
}
479
479
+
}
480
480
+
481
481
+
/// Spacing section.
482
482
+
#[component]
483
483
+
fn SpacingSection(values: Signal<ThemeEditorValues>) -> Element {
484
484
+
rsx! {
485
485
+
div { class: "theme-editor-section",
486
486
+
h3 { "Spacing" }
487
487
+
div { class: "theme-editor-spacing",
488
488
+
div { class: "theme-editor-spacing-field",
489
489
+
label { "Base size (px)" }
490
490
+
input {
491
491
+
r#type: "number",
492
492
+
value: "{values().spacing_base}",
493
493
+
oninput: move |e| values.write().spacing_base = e.value(),
494
494
+
}
495
495
+
}
496
496
+
div { class: "theme-editor-spacing-field",
497
497
+
label { "Line height" }
498
498
+
input {
499
499
+
r#type: "text",
500
500
+
value: "{values().spacing_line_height}",
501
501
+
oninput: move |e| values.write().spacing_line_height = e.value(),
502
502
+
}
503
503
+
}
504
504
+
div { class: "theme-editor-spacing-field",
505
505
+
label { "Scale" }
506
506
+
input {
507
507
+
r#type: "text",
508
508
+
value: "{values().spacing_scale}",
509
509
+
oninput: move |e| values.write().spacing_scale = e.value(),
510
510
+
}
511
511
+
}
512
512
+
}
513
513
+
}
514
514
+
}
515
515
+
}
516
516
+
517
517
+
/// Code themes section.
518
518
+
#[component]
519
519
+
fn CodeThemesSection(values: Signal<ThemeEditorValues>) -> Element {
520
520
+
let light_themes: Vec<_> = BUILTIN_CODE_THEMES
521
521
+
.iter()
522
522
+
.filter(|t| t.variant == "light")
523
523
+
.collect();
524
524
+
let dark_themes: Vec<_> = BUILTIN_CODE_THEMES
525
525
+
.iter()
526
526
+
.filter(|t| t.variant == "dark")
527
527
+
.collect();
528
528
+
529
529
+
rsx! {
530
530
+
div { class: "theme-editor-section",
531
531
+
h3 { "Code themes" }
532
532
+
div { class: "theme-editor-code-themes",
533
533
+
div { class: "theme-editor-code-theme",
534
534
+
label { "Light" }
535
535
+
Select::<String> {
536
536
+
value: Some(values().light_code_theme.clone()),
537
537
+
on_value_change: move |val: Option<String>| {
538
538
+
if let Some(val) = val {
539
539
+
values.write().light_code_theme = val;
540
540
+
}
541
541
+
},
542
542
+
SelectTrigger {
543
543
+
SelectValue {}
544
544
+
}
545
545
+
SelectList {
546
546
+
for (idx, theme) in light_themes.iter().enumerate() {
547
547
+
SelectOption::<String> {
548
548
+
index: idx,
549
549
+
value: theme.id.to_string(),
550
550
+
text_value: theme.name.to_string(),
551
551
+
"{theme.name}"
552
552
+
}
553
553
+
}
554
554
+
}
555
555
+
}
556
556
+
}
557
557
+
div { class: "theme-editor-code-theme",
558
558
+
label { "Dark" }
559
559
+
Select::<String> {
560
560
+
value: Some(values().dark_code_theme.clone()),
561
561
+
on_value_change: move |val: Option<String>| {
562
562
+
if let Some(val) = val {
563
563
+
values.write().dark_code_theme = val;
564
564
+
}
565
565
+
},
566
566
+
SelectTrigger {
567
567
+
SelectValue {}
568
568
+
}
569
569
+
SelectList {
570
570
+
for (idx, theme) in dark_themes.iter().enumerate() {
571
571
+
SelectOption::<String> {
572
572
+
index: idx,
573
573
+
value: theme.id.to_string(),
574
574
+
text_value: theme.name.to_string(),
575
575
+
"{theme.name}"
576
576
+
}
577
577
+
}
578
578
+
}
579
579
+
}
580
580
+
}
581
581
+
}
582
582
+
}
583
583
+
}
584
584
+
}
585
585
+
586
586
+
/// Mode section.
587
587
+
#[component]
588
588
+
fn ModeSection(values: Signal<ThemeEditorValues>) -> Element {
589
589
+
rsx! {
590
590
+
div { class: "theme-editor-section",
591
591
+
div { class: "theme-editor-mode",
592
592
+
label { "Default mode:" }
593
593
+
ToggleGroup {
594
594
+
horizontal: true,
595
595
+
default_pressed: mode_to_index(&values().default_mode),
596
596
+
on_pressed_change: move |pressed: std::collections::HashSet<usize>| {
597
597
+
if let Some(&idx) = pressed.iter().next() {
598
598
+
values.write().default_mode = index_to_mode(idx).to_string();
599
599
+
}
600
600
+
},
601
601
+
ToggleItem { index: 0usize, "Auto" }
602
602
+
ToggleItem { index: 1usize, "Light" }
603
603
+
ToggleItem { index: 2usize, "Dark" }
604
604
+
}
605
605
+
}
606
606
+
}
607
607
+
}
608
608
+
}
+149
crates/weaver-app/src/components/theme_preview.rs
···
1
1
+
//! Theme preview component with sample markdown rendering.
2
2
+
3
3
+
use dioxus::prelude::*;
4
4
+
5
5
+
use crate::components::css::{ThemePreviewInput, generate_theme_preview};
6
6
+
use crate::components::inline_theme_editor::InlineThemeValues;
7
7
+
8
8
+
const NOTEBOOK_DEFAULTS_CSS: Asset = asset!("/assets/styling/notebook-defaults.css");
9
9
+
10
10
+
/// Sample markdown to render for theme preview.
11
11
+
const SAMPLE_MARKDOWN: &str = r#"# Heading 1
12
12
+
13
13
+
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
14
14
+
15
15
+
## Heading 2
16
16
+
17
17
+
Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris. Here's a [link to somewhere](#) and some **bold text** with *italics*.
18
18
+
19
19
+
### Heading 3
20
20
+
21
21
+
> A blockquote for emphasis. This tests the border and muted text colours.
22
22
+
23
23
+
Here's a list of items:
24
24
+
25
25
+
- First item with some text
26
26
+
- Second item with `inline code`
27
27
+
- Third item
28
28
+
29
29
+
And some numbered steps:
30
30
+
31
31
+
1. Do the first thing
32
32
+
2. Then the second
33
33
+
3. Finally the third
34
34
+
35
35
+
```rust
36
36
+
fn main() {
37
37
+
// A code block to test syntax highlighting
38
38
+
let message = "Hello, world!";
39
39
+
println!("{}", message);
40
40
+
}
41
41
+
```
42
42
+
43
43
+
---
44
44
+
45
45
+
That's the end of the preview.
46
46
+
"#;
47
47
+
48
48
+
/// Render markdown to HTML using the full pipeline with syntax highlighting.
49
49
+
fn render_markdown(markdown: &str) -> String {
50
50
+
use markdown_weaver::Parser;
51
51
+
use weaver_renderer::atproto::ClientWriter;
52
52
+
use weaver_renderer::default_md_options;
53
53
+
54
54
+
let parser = Parser::new_ext(markdown, default_md_options()).into_offset_iter();
55
55
+
let mut html = String::new();
56
56
+
ClientWriter::<_, _, ()>::new(parser, &mut html, markdown)
57
57
+
.run()
58
58
+
.ok();
59
59
+
html
60
60
+
}
61
61
+
62
62
+
/// Props for ThemePreview with signal for reactivity.
63
63
+
#[derive(Props, Clone, PartialEq)]
64
64
+
pub struct ThemePreviewProps {
65
65
+
/// Theme values to preview (signal for reactivity).
66
66
+
pub values: Signal<InlineThemeValues>,
67
67
+
/// Whether to show dark variant (false = light).
68
68
+
#[props(default = false)]
69
69
+
pub dark: bool,
70
70
+
}
71
71
+
72
72
+
/// Theme preview component that renders sample markdown with theme applied.
73
73
+
#[component]
74
74
+
pub fn ThemePreview(props: ThemePreviewProps) -> Element {
75
75
+
let dark = props.dark;
76
76
+
let values = props.values;
77
77
+
78
78
+
let mut preview_resource = use_resource(move || {
79
79
+
let values = values();
80
80
+
async move {
81
81
+
// Skip if background is empty (invalid).
82
82
+
if values.background.is_empty() {
83
83
+
return Err(ServerFnError::new("No theme values set"));
84
84
+
}
85
85
+
86
86
+
let input = ThemePreviewInput {
87
87
+
background: values.background.clone(),
88
88
+
text: values.text.clone(),
89
89
+
primary: values.primary.clone(),
90
90
+
link: values.link.clone(),
91
91
+
light_background: values.light_background.clone(),
92
92
+
light_text: values.light_text.clone(),
93
93
+
light_primary: values.light_primary.clone(),
94
94
+
light_link: values.light_link.clone(),
95
95
+
dark_background: values.dark_background.clone(),
96
96
+
dark_text: values.dark_text.clone(),
97
97
+
dark_primary: values.dark_primary.clone(),
98
98
+
dark_link: values.dark_link.clone(),
99
99
+
light_code_theme: values.light_code_theme.clone(),
100
100
+
dark_code_theme: values.dark_code_theme.clone(),
101
101
+
};
102
102
+
generate_theme_preview(input).await
103
103
+
}
104
104
+
});
105
105
+
106
106
+
// Restart resource when values change.
107
107
+
use_effect(move || {
108
108
+
let _ = values();
109
109
+
preview_resource.restart();
110
110
+
});
111
111
+
112
112
+
let rendered_html = render_markdown(SAMPLE_MARKDOWN);
113
113
+
114
114
+
match preview_resource() {
115
115
+
Some(Ok(output)) => {
116
116
+
let palette = if dark { &output.dark_palette } else { &output.light_palette };
117
117
+
let css_vars = palette.to_css_vars();
118
118
+
119
119
+
// Scoped CSS: variables -> notebook defaults -> syntax highlighting
120
120
+
let scoped_css = format!(".theme-preview {{ {} }}", css_vars);
121
121
+
122
122
+
rsx! {
123
123
+
// 1. CSS variables scoped to .theme-preview
124
124
+
style { dangerous_inner_html: "{scoped_css}" }
125
125
+
126
126
+
// 2. Notebook content styles (uses the variables)
127
127
+
document::Stylesheet { href: NOTEBOOK_DEFAULTS_CSS }
128
128
+
129
129
+
// 3. Syntax highlighting CSS
130
130
+
style { dangerous_inner_html: "{output.syntax_css}" }
131
131
+
132
132
+
div {
133
133
+
class: "theme-preview notebook-content",
134
134
+
dangerous_inner_html: "{rendered_html}"
135
135
+
}
136
136
+
}
137
137
+
}
138
138
+
Some(Err(e)) => rsx! {
139
139
+
div { class: "theme-preview theme-preview--error",
140
140
+
"Failed to generate theme preview: {e}"
141
141
+
}
142
142
+
},
143
143
+
None => rsx! {
144
144
+
div { class: "theme-preview theme-preview--loading",
145
145
+
"Loading preview..."
146
146
+
}
147
147
+
},
148
148
+
}
149
149
+
}
+3
-3
crates/weaver-app/src/custom_domain_app.rs
···
80
80
}
81
81
82
82
#[component]
83
83
-
fn EntryByRkey(rkey: ReadOnlySignal<SmolStr>) -> Element {
83
83
+
fn EntryByRkey(rkey: ReadSignal<SmolStr>) -> Element {
84
84
let ctx = use_context::<CustomDomainContext>();
85
85
86
86
rsx! {
···
93
93
}
94
94
95
95
#[component]
96
96
-
fn EntryEdit(rkey: ReadOnlySignal<SmolStr>) -> Element {
96
96
+
fn EntryEdit(rkey: ReadSignal<SmolStr>) -> Element {
97
97
let ctx = use_context::<CustomDomainContext>();
98
98
99
99
rsx! {
···
113
113
}
114
114
115
115
#[component]
116
116
-
fn PathPage(segments: ReadOnlySignal<Vec<String>>) -> Element {
116
116
+
fn PathPage(segments: ReadSignal<Vec<String>>) -> Element {
117
117
let ctx = use_context::<CustomDomainContext>();
118
118
119
119
let ident = use_memo(move || ctx.owner.clone());
+6
-3
crates/weaver-app/src/lib.rs
···
48
48
use views::{
49
49
AboutPage, Callback, DraftEdit, DraftsList, Editor, Home, InvitesPage, LeafletEntry,
50
50
LeafletEntryNsid, Navbar, NewDraft, Notebook, NotebookEntryByRkey, NotebookEntryEdit,
51
51
-
NotebookIndex, NotebookPage, PcktEntry, PcktEntryBlogNsid, PcktEntryNsid, PrivacyPage,
52
52
-
RecordIndex, RecordPage, StandaloneEntry, StandaloneEntryEdit, StandaloneEntryNsid, TermsPage,
53
53
-
WhiteWindEntry, WhiteWindEntryNsid,
51
51
+
NotebookIndex, NotebookPage, NotebookSettings, PcktEntry, PcktEntryBlogNsid, PcktEntryNsid,
52
52
+
PrivacyPage, RecordIndex, RecordPage, StandaloneEntry, StandaloneEntryEdit,
53
53
+
StandaloneEntryNsid, TermsPage, WhiteWindEntry, WhiteWindEntryNsid,
54
54
};
55
55
56
56
#[derive(Debug, Clone, Routable, PartialEq)]
···
118
118
#[layout(Notebook)]
119
119
#[route("/")]
120
120
NotebookIndex { ident: AtIdentifier<'static>, book_title: SmolStr },
121
121
+
// Settings must come before /:title to avoid capture
122
122
+
#[route("/settings")]
123
123
+
NotebookSettings { ident: AtIdentifier<'static>, book_title: SmolStr },
121
124
#[route("/:title")]
122
125
EntryPage { ident: AtIdentifier<'static>, book_title: SmolStr, title: SmolStr },
123
126
// Entry by rkey (canonical path)
+1
crates/weaver-app/src/views/footer.rs
···
37
37
| Route::StandaloneEntryNsid { ident, .. }
38
38
| Route::StandaloneEntryEdit { ident, .. }
39
39
| Route::NotebookIndex { ident, .. }
40
40
+
| Route::NotebookSettings { ident, .. }
40
41
| Route::EntryPage { ident, .. }
41
42
| Route::NotebookEntryByRkey { ident, .. }
42
43
| Route::NotebookEntryEdit { ident, .. } => is_owner_ident(ident),
+3
crates/weaver-app/src/views/mod.rs
···
52
52
53
53
mod subdomain_navbar;
54
54
pub use subdomain_navbar::{SubdomainErrorLayout, SubdomainNavbar};
55
55
+
56
56
+
mod notebook_settings;
57
57
+
pub use notebook_settings::NotebookSettings;
+960
crates/weaver-app/src/views/notebook_settings.rs
···
1
1
+
//! Notebook settings view with full theme editor.
2
2
+
3
3
+
use dioxus::prelude::*;
4
4
+
use jacquard::client::AgentSessionExt;
5
5
+
use jacquard::common::from_data;
6
6
+
use jacquard::smol_str::SmolStr;
7
7
+
use jacquard::types::aturi::AtUri;
8
8
+
use jacquard::types::ident::AtIdentifier;
9
9
+
use jacquard::types::string::Datetime;
10
10
+
use jacquard::{CowStr, IntoStatic};
11
11
+
use weaver_api::com_atproto::repo::strong_ref::StrongRef;
12
12
+
use weaver_api::sh_weaver::notebook::book::Book;
13
13
+
use weaver_api::sh_weaver::notebook::colour_scheme::ColourScheme;
14
14
+
use weaver_api::sh_weaver::notebook::theme::{
15
15
+
Theme, ThemeDarkCodeTheme, ThemeFonts, ThemeLightCodeTheme, ThemeSpacing,
16
16
+
};
17
17
+
18
18
+
use crate::Route;
19
19
+
use crate::auth::AuthState;
20
20
+
use crate::components::button::{Button, ButtonVariant};
21
21
+
use crate::components::notebook::{delete_publication, sync_publication};
22
22
+
use crate::components::{ColourSchemeValues, ThemeEditor, ThemeEditorValues};
23
23
+
use crate::data;
24
24
+
use crate::fetch::Fetcher;
25
25
+
26
26
+
const NOTEBOOK_SETTINGS_CSS: Asset = asset!("/assets/styling/notebook-settings.css");
27
27
+
28
28
+
/// Form state for notebook settings.
29
29
+
#[derive(Debug, Clone, PartialEq, Default)]
30
30
+
pub struct NotebookSettingsState {
31
31
+
pub title: String,
32
32
+
pub path: String,
33
33
+
pub publish_global: bool,
34
34
+
pub tags: Vec<String>,
35
35
+
pub tags_input: String,
36
36
+
pub content_warnings: Vec<String>,
37
37
+
pub rating: Option<String>,
38
38
+
pub theme: ThemeEditorValues,
39
39
+
}
40
40
+
41
41
+
/// Props for NotebookSettings view.
42
42
+
#[derive(Props, Clone, PartialEq)]
43
43
+
pub struct NotebookSettingsProps {
44
44
+
pub ident: ReadSignal<AtIdentifier<'static>>,
45
45
+
pub book_title: ReadSignal<SmolStr>,
46
46
+
}
47
47
+
48
48
+
/// Notebook settings page with full theme editor.
49
49
+
#[component]
50
50
+
pub fn NotebookSettings(props: NotebookSettingsProps) -> Element {
51
51
+
// Load notebook data.
52
52
+
let (notebook_result, notebook_data) = data::use_notebook(props.ident, props.book_title);
53
53
+
54
54
+
#[cfg(feature = "fullstack-server")]
55
55
+
let _ = notebook_result?;
56
56
+
57
57
+
let auth_state = use_context::<Signal<AuthState>>();
58
58
+
let fetcher = use_context::<Fetcher>();
59
59
+
let navigator = use_navigator();
60
60
+
61
61
+
// Form state - editable copy of initial values.
62
62
+
let mut state = use_signal(NotebookSettingsState::default);
63
63
+
let mut state_initialized = use_signal(|| false);
64
64
+
let mut theme_values = use_signal(|| ThemeEditorValues::from_preset("rose-pine").unwrap_or_default());
65
65
+
let mut theme_initialized = use_signal(|| false);
66
66
+
let mut saving = use_signal(|| false);
67
67
+
let mut error = use_signal(|| None::<String>);
68
68
+
69
69
+
// Active section for navigation.
70
70
+
let mut active_section = use_signal(|| "general".to_string());
71
71
+
72
72
+
// Check ownership.
73
73
+
let current_did = auth_state.read().did.clone();
74
74
+
let ident_val = props.ident.read().clone();
75
75
+
let is_owner = match (¤t_did, &ident_val) {
76
76
+
(Some(did), AtIdentifier::Did(ident_did)) => *did == *ident_did,
77
77
+
_ => false,
78
78
+
};
79
79
+
80
80
+
// Derive notebook URI and book from loaded data.
81
81
+
let notebook_uri = use_memo(move || {
82
82
+
let data = notebook_data()?;
83
83
+
let (notebook_view, _) = &data;
84
84
+
Some(notebook_view.uri.clone().into_static())
85
85
+
});
86
86
+
87
87
+
let current_book = use_memo(move || {
88
88
+
let data = notebook_data()?;
89
89
+
let (notebook_view, _) = &data;
90
90
+
let book: Book<'_> = from_data(¬ebook_view.record).ok()?;
91
91
+
Some(book.into_static())
92
92
+
});
93
93
+
94
94
+
// Derive initial form state from notebook data.
95
95
+
let initial_form_state = use_memo(move || {
96
96
+
let book = current_book()?;
97
97
+
Some(NotebookSettingsState {
98
98
+
title: book
99
99
+
.title
100
100
+
.as_ref()
101
101
+
.map(|t| t.as_ref().to_string())
102
102
+
.unwrap_or_default(),
103
103
+
path: book
104
104
+
.path
105
105
+
.as_ref()
106
106
+
.map(|p| p.as_ref().to_string())
107
107
+
.unwrap_or_default(),
108
108
+
publish_global: book.publish_global.unwrap_or(false),
109
109
+
tags: book
110
110
+
.tags
111
111
+
.as_ref()
112
112
+
.map(|t| t.iter().map(|s| s.as_ref().to_string()).collect())
113
113
+
.unwrap_or_default(),
114
114
+
tags_input: String::new(),
115
115
+
content_warnings: book
116
116
+
.content_warnings
117
117
+
.as_ref()
118
118
+
.map(|cw| cw.iter().map(|s| s.as_ref().to_string()).collect())
119
119
+
.unwrap_or_default(),
120
120
+
rating: book.rating.as_ref().map(|r| r.as_ref().to_string()),
121
121
+
theme: ThemeEditorValues::default(),
122
122
+
})
123
123
+
});
124
124
+
125
125
+
// Load theme values from theme ref.
126
126
+
let theme_fetcher = fetcher.clone();
127
127
+
let theme_resource = use_resource(move || {
128
128
+
let theme_ref = current_book()
129
129
+
.and_then(|b| b.theme.clone())
130
130
+
.map(|t| t.into_static());
131
131
+
let fetcher = theme_fetcher.clone();
132
132
+
async move {
133
133
+
let Some(theme_ref) = theme_ref else {
134
134
+
return None;
135
135
+
};
136
136
+
load_full_theme_values(&fetcher, &theme_ref).await.ok()
137
137
+
}
138
138
+
});
139
139
+
140
140
+
// Initialize editable state from loaded data (once).
141
141
+
use_effect(move || {
142
142
+
if !state_initialized() {
143
143
+
if let Some(form_state) = initial_form_state() {
144
144
+
state.set(form_state);
145
145
+
state_initialized.set(true);
146
146
+
}
147
147
+
}
148
148
+
if !theme_initialized() {
149
149
+
if let Some(Some(theme_vals)) = theme_resource.read().as_ref() {
150
150
+
theme_values.set(theme_vals.clone());
151
151
+
theme_initialized.set(true);
152
152
+
}
153
153
+
}
154
154
+
});
155
155
+
156
156
+
if !is_owner {
157
157
+
return rsx! {
158
158
+
div { class: "notebook-settings-unauthorized",
159
159
+
h1 { "Unauthorized" }
160
160
+
p { "You don't have permission to edit this notebook's settings." }
161
161
+
}
162
162
+
};
163
163
+
}
164
164
+
165
165
+
// Save general settings handler.
166
166
+
let save_fetcher = fetcher.clone();
167
167
+
let handle_save = move |_| {
168
168
+
let fetcher = save_fetcher.clone();
169
169
+
let uri = notebook_uri();
170
170
+
let book = current_book();
171
171
+
172
172
+
spawn(async move {
173
173
+
let Some(uri) = uri else {
174
174
+
error.set(Some("Notebook not loaded".to_string()));
175
175
+
return;
176
176
+
};
177
177
+
let Some(existing_book) = book else {
178
178
+
error.set(Some("Notebook not loaded".to_string()));
179
179
+
return;
180
180
+
};
181
181
+
182
182
+
saving.set(true);
183
183
+
error.set(None);
184
184
+
185
185
+
let form = state();
186
186
+
let now = Datetime::now();
187
187
+
188
188
+
let tags: Option<Vec<CowStr<'static>>> = if form.tags.is_empty() {
189
189
+
None
190
190
+
} else {
191
191
+
Some(form.tags.iter().map(|s| CowStr::from(s.clone())).collect())
192
192
+
};
193
193
+
194
194
+
use weaver_api::sh_weaver::notebook::{ContentRating, ContentWarning};
195
195
+
196
196
+
let content_warnings: Option<Vec<ContentWarning<'static>>> =
197
197
+
if form.content_warnings.is_empty() {
198
198
+
None
199
199
+
} else {
200
200
+
Some(
201
201
+
form.content_warnings
202
202
+
.iter()
203
203
+
.map(|s| ContentWarning::from(s.clone()))
204
204
+
.collect(),
205
205
+
)
206
206
+
};
207
207
+
208
208
+
let path: CowStr<'static> = form.path.clone().into();
209
209
+
let title: CowStr<'static> = form.title.clone().into();
210
210
+
let publish_global = form.publish_global;
211
211
+
let rating: Option<ContentRating<'static>> =
212
212
+
form.rating.clone().map(|r| ContentRating::from(r));
213
213
+
214
214
+
let client = fetcher.get_client();
215
215
+
match client
216
216
+
.update_record::<Book>(&uri, |book| {
217
217
+
book.title = Some(title.clone());
218
218
+
if !path.is_empty() {
219
219
+
book.path = Some(path.clone());
220
220
+
}
221
221
+
book.publish_global = Some(publish_global);
222
222
+
book.tags = tags.clone();
223
223
+
book.content_warnings = content_warnings.clone();
224
224
+
book.rating = rating.clone();
225
225
+
book.updated_at = Some(now.clone());
226
226
+
})
227
227
+
.await
228
228
+
{
229
229
+
Ok(_) => {
230
230
+
// Sync or delete publication based on publish_global.
231
231
+
let theme_vals = crate::components::InlineThemeValues::default();
232
232
+
if publish_global {
233
233
+
if let Err(e) =
234
234
+
sync_publication(&fetcher, &uri, &title, &path, &theme_vals).await
235
235
+
{
236
236
+
tracing::warn!("Failed to sync publication: {:?}", e);
237
237
+
}
238
238
+
} else if let Err(e) = delete_publication(&fetcher, &uri).await {
239
239
+
tracing::warn!("Failed to delete publication: {:?}", e);
240
240
+
}
241
241
+
saving.set(false);
242
242
+
}
243
243
+
Err(e) => {
244
244
+
error.set(Some(format!("Failed to save: {:?}", e)));
245
245
+
saving.set(false);
246
246
+
}
247
247
+
}
248
248
+
});
249
249
+
};
250
250
+
251
251
+
rsx! {
252
252
+
document::Stylesheet { href: NOTEBOOK_SETTINGS_CSS }
253
253
+
254
254
+
div { class: "notebook-settings",
255
255
+
// Sidebar navigation.
256
256
+
nav { class: "notebook-settings-nav",
257
257
+
button {
258
258
+
class: if active_section() == "general" { "active" } else { "" },
259
259
+
onclick: move |_| active_section.set("general".to_string()),
260
260
+
"General"
261
261
+
}
262
262
+
button {
263
263
+
class: if active_section() == "theme" { "active" } else { "" },
264
264
+
onclick: move |_| active_section.set("theme".to_string()),
265
265
+
"Theme"
266
266
+
}
267
267
+
button {
268
268
+
class: if active_section() == "collaborators" { "active" } else { "" },
269
269
+
onclick: move |_| active_section.set("collaborators".to_string()),
270
270
+
"Collaborators"
271
271
+
}
272
272
+
button {
273
273
+
class: if active_section() == "danger" { "active" } else { "" },
274
274
+
onclick: move |_| active_section.set("danger".to_string()),
275
275
+
"Danger Zone"
276
276
+
}
277
277
+
}
278
278
+
279
279
+
// Content area.
280
280
+
div { class: "notebook-settings-content",
281
281
+
match active_section().as_str() {
282
282
+
"general" => rsx! {
283
283
+
GeneralSection {
284
284
+
state: state,
285
285
+
saving: saving(),
286
286
+
error: error(),
287
287
+
on_save: handle_save,
288
288
+
}
289
289
+
},
290
290
+
"theme" => rsx! {
291
291
+
ThemeSection {
292
292
+
values: theme_values,
293
293
+
saving: saving(),
294
294
+
on_save: {
295
295
+
let fetcher = fetcher.clone();
296
296
+
move |values: ThemeEditorValues| {
297
297
+
let fetcher = fetcher.clone();
298
298
+
let uri = notebook_uri();
299
299
+
let book = current_book();
300
300
+
let values = values.clone();
301
301
+
302
302
+
spawn(async move {
303
303
+
let Some(uri) = uri else {
304
304
+
error.set(Some("Notebook not loaded".to_string()));
305
305
+
return;
306
306
+
};
307
307
+
let Some(existing_book) = book else {
308
308
+
error.set(Some("Notebook not loaded".to_string()));
309
309
+
return;
310
310
+
};
311
311
+
312
312
+
saving.set(true);
313
313
+
error.set(None);
314
314
+
315
315
+
// Sync theme records.
316
316
+
match sync_full_theme(&fetcher, existing_book.theme.as_ref(), &values).await {
317
317
+
Ok(theme_result) => {
318
318
+
// Update book with new theme ref.
319
319
+
let theme_ref = StrongRef::new()
320
320
+
.uri(theme_result.theme_uri)
321
321
+
.cid(theme_result.theme_cid)
322
322
+
.build();
323
323
+
324
324
+
let client = fetcher.get_client();
325
325
+
let now = Datetime::now();
326
326
+
match client
327
327
+
.update_record::<Book>(&uri, |book| {
328
328
+
book.theme = Some(theme_ref.clone());
329
329
+
book.updated_at = Some(now.clone());
330
330
+
})
331
331
+
.await
332
332
+
{
333
333
+
Ok(_) => {
334
334
+
theme_values.set(values);
335
335
+
saving.set(false);
336
336
+
}
337
337
+
Err(e) => {
338
338
+
error.set(Some(format!("Failed to update book: {:?}", e)));
339
339
+
saving.set(false);
340
340
+
}
341
341
+
}
342
342
+
}
343
343
+
Err(e) => {
344
344
+
error.set(Some(format!("Failed to sync theme: {:?}", e)));
345
345
+
saving.set(false);
346
346
+
}
347
347
+
}
348
348
+
});
349
349
+
}
350
350
+
},
351
351
+
}
352
352
+
},
353
353
+
"collaborators" => rsx! {
354
354
+
CollaboratorsSection {}
355
355
+
},
356
356
+
"danger" => rsx! {
357
357
+
DangerSection {
358
358
+
notebook_uri: notebook_uri(),
359
359
+
on_deleted: {
360
360
+
let ident = ident_val.clone();
361
361
+
move |_| {
362
362
+
navigator.push(Route::RepositoryIndex { ident: ident.clone() });
363
363
+
}
364
364
+
},
365
365
+
}
366
366
+
},
367
367
+
_ => rsx! { div { "Unknown section" } },
368
368
+
}
369
369
+
}
370
370
+
}
371
371
+
}
372
372
+
}
373
373
+
374
374
+
/// General settings section.
375
375
+
#[component]
376
376
+
fn GeneralSection(
377
377
+
state: Signal<NotebookSettingsState>,
378
378
+
saving: bool,
379
379
+
error: Option<String>,
380
380
+
on_save: EventHandler<()>,
381
381
+
) -> Element {
382
382
+
let mut state = state;
383
383
+
384
384
+
rsx! {
385
385
+
div { class: "notebook-settings-section",
386
386
+
h2 { "General Settings" }
387
387
+
388
388
+
// Title field.
389
389
+
div { class: "notebook-settings-field",
390
390
+
label { "Title" }
391
391
+
input {
392
392
+
r#type: "text",
393
393
+
value: "{state.read().title}",
394
394
+
placeholder: "My Notebook",
395
395
+
oninput: move |e| state.write().title = e.value(),
396
396
+
}
397
397
+
}
398
398
+
399
399
+
// Path field.
400
400
+
div { class: "notebook-settings-field",
401
401
+
label { "Path" }
402
402
+
input {
403
403
+
r#type: "text",
404
404
+
value: "{state.read().path}",
405
405
+
placeholder: "my-notebook",
406
406
+
oninput: move |e| state.write().path = e.value(),
407
407
+
}
408
408
+
span { class: "notebook-settings-hint",
409
409
+
"URL-friendly identifier for your notebook."
410
410
+
}
411
411
+
}
412
412
+
413
413
+
// Publish globally toggle.
414
414
+
div { class: "notebook-settings-field notebook-settings-toggle",
415
415
+
label {
416
416
+
input {
417
417
+
r#type: "checkbox",
418
418
+
checked: state.read().publish_global,
419
419
+
onchange: move |e| state.write().publish_global = e.checked(),
420
420
+
}
421
421
+
" Publish globally"
422
422
+
}
423
423
+
span { class: "notebook-settings-hint",
424
424
+
"Enable cross-platform discovery via site.standard.* records."
425
425
+
}
426
426
+
}
427
427
+
428
428
+
// Tags field.
429
429
+
div { class: "notebook-settings-field",
430
430
+
label { "Tags" }
431
431
+
div { class: "notebook-settings-tags",
432
432
+
for (i, tag) in state.read().tags.iter().enumerate() {
433
433
+
span {
434
434
+
key: "{i}",
435
435
+
class: "notebook-settings-tag",
436
436
+
"{tag}"
437
437
+
button {
438
438
+
class: "notebook-settings-tag-remove",
439
439
+
onclick: move |_| {
440
440
+
state.write().tags.remove(i);
441
441
+
},
442
442
+
"ร"
443
443
+
}
444
444
+
}
445
445
+
}
446
446
+
input {
447
447
+
r#type: "text",
448
448
+
class: "notebook-settings-tags-input",
449
449
+
value: "{state.read().tags_input}",
450
450
+
placeholder: "Add tag...",
451
451
+
oninput: move |e| state.write().tags_input = e.value(),
452
452
+
onkeydown: move |e| {
453
453
+
if e.key() == Key::Enter || e.key() == Key::Character(",".to_string()) {
454
454
+
e.prevent_default();
455
455
+
let tag = state.read().tags_input.trim().to_string();
456
456
+
if !tag.is_empty() {
457
457
+
let mut s = state.write();
458
458
+
if !s.tags.contains(&tag) {
459
459
+
s.tags.push(tag);
460
460
+
}
461
461
+
s.tags_input.clear();
462
462
+
}
463
463
+
}
464
464
+
},
465
465
+
}
466
466
+
}
467
467
+
}
468
468
+
469
469
+
// Content rating.
470
470
+
div { class: "notebook-settings-field",
471
471
+
label { "Content Rating" }
472
472
+
select {
473
473
+
value: state.read().rating.clone().unwrap_or_default(),
474
474
+
onchange: move |e: Event<FormData>| {
475
475
+
let val = e.value();
476
476
+
state.write().rating = if val.is_empty() { None } else { Some(val) };
477
477
+
},
478
478
+
option { value: "", "None" }
479
479
+
option { value: "general", "General" }
480
480
+
option { value: "mature", "Mature" }
481
481
+
option { value: "adult", "Adult" }
482
482
+
}
483
483
+
}
484
484
+
485
485
+
// Error display.
486
486
+
if let Some(ref err) = error {
487
487
+
div { class: "notebook-settings-error", "{err}" }
488
488
+
}
489
489
+
490
490
+
// Save button.
491
491
+
div { class: "notebook-settings-actions",
492
492
+
Button {
493
493
+
variant: ButtonVariant::Primary,
494
494
+
onclick: move |_| on_save.call(()),
495
495
+
disabled: saving,
496
496
+
if saving { "Saving..." } else { "Save Changes" }
497
497
+
}
498
498
+
}
499
499
+
}
500
500
+
}
501
501
+
}
502
502
+
503
503
+
/// Theme settings section with full editor.
504
504
+
#[component]
505
505
+
fn ThemeSection(
506
506
+
values: Signal<ThemeEditorValues>,
507
507
+
saving: bool,
508
508
+
on_save: EventHandler<ThemeEditorValues>,
509
509
+
) -> Element {
510
510
+
rsx! {
511
511
+
div { class: "notebook-settings-section notebook-settings-theme",
512
512
+
h2 { "Theme Settings" }
513
513
+
p { class: "notebook-settings-description",
514
514
+
"Customize the appearance of your notebook with colours, fonts, and spacing."
515
515
+
}
516
516
+
517
517
+
ThemeEditor {
518
518
+
values: values,
519
519
+
on_save: on_save,
520
520
+
on_cancel: move |_| {},
521
521
+
saving: saving,
522
522
+
}
523
523
+
}
524
524
+
}
525
525
+
}
526
526
+
527
527
+
/// Collaborators section.
528
528
+
#[component]
529
529
+
fn CollaboratorsSection() -> Element {
530
530
+
rsx! {
531
531
+
div { class: "notebook-settings-section",
532
532
+
h2 { "Collaborators" }
533
533
+
p { class: "notebook-settings-description",
534
534
+
"Manage who can edit this notebook."
535
535
+
}
536
536
+
537
537
+
// TODO: Integrate CollaboratorsPanel when notebook URI is available.
538
538
+
div { class: "notebook-settings-placeholder",
539
539
+
"Collaborator management coming soon."
540
540
+
}
541
541
+
}
542
542
+
}
543
543
+
}
544
544
+
545
545
+
/// Danger zone section.
546
546
+
#[component]
547
547
+
fn DangerSection(notebook_uri: Option<AtUri<'static>>, on_deleted: EventHandler<()>) -> Element {
548
548
+
let fetcher = use_context::<Fetcher>();
549
549
+
let mut show_delete_confirm = use_signal(|| false);
550
550
+
let mut deleting = use_signal(|| false);
551
551
+
let mut delete_error = use_signal(|| None::<String>);
552
552
+
553
553
+
let delete_fetcher = fetcher.clone();
554
554
+
let notebook_uri_for_delete = notebook_uri.clone();
555
555
+
let handle_delete = move |_| {
556
556
+
let Some(uri) = notebook_uri_for_delete.clone() else {
557
557
+
delete_error.set(Some("Notebook not loaded".to_string()));
558
558
+
return;
559
559
+
};
560
560
+
let fetcher = delete_fetcher.clone();
561
561
+
562
562
+
spawn(async move {
563
563
+
deleting.set(true);
564
564
+
delete_error.set(None);
565
565
+
566
566
+
// Delete all entries first, then the book.
567
567
+
let rkey = match uri.rkey() {
568
568
+
Some(r) => match RecordKey::any(r.as_ref()) {
569
569
+
Ok(k) => k.into_static(),
570
570
+
Err(_) => {
571
571
+
delete_error.set(Some("Invalid record key".to_string()));
572
572
+
deleting.set(false);
573
573
+
return;
574
574
+
}
575
575
+
},
576
576
+
None => {
577
577
+
delete_error.set(Some("Invalid notebook URI".to_string()));
578
578
+
deleting.set(false);
579
579
+
return;
580
580
+
}
581
581
+
};
582
582
+
583
583
+
let client = fetcher.get_client();
584
584
+
match client.delete_record::<Book>(rkey).await {
585
585
+
Ok(_) => {
586
586
+
deleting.set(false);
587
587
+
show_delete_confirm.set(false);
588
588
+
on_deleted.call(());
589
589
+
}
590
590
+
Err(e) => {
591
591
+
delete_error.set(Some(format!("Failed to delete: {:?}", e)));
592
592
+
deleting.set(false);
593
593
+
}
594
594
+
}
595
595
+
});
596
596
+
};
597
597
+
598
598
+
rsx! {
599
599
+
div { class: "notebook-settings-section notebook-settings-danger",
600
600
+
h2 { "Danger Zone" }
601
601
+
602
602
+
if let Some(ref err) = delete_error() {
603
603
+
div { class: "notebook-settings-error", "{err}" }
604
604
+
}
605
605
+
606
606
+
div { class: "notebook-settings-danger-item",
607
607
+
div { class: "notebook-settings-danger-info",
608
608
+
h3 { "Delete Notebook" }
609
609
+
p { "Permanently delete this notebook and all its entries. This action cannot be undone." }
610
610
+
}
611
611
+
Button {
612
612
+
variant: ButtonVariant::Destructive,
613
613
+
onclick: move |_| show_delete_confirm.set(true),
614
614
+
disabled: notebook_uri.is_none(),
615
615
+
"Delete Notebook"
616
616
+
}
617
617
+
}
618
618
+
619
619
+
if show_delete_confirm() {
620
620
+
div { class: "notebook-settings-confirm-overlay",
621
621
+
div { class: "notebook-settings-confirm-dialog",
622
622
+
h3 { "Are you sure?" }
623
623
+
p { "This will permanently delete the notebook and all its entries." }
624
624
+
div { class: "notebook-settings-confirm-actions",
625
625
+
Button {
626
626
+
variant: ButtonVariant::Destructive,
627
627
+
onclick: handle_delete,
628
628
+
disabled: deleting(),
629
629
+
if deleting() { "Deleting..." } else { "Yes, Delete" }
630
630
+
}
631
631
+
Button {
632
632
+
variant: ButtonVariant::Ghost,
633
633
+
onclick: move |_| show_delete_confirm.set(false),
634
634
+
disabled: deleting(),
635
635
+
"Cancel"
636
636
+
}
637
637
+
}
638
638
+
}
639
639
+
}
640
640
+
}
641
641
+
}
642
642
+
}
643
643
+
}
644
644
+
645
645
+
// --- Helper functions for full theme sync ---
646
646
+
647
647
+
use jacquard::types::string::{Cid, RecordKey};
648
648
+
use weaver_api::sh_weaver::notebook::colour_scheme::ColourSchemeColours;
649
649
+
use weaver_common::WeaverError;
650
650
+
651
651
+
/// Result of syncing theme records.
652
652
+
pub struct FullThemeSyncResult {
653
653
+
pub theme_uri: AtUri<'static>,
654
654
+
pub theme_cid: Cid<'static>,
655
655
+
}
656
656
+
657
657
+
/// Load full theme values from existing theme records.
658
658
+
async fn load_full_theme_values(
659
659
+
fetcher: &Fetcher,
660
660
+
theme_ref: &StrongRef<'_>,
661
661
+
) -> Result<ThemeEditorValues, WeaverError> {
662
662
+
// Fetch Theme record.
663
663
+
let theme: Theme<'static> = fetcher
664
664
+
.fetch_record(
665
665
+
&Theme::uri(theme_ref.uri.as_ref())
666
666
+
.map_err(|e| WeaverError::InvalidNotebook(e.to_string().into()))?,
667
667
+
)
668
668
+
.await
669
669
+
.map_err(|e| WeaverError::InvalidNotebook(e.to_string().into()))?
670
670
+
.value
671
671
+
.into_static();
672
672
+
673
673
+
// Fetch light ColourScheme.
674
674
+
let light_scheme: ColourScheme<'static> = fetcher
675
675
+
.fetch_record(
676
676
+
&ColourScheme::uri(theme.light_scheme.uri.as_ref())
677
677
+
.map_err(|e| WeaverError::InvalidNotebook(e.to_string().into()))?,
678
678
+
)
679
679
+
.await
680
680
+
.map_err(|e| WeaverError::InvalidNotebook(e.to_string().into()))?
681
681
+
.value
682
682
+
.into_static();
683
683
+
684
684
+
// Fetch dark ColourScheme.
685
685
+
let dark_scheme: ColourScheme<'static> = fetcher
686
686
+
.fetch_record(
687
687
+
&ColourScheme::uri(theme.dark_scheme.uri.as_ref())
688
688
+
.map_err(|e| WeaverError::InvalidNotebook(e.to_string().into()))?,
689
689
+
)
690
690
+
.await
691
691
+
.map_err(|e| WeaverError::InvalidNotebook(e.to_string().into()))?
692
692
+
.value
693
693
+
.into_static();
694
694
+
695
695
+
// Extract code themes.
696
696
+
let light_code_theme = match &theme.light_code_theme {
697
697
+
ThemeLightCodeTheme::CodeThemeName(name) => name.as_ref().to_string(),
698
698
+
ThemeLightCodeTheme::CodeThemeFile(file) => file.name.as_ref().to_string(),
699
699
+
ThemeLightCodeTheme::Unknown(_) => "base16-ocean.light".to_string(),
700
700
+
};
701
701
+
let dark_code_theme = match &theme.dark_code_theme {
702
702
+
ThemeDarkCodeTheme::CodeThemeName(name) => name.as_ref().to_string(),
703
703
+
ThemeDarkCodeTheme::CodeThemeFile(file) => file.name.as_ref().to_string(),
704
704
+
ThemeDarkCodeTheme::Unknown(_) => "base16-ocean.dark".to_string(),
705
705
+
};
706
706
+
707
707
+
let default_mode = theme
708
708
+
.default_theme
709
709
+
.as_ref()
710
710
+
.map(|s| s.to_string())
711
711
+
.unwrap_or_else(|| "auto".to_string());
712
712
+
713
713
+
fn colours_to_scheme(colours: &ColourSchemeColours<'_>) -> ColourSchemeValues {
714
714
+
ColourSchemeValues {
715
715
+
base: strip_hex(&colours.base),
716
716
+
surface: strip_hex(&colours.surface),
717
717
+
overlay: strip_hex(&colours.overlay),
718
718
+
text: strip_hex(&colours.text),
719
719
+
muted: strip_hex(&colours.muted),
720
720
+
subtle: strip_hex(&colours.subtle),
721
721
+
emphasis: strip_hex(&colours.emphasis),
722
722
+
primary: strip_hex(&colours.primary),
723
723
+
secondary: strip_hex(&colours.secondary),
724
724
+
tertiary: strip_hex(&colours.tertiary),
725
725
+
error: strip_hex(&colours.error),
726
726
+
warning: strip_hex(&colours.warning),
727
727
+
success: strip_hex(&colours.success),
728
728
+
border: strip_hex(&colours.border),
729
729
+
link: strip_hex(&colours.link),
730
730
+
highlight: strip_hex(&colours.highlight),
731
731
+
}
732
732
+
}
733
733
+
734
734
+
Ok(ThemeEditorValues {
735
735
+
light: colours_to_scheme(&light_scheme.colours),
736
736
+
dark: colours_to_scheme(&dark_scheme.colours),
737
737
+
font_body: String::new(),
738
738
+
font_heading: String::new(),
739
739
+
font_mono: String::new(),
740
740
+
spacing_base: theme.spacing.base_size.to_string(),
741
741
+
spacing_line_height: theme.spacing.line_height.to_string(),
742
742
+
spacing_scale: theme.spacing.scale.to_string(),
743
743
+
light_code_theme,
744
744
+
dark_code_theme,
745
745
+
default_mode,
746
746
+
})
747
747
+
}
748
748
+
749
749
+
fn strip_hex(s: &str) -> String {
750
750
+
s.trim_start_matches('#')
751
751
+
.trim_start_matches("0x")
752
752
+
.trim_start_matches("0X")
753
753
+
.to_uppercase()
754
754
+
}
755
755
+
756
756
+
/// Sync full theme values to ColourScheme and Theme records.
757
757
+
async fn sync_full_theme(
758
758
+
fetcher: &Fetcher,
759
759
+
existing_theme_ref: Option<&StrongRef<'_>>,
760
760
+
values: &ThemeEditorValues,
761
761
+
) -> Result<FullThemeSyncResult, WeaverError> {
762
762
+
fn scheme_to_colours(scheme: &ColourSchemeValues) -> ColourSchemeColours<'static> {
763
763
+
ColourSchemeColours {
764
764
+
base: format!("#{}", scheme.base).into(),
765
765
+
surface: format!("#{}", scheme.surface).into(),
766
766
+
overlay: format!("#{}", scheme.overlay).into(),
767
767
+
text: format!("#{}", scheme.text).into(),
768
768
+
muted: format!("#{}", scheme.muted).into(),
769
769
+
subtle: format!("#{}", scheme.subtle).into(),
770
770
+
emphasis: format!("#{}", scheme.emphasis).into(),
771
771
+
primary: format!("#{}", scheme.primary).into(),
772
772
+
secondary: format!("#{}", scheme.secondary).into(),
773
773
+
tertiary: format!("#{}", scheme.tertiary).into(),
774
774
+
error: format!("#{}", scheme.error).into(),
775
775
+
warning: format!("#{}", scheme.warning).into(),
776
776
+
success: format!("#{}", scheme.success).into(),
777
777
+
border: format!("#{}", scheme.border).into(),
778
778
+
link: format!("#{}", scheme.link).into(),
779
779
+
highlight: format!("#{}", scheme.highlight).into(),
780
780
+
extra_data: None,
781
781
+
}
782
782
+
}
783
783
+
784
784
+
let light_colours = scheme_to_colours(&values.light);
785
785
+
let dark_colours = scheme_to_colours(&values.dark);
786
786
+
787
787
+
let light_code =
788
788
+
ThemeLightCodeTheme::CodeThemeName(Box::new(CowStr::from(values.light_code_theme.clone())));
789
789
+
let dark_code =
790
790
+
ThemeDarkCodeTheme::CodeThemeName(Box::new(CowStr::from(values.dark_code_theme.clone())));
791
791
+
let default_theme: Option<CowStr<'static>> = match values.default_mode.as_str() {
792
792
+
"light" => Some(CowStr::from("light")),
793
793
+
"dark" => Some(CowStr::from("dark")),
794
794
+
_ => None,
795
795
+
};
796
796
+
797
797
+
if let Some(theme_ref) = existing_theme_ref {
798
798
+
// UPDATE existing records.
799
799
+
let theme_uri = &theme_ref.uri;
800
800
+
let existing_theme: Theme<'static> = fetcher
801
801
+
.fetch_record(
802
802
+
&Theme::uri(theme_uri.as_ref())
803
803
+
.map_err(|e| WeaverError::InvalidNotebook(e.to_string().into()))?,
804
804
+
)
805
805
+
.await
806
806
+
.map_err(|e| WeaverError::InvalidNotebook(e.to_string().into()))?
807
807
+
.value
808
808
+
.into_static();
809
809
+
810
810
+
// Update light ColourScheme.
811
811
+
let light_scheme_rkey =
812
812
+
existing_theme.light_scheme.uri.rkey().ok_or_else(|| {
813
813
+
WeaverError::InvalidNotebook("Light scheme URI missing rkey".into())
814
814
+
})?;
815
815
+
let light_result = fetcher
816
816
+
.put_record(
817
817
+
RecordKey::any(light_scheme_rkey.as_ref())
818
818
+
.map_err(|e| WeaverError::InvalidNotebook(e.to_string().into()))?
819
819
+
.into_static(),
820
820
+
ColourScheme::new()
821
821
+
.name(CowStr::from("Custom Light"))
822
822
+
.variant(CowStr::from("light"))
823
823
+
.colours(light_colours)
824
824
+
.build(),
825
825
+
)
826
826
+
.await
827
827
+
.map_err(|e| WeaverError::InvalidNotebook(e.to_string().into()))?;
828
828
+
829
829
+
// Update dark ColourScheme.
830
830
+
let dark_scheme_rkey =
831
831
+
existing_theme.dark_scheme.uri.rkey().ok_or_else(|| {
832
832
+
WeaverError::InvalidNotebook("Dark scheme URI missing rkey".into())
833
833
+
})?;
834
834
+
let dark_result = fetcher
835
835
+
.put_record(
836
836
+
RecordKey::any(dark_scheme_rkey.as_ref())
837
837
+
.map_err(|e| WeaverError::InvalidNotebook(e.to_string().into()))?
838
838
+
.into_static(),
839
839
+
ColourScheme::new()
840
840
+
.name(CowStr::from("Custom Dark"))
841
841
+
.variant(CowStr::from("dark"))
842
842
+
.colours(dark_colours)
843
843
+
.build(),
844
844
+
)
845
845
+
.await
846
846
+
.map_err(|e| WeaverError::InvalidNotebook(e.to_string().into()))?;
847
847
+
848
848
+
// Update Theme with new CIDs.
849
849
+
let theme_rkey = theme_uri
850
850
+
.rkey()
851
851
+
.ok_or_else(|| WeaverError::InvalidNotebook("Theme URI missing rkey".into()))?;
852
852
+
let theme_result = fetcher
853
853
+
.put_record(
854
854
+
RecordKey::any(theme_rkey.as_ref())
855
855
+
.map_err(|e| WeaverError::InvalidNotebook(e.to_string().into()))?
856
856
+
.into_static(),
857
857
+
Theme::new()
858
858
+
.light_scheme(
859
859
+
StrongRef::new()
860
860
+
.uri(light_result.uri.clone().into_static())
861
861
+
.cid(light_result.cid.clone().into_static())
862
862
+
.build(),
863
863
+
)
864
864
+
.dark_scheme(
865
865
+
StrongRef::new()
866
866
+
.uri(dark_result.uri.clone().into_static())
867
867
+
.cid(dark_result.cid.clone().into_static())
868
868
+
.build(),
869
869
+
)
870
870
+
.light_code_theme(light_code)
871
871
+
.dark_code_theme(dark_code)
872
872
+
.fonts(
873
873
+
ThemeFonts::new()
874
874
+
.body(vec![])
875
875
+
.heading(vec![])
876
876
+
.monospace(vec![])
877
877
+
.build(),
878
878
+
)
879
879
+
.spacing(ThemeSpacing {
880
880
+
base_size: CowStr::from(values.spacing_base.clone()),
881
881
+
line_height: CowStr::from(values.spacing_line_height.clone()),
882
882
+
scale: CowStr::from(values.spacing_scale.clone()),
883
883
+
extra_data: None,
884
884
+
})
885
885
+
.maybe_default_theme(default_theme)
886
886
+
.build(),
887
887
+
)
888
888
+
.await
889
889
+
.map_err(|e| WeaverError::InvalidNotebook(e.to_string().into()))?;
890
890
+
891
891
+
Ok(FullThemeSyncResult {
892
892
+
theme_uri: theme_result.uri.into_static(),
893
893
+
theme_cid: theme_result.cid.into_static(),
894
894
+
})
895
895
+
} else {
896
896
+
// CREATE new records.
897
897
+
let light_scheme = ColourScheme::new()
898
898
+
.name(CowStr::from("Custom Light"))
899
899
+
.variant(CowStr::from("light"))
900
900
+
.colours(light_colours)
901
901
+
.build();
902
902
+
903
903
+
let light_result = fetcher
904
904
+
.create_record(light_scheme, None)
905
905
+
.await
906
906
+
.map_err(|e| WeaverError::InvalidNotebook(e.to_string().into()))?;
907
907
+
908
908
+
let dark_scheme = ColourScheme::new()
909
909
+
.name(CowStr::from("Custom Dark"))
910
910
+
.variant(CowStr::from("dark"))
911
911
+
.colours(dark_colours)
912
912
+
.build();
913
913
+
914
914
+
let dark_result = fetcher
915
915
+
.create_record(dark_scheme, None)
916
916
+
.await
917
917
+
.map_err(|e| WeaverError::InvalidNotebook(e.to_string().into()))?;
918
918
+
919
919
+
let theme = Theme::new()
920
920
+
.light_scheme(
921
921
+
StrongRef::new()
922
922
+
.uri(light_result.uri.clone().into_static())
923
923
+
.cid(light_result.cid.clone().into_static())
924
924
+
.build(),
925
925
+
)
926
926
+
.dark_scheme(
927
927
+
StrongRef::new()
928
928
+
.uri(dark_result.uri.clone().into_static())
929
929
+
.cid(dark_result.cid.clone().into_static())
930
930
+
.build(),
931
931
+
)
932
932
+
.light_code_theme(light_code)
933
933
+
.dark_code_theme(dark_code)
934
934
+
.fonts(
935
935
+
ThemeFonts::new()
936
936
+
.body(vec![])
937
937
+
.heading(vec![])
938
938
+
.monospace(vec![])
939
939
+
.build(),
940
940
+
)
941
941
+
.spacing(ThemeSpacing {
942
942
+
base_size: CowStr::from(values.spacing_base.clone()),
943
943
+
line_height: CowStr::from(values.spacing_line_height.clone()),
944
944
+
scale: CowStr::from(values.spacing_scale.clone()),
945
945
+
extra_data: None,
946
946
+
})
947
947
+
.maybe_default_theme(default_theme)
948
948
+
.build();
949
949
+
950
950
+
let theme_result = fetcher
951
951
+
.create_record(theme, None)
952
952
+
.await
953
953
+
.map_err(|e| WeaverError::InvalidNotebook(e.to_string().into()))?;
954
954
+
955
955
+
Ok(FullThemeSyncResult {
956
956
+
theme_uri: theme_result.uri.into_static(),
957
957
+
theme_cid: theme_result.cid.into_static(),
958
958
+
})
959
959
+
}
960
960
+
}