tangled
alpha
login
or
join now
nonbinary.computer
/
weaver
atproto blogging
24
fork
atom
overview
issues
2
pulls
pipelines
fuckin around with styling
Orual
2 months ago
d3731e20
0fb1e75d
+949
-225
18 changed files
expand all
collapse all
unified
split
crates
weaver-app
assets
styling
entry-card.css
entry.css
main.css
notebook-card.css
notebook-cover.css
profile.css
theme-defaults.css
src
components
entry.rs
identity.rs
mod.rs
notebook_cover.rs
profile.rs
views
home.rs
navbar.rs
notebook.rs
weaver-common
src
agent.rs
weaver-renderer
src
css.rs
static_site
document.rs
+74
-16
crates/weaver-app/assets/styling/entry-card.css
···
1
1
/* Entry card styling */
2
2
3
3
-
.entries-list {
4
4
-
max-width: 800px;
3
3
+
/* Notebook layout - sidebar in left gutter on desktop, header on mobile */
4
4
+
.notebook-layout {
5
5
+
display: grid;
6
6
+
grid-template-columns: minmax(280px, 1fr) minmax(0, 90ch) minmax(280px, 1fr);
7
7
+
gap: 2rem;
8
8
+
max-width: calc(90ch + 560px + 4rem); /* content + gutters + gaps */
5
9
margin: 0 auto;
6
6
-
padding: 2rem 1rem;
10
10
+
padding: 2.5rem 1.25rem 2.5rem 0;
11
11
+
}
12
12
+
13
13
+
.notebook-sidebar {
14
14
+
grid-column: 1;
15
15
+
position: sticky;
16
16
+
top: 2rem;
17
17
+
align-self: flex-start;
18
18
+
max-height: calc(100vh - 4rem);
19
19
+
overflow-y: auto;
20
20
+
}
21
21
+
22
22
+
.notebook-main {
23
23
+
grid-column: 2;
24
24
+
padding: 0 1rem;
25
25
+
}
26
26
+
27
27
+
/* Mobile layout - sidebar becomes header */
28
28
+
@media (max-width: 1400px) {
29
29
+
.notebook-layout {
30
30
+
grid-template-columns: 1fr !important;
31
31
+
gap: 0 !important;
32
32
+
max-width: 100vw !important;
33
33
+
box-sizing: border-box !important;
34
34
+
}
35
35
+
36
36
+
.notebook-sidebar {
37
37
+
grid-column: 1;
38
38
+
position: static;
39
39
+
max-height: none;
40
40
+
min-width: 0;
41
41
+
}
42
42
+
43
43
+
.notebook-main {
44
44
+
grid-column: 1;
45
45
+
padding: 1.25rem;
46
46
+
min-width: 0;
47
47
+
}
48
48
+
}
49
49
+
50
50
+
/* Entries list - width constrained by grid column */
51
51
+
.entries-list {
7
52
}
8
53
9
54
.entry-card {
···
13
58
.entry-card-link {
14
59
display: block;
15
60
background: var(--color-surface);
16
16
-
border: 1px solid var(--color-border);
17
17
-
border-radius: 6px;
61
61
+
box-shadow: 0 1px 2px color-mix(in srgb, var(--color-text) 6%, transparent);
62
62
+
border-left: 2px solid transparent;
18
63
padding: 1.25rem;
19
64
text-decoration: none;
20
65
color: var(--color-text);
21
21
-
transition: all 0.2s ease;
66
66
+
transition:
67
67
+
box-shadow 0.2s ease,
68
68
+
border-color 0.2s ease;
22
69
}
23
70
24
71
.entry-card-link:hover {
25
25
-
background: var(--color-overlay);
26
26
-
border-color: var(--color-secondary);
27
27
-
box-shadow: 0 2px 6px color-mix(in srgb, var(--color-secondary) 8%, transparent);
28
28
-
transform: translateY(-1px);
72
72
+
box-shadow: 0 2px 4px color-mix(in srgb, var(--color-text) 10%, transparent);
73
73
+
border-left-color: var(--color-secondary);
74
74
+
}
75
75
+
76
76
+
.entry-card-link:hover .entry-card-title {
77
77
+
color: var(--color-secondary);
29
78
}
30
79
31
80
.entry-card-header {
···
38
87
color: var(--color-primary);
39
88
margin: 0;
40
89
font-family: var(--font-heading);
90
90
+
transition: color 0.2s ease;
41
91
}
42
92
43
93
.entry-card-meta {
···
73
123
}
74
124
75
125
.entry-card-tag {
76
76
-
padding: 0.2rem 0.5rem;
77
77
-
background: var(--color-base);
78
78
-
border: 1px solid var(--color-border);
79
79
-
border-radius: 3px;
126
126
+
padding: 0.2rem 0.4rem 0.2rem 0;
80
127
font-size: 0.75rem;
81
128
color: var(--color-subtle);
82
82
-
transition: all 0.15s ease;
129
129
+
border-bottom: 1px solid var(--color-border);
130
130
+
transition:
131
131
+
color 0.15s ease,
132
132
+
border-color 0.15s ease;
83
133
}
84
134
85
135
.entry-card-link:hover .entry-card-tag {
86
86
-
background: var(--color-surface);
136
136
+
color: var(--color-tertiary);
87
137
border-color: var(--color-tertiary);
88
138
}
89
139
···
128
178
.entry-card-preview code {
129
179
font-size: 0.8rem;
130
180
}
181
181
+
182
182
+
/* Dark mode: replace shadows with borders */
183
183
+
@media (prefers-color-scheme: dark) {
184
184
+
.entry-card-link {
185
185
+
box-shadow: none;
186
186
+
border: 1px solid var(--color-border);
187
187
+
}
188
188
+
}
+34
-34
crates/weaver-app/assets/styling/entry.css
···
1
1
/* Entry page layout with gutter navigation */
2
2
.entry-page-layout {
3
3
display: grid;
4
4
-
grid-template-columns: minmax(0, 1fr) minmax(0, 90ch) minmax(0, 1fr);
5
5
-
gap: 0;
4
4
+
grid-template-columns: minmax(200px, 1fr) minmax(0, 90ch) minmax(200px, 1fr);
5
5
+
gap: 2rem;
6
6
width: 100%;
7
7
min-height: 100vh;
8
8
background: var(--color-base);
9
9
+
max-width: calc(90ch + 400px + 4rem); /* content + gutters + gaps */
10
10
+
margin: 0 auto;
11
11
+
padding: 0 1rem 0 0;
9
12
}
10
13
11
14
/* Main content area */
···
18
21
.nav-gutter {
19
22
position: sticky;
20
23
top: auto;
21
21
-
bottom: calc(2rem * var(--spacing-scale, 1.5));
24
24
+
bottom: 2rem;
22
25
height: fit-content;
23
26
align-self: end;
24
27
}
25
28
26
29
.nav-prev {
27
30
grid-column: 1;
28
28
-
padding-left: calc(1rem * var(--spacing-scale, 1.5));
29
31
}
30
32
31
33
.nav-next {
32
34
grid-column: 3;
33
33
-
padding-right: calc(1rem * var(--spacing-scale, 1.5));
34
35
}
35
36
36
37
/* Navigation buttons */
37
38
.nav-button {
38
39
display: flex;
39
40
flex-direction: column;
40
40
-
gap: calc(0.5rem * var(--spacing-scale, 1.5));
41
41
-
padding: calc(1rem * var(--spacing-scale, 1.5));
41
41
+
gap: 0.5rem;
42
42
+
padding: 1rem;
42
43
background: var(--color-surface);
43
43
-
border: 2px solid var(--color-border);
44
44
-
border-radius: 4px;
44
44
+
box-shadow: 0 1px 3px color-mix(in srgb, var(--color-text) 8%, transparent);
45
45
text-decoration: none;
46
46
color: var(--color-text);
47
47
-
transition: all 0.2s ease;
47
47
+
transition:
48
48
+
box-shadow 0.2s ease,
49
49
+
border-color 0.2s ease;
48
50
}
49
51
50
52
.nav-button:hover {
51
51
-
background: var(--color-overlay);
52
52
-
border-color: var(--color-primary);
53
53
-
box-shadow: 0 2px 8px color-mix(in srgb, var(--color-primary) 20%, transparent);
53
53
+
box-shadow: 0 2px 6px color-mix(in srgb, var(--color-text) 12%, transparent);
54
54
+
}
55
55
+
56
56
+
/* Dark mode: borders instead of shadows */
57
57
+
@media (prefers-color-scheme: dark) {
58
58
+
.nav-button {
59
59
+
box-shadow: none;
60
60
+
border: 1px dashed var(--color-border);
61
61
+
}
62
62
+
63
63
+
.nav-button:hover {
64
64
+
box-shadow: none;
65
65
+
border-color: var(--color-primary);
66
66
+
}
54
67
}
55
68
56
69
.nav-button-prev {
···
74
87
color: var(--color-emphasis);
75
88
}
76
89
77
77
-
.nav-label {
78
78
-
font-size: 0.875rem;
79
79
-
font-weight: 600;
80
80
-
text-transform: uppercase;
81
81
-
letter-spacing: 0.05em;
82
82
-
color: var(--color-subtle);
83
83
-
transition: color 0.2s ease;
84
84
-
}
85
85
-
86
90
.nav-button:hover .nav-label {
87
91
color: var(--color-secondary);
88
92
}
···
90
94
.nav-title {
91
95
font-size: 0.95rem;
92
96
font-weight: 500;
93
93
-
max-width: 20ch;
94
94
-
overflow: hidden;
95
95
-
text-overflow: ellipsis;
96
96
-
white-space: nowrap;
97
97
+
line-height: 1.4;
97
98
}
98
99
99
100
/* Entry metadata header */
···
155
156
}
156
157
157
158
.entry-tag {
158
158
-
padding: 0.25rem 0.75rem;
159
159
-
background: var(--color-surface);
160
160
-
border: 1px solid var(--color-border);
161
161
-
border-radius: 3px;
159
159
+
padding: 0.25rem 0.5rem 0.25rem 0;
162
160
font-size: 0.85rem;
163
161
color: var(--color-subtle);
162
162
+
border-bottom: 1px solid var(--color-border);
164
163
text-decoration: none;
165
165
-
transition: all 0.2s ease;
164
164
+
transition:
165
165
+
color 0.2s ease,
166
166
+
border-color 0.2s ease;
166
167
}
167
168
168
169
.entry-tag:hover {
169
169
-
background: var(--color-overlay);
170
170
+
color: var(--color-tertiary);
170
171
border-color: var(--color-tertiary);
171
171
-
color: var(--color-text);
172
172
}
173
173
174
174
/* Content styling */
···
204
204
}
205
205
206
206
/* Responsive layout */
207
207
-
@media (max-width: 1200px) {
207
207
+
@media (max-width: 1400px) {
208
208
.entry-page-layout {
209
209
grid-template-columns: 1fr;
210
210
gap: 0;
+1
-35
crates/weaver-app/assets/styling/main.css
···
5
5
margin: 20px;
6
6
}
7
7
8
8
-
#hero {
9
9
-
margin: 0;
10
10
-
display: flex;
11
11
-
flex-direction: column;
12
12
-
justify-content: center;
13
13
-
align-items: center;
14
14
-
}
15
15
-
16
16
-
#links {
17
17
-
width: 400px;
18
18
-
text-align: left;
19
19
-
font-size: x-large;
20
20
-
color: var(--color-text);
21
21
-
display: flex;
22
22
-
flex-direction: column;
23
23
-
}
24
24
-
25
25
-
#links a {
26
26
-
color: var(--color-link);
27
27
-
text-decoration: none;
28
28
-
margin-top: 20px;
29
29
-
margin: 10px 0px;
30
30
-
border: 1px solid var(--color-border);
31
31
-
border-radius: 5px;
32
32
-
padding: 10px;
33
33
-
transition: all 0.2s ease;
34
34
-
}
35
35
-
36
36
-
#links a:hover {
37
37
-
background-color: var(--color-surface);
38
38
-
border-color: var(--color-primary);
39
39
-
cursor: pointer;
40
40
-
}
41
41
-
42
8
#header {
43
9
max-width: 1200px;
44
44
-
}
10
10
+
}
+192
-38
crates/weaver-app/assets/styling/notebook-card.css
···
1
1
/* Notebook card styling */
2
2
3
3
-
.notebooks-list {
4
4
-
max-width: 800px;
3
3
+
/* Repository layout - sidebar in left gutter on desktop, header on mobile */
4
4
+
5
5
+
.repository-layout {
6
6
+
display: grid;
7
7
+
grid-template-columns: minmax(240px, 1fr) minmax(0, 90ch) minmax(240px, 1fr);
8
8
+
gap: 2rem;
9
9
+
max-width: calc(90ch + 480px + 4rem); /* content + gutters + gaps */
5
10
margin: 0 auto;
6
6
-
padding: 2rem 1rem;
11
11
+
padding: 2.25rem 1.25rem 2.25rem 0;
12
12
+
}
13
13
+
14
14
+
.repository-sidebar {
15
15
+
grid-column: 1;
16
16
+
position: sticky;
17
17
+
top: 2rem;
18
18
+
align-self: flex-start;
19
19
+
overflow-y: auto;
20
20
+
}
21
21
+
22
22
+
.repository-main {
23
23
+
grid-column: 2;
24
24
+
padding: 0 1rem;
25
25
+
}
26
26
+
27
27
+
/* Mobile layout - sidebar becomes header */
28
28
+
@media (max-width: 1400px) {
29
29
+
.repository-layout {
30
30
+
grid-template-columns: 1fr !important;
31
31
+
gap: 0 !important;
32
32
+
max-width: 100vw !important;
33
33
+
box-sizing: border-box !important;
34
34
+
}
35
35
+
36
36
+
.repository-sidebar {
37
37
+
grid-column: 1;
38
38
+
position: static;
39
39
+
max-height: none;
40
40
+
min-width: 0;
41
41
+
margin-bottom: 2rem;
42
42
+
}
43
43
+
44
44
+
.repository-main {
45
45
+
grid-column: 1;
46
46
+
padding: 0;
47
47
+
min-width: 0;
48
48
+
}
49
49
+
}
50
50
+
51
51
+
/* Notebook list - width constrained by grid column */
52
52
+
.notebooks-list {
53
53
+
margin-top: 0.25rem;
7
54
}
8
55
9
56
.notebook-card {
10
10
-
margin-bottom: calc(1.5rem * var(--spacing-scale, 1.25));
57
57
+
margin-bottom: 2.5rem; /* 2 grid units */
58
58
+
}
59
59
+
60
60
+
.notebook-card-container {
61
61
+
background: var(--color-surface);
62
62
+
box-shadow: 0 1px 3px color-mix(in srgb, var(--color-text) 8%, transparent);
63
63
+
padding: 1.25rem;
11
64
}
12
65
13
13
-
.notebook-card-link {
66
66
+
.notebook-card-header-link {
14
67
display: block;
15
15
-
background: var(--color-surface);
16
16
-
border: 1px solid var(--color-border);
17
17
-
border-radius: 8px;
18
18
-
padding: 1.5rem;
19
68
text-decoration: none;
20
69
color: var(--color-text);
21
21
-
transition: all 0.2s ease;
70
70
+
border-left: 3px solid transparent;
71
71
+
padding-left: 0.75rem;
72
72
+
margin-left: -0.75rem;
73
73
+
transition: border-color 0.2s ease;
74
74
+
}
75
75
+
76
76
+
.notebook-card-header-link:hover {
77
77
+
border-left-color: var(--color-primary);
22
78
}
23
79
24
24
-
.notebook-card-link:hover {
25
25
-
background: var(--color-overlay);
26
26
-
border-color: var(--color-primary);
80
80
+
.notebook-card-header-link:hover .notebook-card-title {
81
81
+
color: var(--color-secondary);
27
82
}
28
83
29
84
.notebook-card-header {
30
30
-
margin-bottom: 1rem;
85
85
+
margin-bottom: 1.25rem;
86
86
+
padding-bottom: 1.25rem;
87
87
+
border-bottom: 2px solid var(--color-border);
31
88
}
32
89
33
90
.notebook-card-title {
···
36
93
color: var(--color-primary);
37
94
margin: 0 0 0.5rem 0;
38
95
font-family: var(--font-heading);
96
96
+
transition: color 0.2s ease;
39
97
}
40
98
41
41
-
.notebook-card-description {
99
99
+
.notebook-card-date {
42
100
color: var(--color-muted);
43
43
-
line-height: 1.5;
44
44
-
margin: 0;
45
45
-
display: -webkit-box;
46
46
-
-webkit-line-clamp: 2;
47
47
-
-webkit-box-orient: vertical;
48
48
-
overflow: hidden;
101
101
+
font-size: 0.85rem;
49
102
}
50
103
51
51
-
.notebook-card-meta {
104
104
+
.notebook-card-authors {
52
105
display: flex;
53
53
-
align-items: center;
54
54
-
gap: 1rem;
55
106
flex-wrap: wrap;
56
56
-
margin-bottom: 0.75rem;
107
107
+
gap: 0.5rem;
108
108
+
margin-bottom: 1rem;
57
109
font-size: 0.9rem;
58
110
color: var(--color-subtle);
59
111
}
60
112
61
61
-
.notebook-card-author {
113
113
+
.author-separator {
114
114
+
color: var(--color-muted);
115
115
+
}
116
116
+
117
117
+
/* Entry previews within notebook card */
118
118
+
.notebook-card-previews {
62
119
display: flex;
63
63
-
align-items: center;
64
64
-
gap: 0.5rem;
120
120
+
flex-direction: column;
121
121
+
margin-bottom: 0; /* Let card padding handle bottom spacing */
65
122
}
66
123
67
67
-
.notebook-card-author .author-name {
68
68
-
font-weight: 500;
124
124
+
.notebook-entry-preview-link {
125
125
+
display: block;
126
126
+
text-decoration: none;
69
127
color: var(--color-text);
128
128
+
border-left: 2px solid transparent;
129
129
+
border-top: 1px solid var(--color-border);
130
130
+
padding-left: 0.625rem; /* 0.5 grid */
131
131
+
padding-top: 1.25rem;
132
132
+
margin-left: -0.625rem;
133
133
+
transition: border-left-color 0.2s ease;
70
134
}
71
135
72
72
-
.notebook-card-date {
73
73
-
margin-left: auto;
136
136
+
.notebook-entry-preview-link:first-child {
137
137
+
border-top: none;
138
138
+
padding-top: 0;
139
139
+
}
140
140
+
141
141
+
.notebook-entry-preview-link:hover {
142
142
+
border-left-color: var(--color-secondary);
143
143
+
}
144
144
+
145
145
+
.notebook-entry-preview-link:hover .entry-preview-title {
146
146
+
color: var(--color-secondary);
147
147
+
}
148
148
+
149
149
+
.notebook-entry-preview {
150
150
+
padding-bottom: 1.25rem;
151
151
+
}
152
152
+
153
153
+
.entry-preview-header {
154
154
+
display: flex;
155
155
+
justify-content: space-between;
156
156
+
align-items: baseline;
157
157
+
gap: 1rem;
158
158
+
margin-bottom: 0.5rem;
159
159
+
}
160
160
+
161
161
+
.entry-preview-title {
162
162
+
color: var(--color-text);
163
163
+
font-weight: 600;
164
164
+
font-size: 0.95rem;
165
165
+
flex: 1;
166
166
+
transition: color 0.2s ease;
167
167
+
}
168
168
+
169
169
+
.entry-preview-date {
170
170
+
color: var(--color-muted);
171
171
+
font-size: 0.8rem;
172
172
+
white-space: nowrap;
173
173
+
}
174
174
+
175
175
+
.notebook-entry-interstitial {
176
176
+
text-align: center;
74
177
color: var(--color-muted);
75
178
font-size: 0.85rem;
179
179
+
padding: 1rem 0;
180
180
+
font-style: italic;
181
181
+
}
182
182
+
183
183
+
.entry-preview-content {
184
184
+
color: var(--color-subtle);
185
185
+
font-size: 0.875rem;
186
186
+
line-height: 1.5;
187
187
+
display: -webkit-box;
188
188
+
-webkit-line-clamp: 3;
189
189
+
-webkit-box-orient: vertical;
190
190
+
overflow: hidden;
191
191
+
max-width: 100%;
192
192
+
word-wrap: break-word;
193
193
+
overflow-wrap: break-word;
194
194
+
}
195
195
+
196
196
+
.entry-preview-content p {
197
197
+
margin: 0;
198
198
+
display: inline;
199
199
+
}
200
200
+
201
201
+
.entry-preview-content h1,
202
202
+
.entry-preview-content h2,
203
203
+
.entry-preview-content h3,
204
204
+
.entry-preview-content h4,
205
205
+
.entry-preview-content h5,
206
206
+
.entry-preview-content h6 {
207
207
+
font-size: 0.875rem;
208
208
+
font-weight: 600;
209
209
+
margin: 0;
210
210
+
}
211
211
+
212
212
+
.entry-preview-content code {
213
213
+
font-size: 0.8rem;
214
214
+
white-space: pre-wrap;
215
215
+
word-break: break-all;
216
216
+
}
217
217
+
218
218
+
.entry-preview-content pre {
219
219
+
white-space: pre-wrap;
220
220
+
word-break: break-all;
221
221
+
max-width: 100%;
76
222
}
77
223
78
224
.notebook-card-tags {
···
82
228
}
83
229
84
230
.notebook-card-tag {
85
85
-
padding: 0.25rem 0.625rem;
86
86
-
background: var(--color-base);
87
87
-
border: 1px solid var(--color-border);
88
88
-
border-radius: 3px;
231
231
+
padding: 0.25rem 0.5rem 0.25rem 0;
89
232
font-size: 0.8rem;
90
233
color: var(--color-subtle);
91
91
-
transition: all 0.15s ease;
234
234
+
border-bottom: 1px solid var(--color-border);
235
235
+
transition:
236
236
+
color 0.15s ease,
237
237
+
border-color 0.15s ease;
92
238
}
93
239
94
240
.notebook-card-link:hover .notebook-card-tag {
95
95
-
background: var(--color-surface);
241
241
+
color: var(--color-tertiary);
96
242
border-color: var(--color-tertiary);
97
243
}
98
244
···
106
252
-webkit-box-orient: vertical;
107
253
overflow: hidden;
108
254
}
255
255
+
256
256
+
/* Dark mode: replace shadows with borders */
257
257
+
@media (prefers-color-scheme: dark) {
258
258
+
.notebook-card-container {
259
259
+
box-shadow: none;
260
260
+
border: 1px dashed var(--color-border);
261
261
+
}
262
262
+
}
+110
crates/weaver-app/assets/styling/notebook-cover.css
···
1
1
+
/* Notebook cover - sidebar on desktop, header on mobile */
2
2
+
3
3
+
.notebook-cover {
4
4
+
/* No background - same plane as page */
5
5
+
}
6
6
+
7
7
+
/* Desktop: sidebar gets top and right borders */
8
8
+
@media (min-width: 1400px) {
9
9
+
.notebook-cover {
10
10
+
border-top: 1px solid var(--color-border);
11
11
+
border-right: 1px solid var(--color-border);
12
12
+
border-bottom: 1px solid var(--color-border);
13
13
+
padding: 1.25rem;
14
14
+
}
15
15
+
}
16
16
+
17
17
+
/* Mobile: header gets top and bottom borders */
18
18
+
@media (max-width: 1400px) {
19
19
+
.notebook-cover {
20
20
+
border-top: 1px solid var(--color-border);
21
21
+
border-bottom: 1px solid var(--color-border);
22
22
+
padding: 1.25rem 0;
23
23
+
margin-bottom: 2rem;
24
24
+
}
25
25
+
}
26
26
+
27
27
+
.notebook-cover-title {
28
28
+
font-size: 1.5rem;
29
29
+
font-weight: 700;
30
30
+
color: var(--color-primary);
31
31
+
margin: 0 0 1rem 0;
32
32
+
font-family: var(--font-heading);
33
33
+
}
34
34
+
35
35
+
.notebook-cover-authors {
36
36
+
margin-bottom: 1.25rem;
37
37
+
}
38
38
+
39
39
+
.notebook-authors-list {
40
40
+
display: flex;
41
41
+
flex-direction: column;
42
42
+
gap: 1rem;
43
43
+
}
44
44
+
45
45
+
.author-separator {
46
46
+
display: none;
47
47
+
}
48
48
+
49
49
+
.notebook-author {
50
50
+
display: flex;
51
51
+
align-items: center;
52
52
+
gap: 0.75rem;
53
53
+
}
54
54
+
55
55
+
.notebook-author .avatar {
56
56
+
width: 48px;
57
57
+
height: 48px;
58
58
+
flex-shrink: 0;
59
59
+
}
60
60
+
61
61
+
.notebook-author-info {
62
62
+
display: flex;
63
63
+
flex-direction: column;
64
64
+
min-width: 0;
65
65
+
}
66
66
+
67
67
+
.notebook-author-name {
68
68
+
font-weight: 600;
69
69
+
color: var(--color-text);
70
70
+
font-size: 1rem;
71
71
+
}
72
72
+
73
73
+
.notebook-author-handle {
74
74
+
color: var(--color-subtle);
75
75
+
font-size: 0.875rem;
76
76
+
}
77
77
+
78
78
+
.notebook-cover-description {
79
79
+
color: var(--color-text);
80
80
+
line-height: 1.6;
81
81
+
margin-bottom: 1rem;
82
82
+
white-space: pre-wrap;
83
83
+
}
84
84
+
85
85
+
.notebook-cover-meta {
86
86
+
display: flex;
87
87
+
gap: 1.5rem;
88
88
+
align-items: center;
89
89
+
flex-wrap: wrap;
90
90
+
margin-bottom: 1rem;
91
91
+
font-size: 0.9rem;
92
92
+
color: var(--color-subtle);
93
93
+
}
94
94
+
95
95
+
.notebook-cover-stat {
96
96
+
font-weight: 500;
97
97
+
}
98
98
+
99
99
+
.notebook-cover-tags {
100
100
+
display: flex;
101
101
+
gap: 0.5rem;
102
102
+
flex-wrap: wrap;
103
103
+
}
104
104
+
105
105
+
.notebook-cover-tag {
106
106
+
padding: 0.25rem 0.5rem 0.25rem 0;
107
107
+
font-size: 0.85rem;
108
108
+
color: var(--color-subtle);
109
109
+
border-bottom: 1px solid var(--color-border);
110
110
+
}
+35
-12
crates/weaver-app/assets/styling/profile.css
···
1
1
/* Profile display - sidebar on desktop, header on mobile */
2
2
3
3
.profile-display {
4
4
-
background: var(--color-surface);
5
5
-
border: 1px solid var(--color-border);
6
6
-
border-radius: 4px;
4
4
+
margin-top: 0.25rem;
5
5
+
max-width: 100%;
6
6
+
/* No background - same plane as page */
7
7
+
}
8
8
+
9
9
+
/* Desktop: sidebar gets top and right borders */
10
10
+
@media (min-width: 1400px) {
11
11
+
.profile-display {
12
12
+
border-top: 1.5px dashed var(--color-border);
13
13
+
border-right: 1.5px dashed var(--color-border);
14
14
+
}
15
15
+
}
16
16
+
17
17
+
/* Mobile: header gets top and bottom borders */
18
18
+
@media (max-width: 1400px) {
19
19
+
.profile-display {
20
20
+
border-top: 1.5px dashed var(--color-border);
21
21
+
border-bottom: 1.5px solid var(--color-border);
22
22
+
}
7
23
}
8
24
9
25
.profile-banner {
10
10
-
width: 100%;
26
26
+
max-width: 100%;
11
27
height: 120px;
12
28
overflow: hidden;
13
13
-
border-radius: 4px 4px 0 0;
14
29
}
15
30
16
31
.profile-banner img {
···
20
35
}
21
36
22
37
.profile-content {
23
23
-
padding: 1.5rem;
38
38
+
padding: 1.25rem;
24
39
}
25
40
26
41
.profile-identity {
27
27
-
margin-bottom: 1.5rem;
42
42
+
margin-bottom: 1.25rem; /* Grid unit */
28
43
}
29
44
30
45
.profile-identity .avatar {
···
64
79
65
80
.profile-description {
66
81
color: var(--color-text);
82
82
+
font-size: 0.875rem;
67
83
line-height: 1.5;
68
84
margin-top: 0.75rem;
69
85
white-space: pre-wrap;
···
72
88
.profile-stats {
73
89
display: grid;
74
90
grid-template-columns: repeat(auto-fit, minmax(100px, 1fr));
75
75
-
gap: 1rem;
76
76
-
padding: 1rem 0;
77
77
-
margin: 1rem 0;
78
78
-
border-top: 1px solid var(--color-border);
79
79
-
border-bottom: 1px solid var(--color-border);
91
91
+
gap: 1.25rem;
92
92
+
padding: 1.25rem 0;
93
93
+
margin: 1.25rem 0;
94
94
+
border-top: 1.5px dashed var(--color-border);
95
95
+
border-bottom: 1.5px dashed var(--color-border);
80
96
}
81
97
82
98
.profile-stat {
···
134
150
font-size: 1.25rem;
135
151
}
136
152
}
153
153
+
154
154
+
@media (prefers-color-scheme: dark) {
155
155
+
.profile-display {
156
156
+
background-color: var(--color-surface);
157
157
+
border: 1px dashed var(--color-border);
158
158
+
}
159
159
+
}
+4
-3
crates/weaver-app/assets/styling/theme-defaults.css
···
20
20
--color-link: #d7827e;
21
21
--color-highlight: #cecacd;
22
22
23
23
-
--font-body: IBM Plex, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
24
24
-
--font-heading: IBM Plex Sans, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
25
25
-
--font-mono: 'IBM Plex Mono', 'Berkeley Mono', 'Cascadia Code', 'Roboto Mono', Consolas, monospace;
23
23
+
--font-body: IBM Plex, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
24
24
+
--font-heading: IBM Plex Sans, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
25
25
+
--font-mono: "IBM Plex Mono", "Berkeley Mono", "Cascadia Code", "Roboto Mono", Consolas, monospace;
26
26
27
27
--spacing-base: 16px;
28
28
--spacing-line-height: 1.6;
···
49
49
--color-link: #ebbcba;
50
50
--color-highlight: #524f67;
51
51
}
52
52
+
52
53
}
+13
-11
crates/weaver-app/src/components/entry.rs
···
130
130
}
131
131
132
132
// Main content area
133
133
-
div { class: "entry-content-main",
133
133
+
div { class: "entry-content-main notebook-content",
134
134
// Metadata header
135
135
EntryMetadata {
136
136
entry_view: entry_view.clone(),
···
161
161
}
162
162
163
163
#[component]
164
164
-
pub fn EntryCard(entry: BookEntryView<'static>, book_title: SmolStr) -> Element {
164
164
+
pub fn EntryCard(
165
165
+
entry: BookEntryView<'static>,
166
166
+
book_title: SmolStr,
167
167
+
author_count: usize,
168
168
+
) -> Element {
165
169
use crate::Route;
166
170
use jacquard::{from_data, IntoStatic};
167
171
use weaver_api::sh_weaver::notebook::entry::Entry;
···
182
186
.format("%B %d, %Y")
183
187
.to_string();
184
188
185
185
-
// Get first author for display
186
186
-
let first_author = entry_view.authors.first();
189
189
+
// Only show author if notebook has multiple authors
190
190
+
let show_author = author_count > 1;
191
191
+
let first_author = if show_author {
192
192
+
entry_view.authors.first()
193
193
+
} else {
194
194
+
None
195
195
+
};
187
196
188
197
// Render preview from entry content
189
198
let preview_html = from_data::<Entry>(&entry_view.record).ok().map(|entry| {
···
403
412
.as_ref()
404
413
.map(|t| t.as_ref())
405
414
.unwrap_or("Untitled");
406
406
-
407
407
-
let label = if direction == "prev" {
408
408
-
"← Previous"
409
409
-
} else {
410
410
-
"Next →"
411
411
-
};
412
415
let arrow = if direction == "prev" { "←" } else { "→" };
413
416
414
417
rsx! {
···
420
423
},
421
424
class: "nav-button nav-button-{direction}",
422
425
div { class: "nav-arrow", "{arrow}" }
423
423
-
div { class: "nav-label", "{label}" }
424
426
div { class: "nav-title", "{entry_title}" }
425
427
}
426
428
}
+238
-52
crates/weaver-app/src/components/identity.rs
···
1
1
use crate::{fetch, Route};
2
2
use dioxus::prelude::*;
3
3
-
use jacquard::types::ident::AtIdentifier;
3
3
+
use jacquard::{smol_str::SmolStr, types::ident::AtIdentifier};
4
4
+
use weaver_api::com_atproto::repo::strong_ref::StrongRef;
4
5
use weaver_api::sh_weaver::notebook::NotebookView;
5
6
6
7
const NOTEBOOK_CARD_CSS: Asset = asset!("/assets/styling/notebook-card.css");
···
17
18
18
19
#[component]
19
20
pub fn RepositoryIndex(ident: AtIdentifier<'static>) -> Element {
21
21
+
use crate::components::ProfileDisplay;
22
22
+
20
23
let fetcher = use_context::<fetch::CachedFetcher>();
21
24
22
25
// Fetch notebooks for this specific DID
···
26
29
}));
27
30
28
31
rsx! {
29
29
-
document::Link { rel: "stylesheet", href: NOTEBOOK_CARD_CSS }
32
32
+
document::Stylesheet { href: NOTEBOOK_CARD_CSS }
33
33
+
34
34
+
div { class: "repository-layout",
35
35
+
// Profile sidebar (desktop) / header (mobile)
36
36
+
aside { class: "repository-sidebar",
37
37
+
ProfileDisplay { ident: ident.clone() }
38
38
+
}
30
39
31
31
-
div { class: "notebooks-list",
32
32
-
match notebooks() {
33
33
-
Some(Ok(notebook_list)) => rsx! {
34
34
-
for notebook in notebook_list.iter() {
35
35
-
{
36
36
-
let view = ¬ebook.0;
37
37
-
rsx! {
38
38
-
div {
39
39
-
key: "{view.cid}",
40
40
-
NotebookCard { notebook: view.clone() }
40
40
+
// Main content area
41
41
+
main { class: "repository-main",
42
42
+
div { class: "notebooks-list",
43
43
+
match notebooks() {
44
44
+
Some(Ok(notebook_list)) => rsx! {
45
45
+
for notebook in notebook_list.iter() {
46
46
+
{
47
47
+
let view = ¬ebook.0;
48
48
+
let entries = ¬ebook.1;
49
49
+
rsx! {
50
50
+
div {
51
51
+
key: "{view.cid}",
52
52
+
NotebookCard {
53
53
+
notebook: view.clone(),
54
54
+
entry_refs: entries.clone()
55
55
+
}
56
56
+
}
57
57
+
}
41
58
}
42
59
}
60
60
+
},
61
61
+
Some(Err(_)) => rsx! {
62
62
+
div { "Error loading notebooks" }
63
63
+
},
64
64
+
None => rsx! {
65
65
+
div { "Loading notebooks..." }
43
66
}
44
67
}
45
45
-
},
46
46
-
Some(Err(_)) => rsx! {
47
47
-
div { "Error loading notebooks" }
48
48
-
},
49
49
-
None => rsx! {
50
50
-
div { "Loading notebooks..." }
51
68
}
52
69
}
53
70
}
···
55
72
}
56
73
57
74
#[component]
58
58
-
pub fn NotebookCard(notebook: NotebookView<'static>) -> Element {
59
59
-
use crate::components::avatar::{Avatar, AvatarImage};
75
75
+
pub fn NotebookCard(
76
76
+
notebook: NotebookView<'static>,
77
77
+
entry_refs: Vec<StrongRef<'static>>,
78
78
+
) -> Element {
60
79
use jacquard::IntoStatic;
80
80
+
81
81
+
let fetcher = use_context::<fetch::CachedFetcher>();
61
82
62
83
let title = notebook
63
84
.title
···
68
89
// Format date
69
90
let formatted_date = notebook.indexed_at.as_ref().format("%B %d, %Y").to_string();
70
91
71
71
-
// Get first author for display
72
72
-
let first_author = notebook.authors.first();
92
92
+
// Show authors only if multiple
93
93
+
let show_authors = notebook.authors.len() > 1;
73
94
74
95
let ident = notebook.uri.authority().clone().into_static();
96
96
+
let book_title: SmolStr = title.to_string().into();
97
97
+
98
98
+
// Fetch all entries to get first/last
99
99
+
let ident_for_fetch = ident.clone();
100
100
+
let book_title_for_fetch = book_title.clone();
101
101
+
let entries = use_resource(use_reactive!(|(ident_for_fetch, book_title_for_fetch)| {
102
102
+
let fetcher = fetcher.clone();
103
103
+
async move {
104
104
+
fetcher
105
105
+
.list_notebook_entries(ident_for_fetch, book_title_for_fetch)
106
106
+
.await
107
107
+
.ok()
108
108
+
.flatten()
109
109
+
}
110
110
+
}));
75
111
rsx! {
76
112
div { class: "notebook-card",
77
77
-
Link {
78
78
-
to: Route::Entry {
79
79
-
ident,
80
80
-
book_title: title.to_string().into(),
81
81
-
title: "".into() // Will redirect to first entry
82
82
-
},
83
83
-
class: "notebook-card-link",
113
113
+
div { class: "notebook-card-container",
84
114
85
85
-
div { class: "notebook-card-header",
86
86
-
h2 { class: "notebook-card-title", "{title}" }
115
115
+
Link {
116
116
+
to: Route::Entry {
117
117
+
ident: ident.clone(),
118
118
+
book_title: title.to_string().into(),
119
119
+
title: "".into() // Will redirect to first entry
120
120
+
},
121
121
+
class: "notebook-card-header-link",
122
122
+
123
123
+
div { class: "notebook-card-header",
124
124
+
h2 { class: "notebook-card-title", "{title}" }
125
125
+
126
126
+
div { class: "notebook-card-date",
127
127
+
time { datetime: "{notebook.indexed_at.as_str()}", "{formatted_date}" }
128
128
+
}
129
129
+
}
87
130
}
88
131
89
89
-
div { class: "notebook-card-meta",
90
90
-
if let Some(author) = first_author {
91
91
-
div { class: "notebook-card-author",
132
132
+
// Show authors only if multiple
133
133
+
if show_authors {
134
134
+
div { class: "notebook-card-authors",
135
135
+
for (i, author) in notebook.authors.iter().enumerate() {
136
136
+
if i > 0 { span { class: "author-separator", ", " } }
92
137
{
93
138
use weaver_api::sh_weaver::actor::ProfileDataViewInner;
94
139
95
140
match &author.record.inner {
96
141
ProfileDataViewInner::ProfileView(profile) => {
97
97
-
let display_name = profile.display_name.as_ref().map(|n| n.as_ref()).unwrap_or("Unknown");
142
142
+
let display_name = profile.display_name.as_ref()
143
143
+
.map(|n| n.as_ref())
144
144
+
.unwrap_or("Unknown");
98
145
rsx! {
99
99
-
if let Some(ref avatar_url) = profile.avatar {
100
100
-
Avatar {
101
101
-
AvatarImage { src: avatar_url.as_ref() }
102
102
-
}
103
103
-
}
104
146
span { class: "author-name", "{display_name}" }
105
147
}
106
148
}
107
149
ProfileDataViewInner::ProfileViewDetailed(profile) => {
108
108
-
let display_name = profile.display_name.as_ref().map(|n| n.as_ref()).unwrap_or("Unknown");
150
150
+
let display_name = profile.display_name.as_ref()
151
151
+
.map(|n| n.as_ref())
152
152
+
.unwrap_or("Unknown");
109
153
rsx! {
110
110
-
if let Some(ref avatar_url) = profile.avatar {
111
111
-
Avatar {
112
112
-
AvatarImage { src: avatar_url.as_ref() }
113
113
-
}
114
114
-
}
115
154
span { class: "author-name", "{display_name}" }
116
155
}
117
156
}
···
120
159
span { class: "author-name", "@{profile.handle.as_ref()}" }
121
160
}
122
161
}
123
123
-
_ => {
124
124
-
rsx! {
125
125
-
span { class: "author-name", "Unknown" }
126
126
-
}
162
162
+
_ => rsx! {
163
163
+
span { class: "author-name", "Unknown" }
127
164
}
128
165
}
129
166
}
130
167
}
131
168
}
169
169
+
}
132
170
133
133
-
div { class: "notebook-card-date",
134
134
-
time { datetime: "{notebook.indexed_at.as_str()}", "{formatted_date}" }
171
171
+
// Entry previews section
172
172
+
if let Some(Some(entry_list)) = entries() {
173
173
+
div { class: "notebook-card-previews",
174
174
+
{
175
175
+
use jacquard::from_data;
176
176
+
use weaver_api::sh_weaver::notebook::entry::Entry;
177
177
+
178
178
+
if entry_list.len() <= 5 {
179
179
+
// Show all entries if 5 or fewer
180
180
+
rsx! {
181
181
+
for (i, entry_view) in entry_list.iter().enumerate() {
182
182
+
{
183
183
+
let entry_title = entry_view.entry.title.as_ref()
184
184
+
.map(|t| t.as_ref())
185
185
+
.unwrap_or("Untitled");
186
186
+
187
187
+
let preview_html = from_data::<Entry>(&entry_view.entry.record).ok().map(|entry| {
188
188
+
let parser = markdown_weaver::Parser::new(&entry.content);
189
189
+
let mut html_buf = String::new();
190
190
+
markdown_weaver::html::push_html(&mut html_buf, parser);
191
191
+
html_buf
192
192
+
});
193
193
+
194
194
+
let created_at = from_data::<Entry>(&entry_view.entry.record).ok()
195
195
+
.map(|entry| entry.created_at.as_ref().format("%B %d, %Y").to_string());
196
196
+
197
197
+
rsx! {
198
198
+
Link {
199
199
+
to: Route::Entry {
200
200
+
ident: ident.clone(),
201
201
+
book_title: book_title.clone(),
202
202
+
title: entry_title.to_string().into()
203
203
+
},
204
204
+
class: "notebook-entry-preview-link",
205
205
+
206
206
+
div { class: "notebook-entry-preview",
207
207
+
div { class: "entry-preview-header",
208
208
+
div { class: "entry-preview-title", "{entry_title}" }
209
209
+
if let Some(ref date) = created_at {
210
210
+
div { class: "entry-preview-date", "{date}" }
211
211
+
}
212
212
+
}
213
213
+
if let Some(ref html) = preview_html {
214
214
+
div { class: "entry-preview-content", dangerous_inner_html: "{html}" }
215
215
+
}
216
216
+
}
217
217
+
}
218
218
+
}
219
219
+
}
220
220
+
}
221
221
+
}
222
222
+
} else {
223
223
+
// Show first, interstitial, and last
224
224
+
rsx! {
225
225
+
if let Some(first_entry) = entry_list.first() {
226
226
+
{
227
227
+
let entry_title = first_entry.entry.title.as_ref()
228
228
+
.map(|t| t.as_ref())
229
229
+
.unwrap_or("Untitled");
230
230
+
231
231
+
let preview_html = from_data::<Entry>(&first_entry.entry.record).ok().map(|entry| {
232
232
+
let parser = markdown_weaver::Parser::new(&entry.content);
233
233
+
let mut html_buf = String::new();
234
234
+
markdown_weaver::html::push_html(&mut html_buf, parser);
235
235
+
html_buf
236
236
+
});
237
237
+
238
238
+
let created_at = from_data::<Entry>(&first_entry.entry.record).ok()
239
239
+
.map(|entry| entry.created_at.as_ref().format("%B %d, %Y").to_string());
240
240
+
241
241
+
rsx! {
242
242
+
Link {
243
243
+
to: Route::Entry {
244
244
+
ident: ident.clone(),
245
245
+
book_title: book_title.clone(),
246
246
+
title: entry_title.to_string().into()
247
247
+
},
248
248
+
class: "notebook-entry-preview-link",
249
249
+
250
250
+
div { class: "notebook-entry-preview notebook-entry-preview-first",
251
251
+
div { class: "entry-preview-header",
252
252
+
div { class: "entry-preview-title", "{entry_title}" }
253
253
+
if let Some(ref date) = created_at {
254
254
+
div { class: "entry-preview-date", "{date}" }
255
255
+
}
256
256
+
}
257
257
+
if let Some(ref html) = preview_html {
258
258
+
div { class: "entry-preview-content", dangerous_inner_html: "{html}" }
259
259
+
}
260
260
+
}
261
261
+
}
262
262
+
}
263
263
+
}
264
264
+
}
265
265
+
266
266
+
// Interstitial showing count
267
267
+
{
268
268
+
let middle_count = entry_list.len().saturating_sub(2);
269
269
+
rsx! {
270
270
+
div { class: "notebook-entry-interstitial",
271
271
+
"... {middle_count} more "
272
272
+
if middle_count == 1 { "entry" } else { "entries" }
273
273
+
" ..."
274
274
+
}
275
275
+
}
276
276
+
}
277
277
+
278
278
+
if let Some(last_entry) = entry_list.last() {
279
279
+
{
280
280
+
let entry_title = last_entry.entry.title.as_ref()
281
281
+
.map(|t| t.as_ref())
282
282
+
.unwrap_or("Untitled");
283
283
+
284
284
+
let preview_html = from_data::<Entry>(&last_entry.entry.record).ok().map(|entry| {
285
285
+
let parser = markdown_weaver::Parser::new(&entry.content);
286
286
+
let mut html_buf = String::new();
287
287
+
markdown_weaver::html::push_html(&mut html_buf, parser);
288
288
+
html_buf
289
289
+
});
290
290
+
291
291
+
let created_at = from_data::<Entry>(&last_entry.entry.record).ok()
292
292
+
.map(|entry| entry.created_at.as_ref().format("%B %d, %Y").to_string());
293
293
+
294
294
+
rsx! {
295
295
+
Link {
296
296
+
to: Route::Entry {
297
297
+
ident: ident.clone(),
298
298
+
book_title: book_title.clone(),
299
299
+
title: entry_title.to_string().into()
300
300
+
},
301
301
+
class: "notebook-entry-preview-link",
302
302
+
303
303
+
div { class: "notebook-entry-preview notebook-entry-preview-last",
304
304
+
div { class: "entry-preview-header",
305
305
+
div { class: "entry-preview-title", "{entry_title}" }
306
306
+
if let Some(ref date) = created_at {
307
307
+
div { class: "entry-preview-date", "{date}" }
308
308
+
}
309
309
+
}
310
310
+
if let Some(ref html) = preview_html {
311
311
+
div { class: "entry-preview-content", dangerous_inner_html: "{html}" }
312
312
+
}
313
313
+
}
314
314
+
}
315
315
+
}
316
316
+
}
317
317
+
}
318
318
+
}
319
319
+
}
320
320
+
}
135
321
}
136
322
}
137
323
+3
crates/weaver-app/src/components/mod.rs
···
14
14
15
15
pub mod profile;
16
16
pub use profile::ProfileDisplay;
17
17
+
18
18
+
pub mod notebook_cover;
19
19
+
pub use notebook_cover::NotebookCover;
+155
crates/weaver-app/src/components/notebook_cover.rs
···
1
1
+
#![allow(non_snake_case)]
2
2
+
3
3
+
use crate::components::avatar::{Avatar, AvatarImage};
4
4
+
use dioxus::prelude::*;
5
5
+
use jacquard::types::ident::AtIdentifier;
6
6
+
use weaver_api::sh_weaver::notebook::NotebookView;
7
7
+
8
8
+
const NOTEBOOK_COVER_CSS: Asset = asset!("/assets/styling/notebook-cover.css");
9
9
+
10
10
+
#[component]
11
11
+
pub fn NotebookCover(notebook: NotebookView<'static>, title: String) -> Element {
12
12
+
use jacquard::from_data;
13
13
+
use weaver_api::sh_weaver::notebook::book::Book;
14
14
+
15
15
+
// Deserialize the book record from the view
16
16
+
let book = match from_data::<Book>(¬ebook.record) {
17
17
+
Ok(book) => book,
18
18
+
Err(_) => {
19
19
+
return rsx! {
20
20
+
document::Stylesheet { href: NOTEBOOK_COVER_CSS }
21
21
+
div { class: "notebook-cover",
22
22
+
h1 { class: "notebook-cover-title", "{title}" }
23
23
+
div { "Error loading notebook details" }
24
24
+
}
25
25
+
}
26
26
+
}
27
27
+
};
28
28
+
29
29
+
rsx! {
30
30
+
document::Stylesheet { href: NOTEBOOK_COVER_CSS }
31
31
+
32
32
+
div { class: "notebook-cover",
33
33
+
h1 { class: "notebook-cover-title", "{title}" }
34
34
+
35
35
+
// Authors section
36
36
+
if !notebook.authors.is_empty() {
37
37
+
div { class: "notebook-cover-authors",
38
38
+
NotebookAuthors { authors: notebook.authors.clone() }
39
39
+
}
40
40
+
}
41
41
+
42
42
+
// Metadata
43
43
+
div { class: "notebook-cover-meta",
44
44
+
// Entry count
45
45
+
span { class: "notebook-cover-stat",
46
46
+
"{book.entry_list.len()} "
47
47
+
if book.entry_list.len() == 1 { "entry" } else { "entries" }
48
48
+
}
49
49
+
50
50
+
// Created date
51
51
+
if let Some(ref created_at) = book.created_at {
52
52
+
{
53
53
+
let formatted_date = created_at.as_ref().format("%B %d, %Y").to_string();
54
54
+
rsx! {
55
55
+
span { class: "notebook-cover-date",
56
56
+
"Created {formatted_date}"
57
57
+
}
58
58
+
}
59
59
+
}
60
60
+
}
61
61
+
}
62
62
+
63
63
+
// Tags if present
64
64
+
if let Some(ref tags) = notebook.tags {
65
65
+
if !tags.is_empty() {
66
66
+
div { class: "notebook-cover-tags",
67
67
+
for tag in tags.iter() {
68
68
+
span { class: "notebook-cover-tag", "{tag}" }
69
69
+
}
70
70
+
}
71
71
+
}
72
72
+
}
73
73
+
}
74
74
+
}
75
75
+
}
76
76
+
77
77
+
#[component]
78
78
+
fn NotebookAuthors(
79
79
+
authors: Vec<weaver_api::sh_weaver::notebook::AuthorListView<'static>>,
80
80
+
) -> Element {
81
81
+
rsx! {
82
82
+
div { class: "notebook-authors-list",
83
83
+
for (i, author) in authors.iter().enumerate() {
84
84
+
if i > 0 { span { class: "author-separator", ", " } }
85
85
+
NotebookAuthor { author: author.clone() }
86
86
+
}
87
87
+
}
88
88
+
}
89
89
+
}
90
90
+
91
91
+
#[component]
92
92
+
fn NotebookAuthor(author: weaver_api::sh_weaver::notebook::AuthorListView<'static>) -> Element {
93
93
+
use crate::data::use_handle;
94
94
+
use weaver_api::sh_weaver::actor::ProfileDataViewInner;
95
95
+
96
96
+
// Author already has profile data hydrated
97
97
+
match &author.record.inner {
98
98
+
ProfileDataViewInner::ProfileView(p) => {
99
99
+
let display_name = p
100
100
+
.display_name
101
101
+
.as_ref()
102
102
+
.map(|n| n.as_ref())
103
103
+
.unwrap_or("Unknown");
104
104
+
let handle = use_handle(p.did.clone().into())?;
105
105
+
106
106
+
rsx! {
107
107
+
div { class: "notebook-author",
108
108
+
if let Some(ref avatar) = p.avatar {
109
109
+
Avatar {
110
110
+
AvatarImage { src: avatar.as_ref() }
111
111
+
}
112
112
+
}
113
113
+
div { class: "notebook-author-info",
114
114
+
div { class: "notebook-author-name", "{display_name}" }
115
115
+
div { class: "notebook-author-handle", "@{handle()}" }
116
116
+
}
117
117
+
}
118
118
+
}
119
119
+
}
120
120
+
ProfileDataViewInner::ProfileViewDetailed(p) => {
121
121
+
let display_name = p
122
122
+
.display_name
123
123
+
.as_ref()
124
124
+
.map(|n| n.as_ref())
125
125
+
.unwrap_or("Unknown");
126
126
+
let handle = use_handle(p.did.clone().into())?;
127
127
+
128
128
+
rsx! {
129
129
+
div { class: "notebook-author",
130
130
+
if let Some(ref avatar) = p.avatar {
131
131
+
Avatar {
132
132
+
AvatarImage { src: avatar.as_ref() }
133
133
+
}
134
134
+
}
135
135
+
div { class: "notebook-author-info",
136
136
+
div { class: "notebook-author-name", "{display_name}" }
137
137
+
div { class: "notebook-author-handle", "@{handle()}" }
138
138
+
}
139
139
+
}
140
140
+
}
141
141
+
}
142
142
+
ProfileDataViewInner::TangledProfileView(p) => {
143
143
+
rsx! {
144
144
+
div { class: "notebook-author",
145
145
+
div { class: "notebook-author-name", "@{p.handle.as_ref()}" }
146
146
+
}
147
147
+
}
148
148
+
}
149
149
+
_ => rsx! {
150
150
+
div { class: "notebook-author",
151
151
+
"Unknown author"
152
152
+
}
153
153
+
},
154
154
+
}
155
155
+
}
+16
-5
crates/weaver-app/src/components/profile.rs
···
2
2
3
3
use crate::{
4
4
components::avatar::{Avatar, AvatarImage},
5
5
+
data::use_handle,
5
6
Route,
6
7
};
7
8
use dioxus::prelude::*;
···
17
18
18
19
match profile().as_ref() {
19
20
Some(profile_view) => rsx! {
20
20
-
document::Link { rel: "stylesheet", href: PROFILE_CSS }
21
21
+
document::Stylesheet { href: PROFILE_CSS }
21
22
22
23
div { class: "profile-display",
23
24
// Banner if present
···
33
34
rsx! { }
34
35
}
35
36
}
37
37
+
ProfileDataViewInner::ProfileViewDetailed(p) => {
38
38
+
if let Some(ref banner) = p.banner {
39
39
+
rsx! {
40
40
+
div { class: "profile-banner",
41
41
+
img { src: "{banner.as_ref()}", alt: "Profile banner" }
42
42
+
}
43
43
+
}
44
44
+
} else {
45
45
+
rsx! { }
46
46
+
}
47
47
+
}
36
48
_ => rsx! { }
37
49
}}
38
50
···
101
113
span { class: "profile-pronouns", " ({pronouns})" }
102
114
}
103
115
}
104
104
-
div { class: "profile-handle", "@{ident}" }
116
116
+
div { class: "profile-handle", "@{use_handle(ident.clone())?}" }
105
117
106
118
if let Some(ref location) = profile.location {
107
119
div { class: "profile-location", "{location}" }
···
131
143
132
144
div { class: "profile-name-section",
133
145
h1 { class: "profile-display-name", "{display_name}" }
134
134
-
div { class: "profile-handle", "@{ident}" }
146
146
+
div { class: "profile-handle", "@{use_handle(ident.clone())?}" }
135
147
}
136
148
137
149
if let Some(ref description) = profile.description {
···
176
188
rsx! {
177
189
div { class: "profile-stats",
178
190
div { class: "profile-stat",
179
179
-
span { class: "profile-stat-label", "Notebooks" }
180
180
-
span { class: "profile-stat-value", "{notebook_count}" }
191
191
+
span { class: "profile-stat-label", "{notebook_count} notebooks" }
181
192
}
182
193
// TODO: Add entry count, subscriber counts when available
183
194
}
+5
-1
crates/weaver-app/src/views/home.rs
···
23
23
for notebook in notebook_list.iter() {
24
24
{
25
25
let view = ¬ebook.0;
26
26
+
let entries = ¬ebook.1;
26
27
rsx! {
27
28
div {
28
29
key: "{view.cid}",
29
29
-
NotebookCard { notebook: view.clone() }
30
30
+
NotebookCard {
31
31
+
notebook: view.clone(),
32
32
+
entry_refs: entries.clone()
33
33
+
}
30
34
}
31
35
}
32
36
}
+4
-3
crates/weaver-app/src/views/navbar.rs
···
1
1
+
use crate::data::use_handle;
1
2
use crate::Route;
2
3
use dioxus::prelude::*;
3
4
···
30
31
match route {
31
32
Route::RepositoryIndex { ident } => rsx! {
32
33
span { class: "breadcrumb-separator", " > " }
33
33
-
span { class: "breadcrumb breadcrumb-current", "@{ident}" }
34
34
+
span { class: "breadcrumb breadcrumb-current", "@{use_handle(ident.clone())?}" }
34
35
},
35
36
Route::NotebookIndex { ident, book_title } => rsx! {
36
37
span { class: "breadcrumb-separator", " > " }
37
38
Link {
38
39
to: Route::RepositoryIndex { ident: ident.clone() },
39
40
class: "breadcrumb",
40
40
-
"@{ident}"
41
41
+
"@{use_handle(ident.clone())?}"
41
42
}
42
43
span { class: "breadcrumb-separator", " > " }
43
44
span { class: "breadcrumb breadcrumb-current", "{book_title}" }
···
47
48
Link {
48
49
to: Route::RepositoryIndex { ident: ident.clone() },
49
50
class: "breadcrumb",
50
50
-
"@{ident}"
51
51
+
"@{use_handle(ident.clone())?}"
51
52
}
52
53
span { class: "breadcrumb-separator", " > " }
53
54
Link {
+57
-13
crates/weaver-app/src/views/notebook.rs
···
1
1
use crate::{
2
2
-
components::{EntryCard, NotebookCss},
2
2
+
components::{EntryCard, NotebookCover, NotebookCss},
3
3
fetch, Route,
4
4
};
5
5
use dioxus::prelude::*;
···
27
27
let fetcher = use_context::<fetch::CachedFetcher>();
28
28
let book_title_clone = book_title.clone();
29
29
30
30
-
let notebook_entries = use_resource(use_reactive!(|(ident, book_title)| {
31
31
-
let fetcher = fetcher.clone();
30
30
+
// Fetch full notebook to get author count
31
31
+
let ident_for_notebook = ident.clone();
32
32
+
let book_title_for_notebook = book_title.clone();
33
33
+
let data_fetcher = fetcher.clone();
34
34
+
let notebook_data = use_resource(use_reactive!(|(
35
35
+
ident_for_notebook,
36
36
+
book_title_for_notebook,
37
37
+
)| {
38
38
+
let fetcher = data_fetcher.clone();
39
39
+
async move {
40
40
+
fetcher
41
41
+
.get_notebook(ident_for_notebook, book_title_for_notebook)
42
42
+
.await
43
43
+
.ok()
44
44
+
.flatten()
45
45
+
}
46
46
+
}));
47
47
+
48
48
+
// Also fetch entries
49
49
+
let entry_fetcher = fetcher.clone();
50
50
+
let entries_resource = use_resource(use_reactive!(|(ident, book_title)| {
51
51
+
let fetcher = entry_fetcher.clone();
32
52
async move {
33
33
-
fetcher.list_notebook_entries(ident, book_title).await.ok().flatten()
53
53
+
fetcher
54
54
+
.list_notebook_entries(ident, book_title)
55
55
+
.await
56
56
+
.ok()
57
57
+
.flatten()
34
58
}
35
59
}));
36
60
37
61
rsx! {
38
62
document::Link { rel: "stylesheet", href: ENTRY_CARD_CSS }
39
63
40
40
-
div { class: "entries-list",
41
41
-
match &*notebook_entries.read_unchecked() {
42
42
-
Some(Some(entries)) => rsx! {
43
43
-
for entry in entries {
44
44
-
EntryCard { entry: entry.clone(), book_title: book_title_clone.clone() }
64
64
+
match (&*notebook_data.read_unchecked(), &*entries_resource.read_unchecked()) {
65
65
+
(Some(Some(data)), Some(Some(entries))) => {
66
66
+
let (notebook_view, _) = data.as_ref();
67
67
+
let author_count = notebook_view.authors.len();
68
68
+
69
69
+
rsx! {
70
70
+
div { class: "notebook-layout",
71
71
+
aside { class: "notebook-sidebar",
72
72
+
NotebookCover {
73
73
+
notebook: notebook_view.clone(),
74
74
+
title: book_title_clone.to_string()
75
75
+
}
76
76
+
}
77
77
+
78
78
+
main { class: "notebook-main",
79
79
+
div { class: "entries-list",
80
80
+
for entry in entries {
81
81
+
EntryCard {
82
82
+
entry: entry.clone(),
83
83
+
book_title: book_title_clone.clone(),
84
84
+
author_count
85
85
+
}
86
86
+
}
87
87
+
}
88
88
+
}
45
89
}
46
46
-
},
47
47
-
Some(None) => rsx! { div { class: "error", "Notebook not found" } },
48
48
-
None => rsx! { div { class: "loading", "Loading notebook..." } }
49
49
-
}
90
90
+
}
91
91
+
},
92
92
+
(Some(None), _) | (_, Some(None)) => rsx! { div { class: "error", "Notebook or entries not found" } },
93
93
+
_ => rsx! { div { class: "loading", "Loading..." } }
50
94
}
51
95
}
52
96
}
+1
-1
crates/weaver-common/src/agent.rs
···
548
548
.map(|blob| {
549
549
let cid = blob.blob().cid();
550
550
jacquard::types::string::Uri::new_owned(format!(
551
551
-
"https://cdn.bsky.app/img/feed_fullsize/plain/{}/{}",
551
551
+
"https://cdn.bsky.app/img/banner/plain/{}/{}",
552
552
did, cid
553
553
))
554
554
})
+2
-1
crates/weaver-renderer/src/css.rs
···
82
82
line-height: var(--spacing-line-height);
83
83
}}
84
84
85
85
-
body {{
85
85
+
/* Scoped to notebook-content container */
86
86
+
.notebook-content {{
86
87
font-family: var(--font-body);
87
88
color: var(--color-text);
88
89
background-color: var(--color-base);
+5
crates/weaver-renderer/src/static_site/document.rs
···
155
155
156
156
writer.write_all(b"</head>\n").await.into_diagnostic()?;
157
157
writer.write_all(b"<body>\n").await.into_diagnostic()?;
158
158
+
writer
159
159
+
.write_all(b"<div class=\"notebook-content\">\n")
160
160
+
.await
161
161
+
.into_diagnostic()?;
158
162
159
163
Ok(())
160
164
}
···
164
168
) -> miette::Result<()> {
165
169
use tokio::io::AsyncWriteExt;
166
170
171
171
+
writer.write_all(b"</div>\n").await.into_diagnostic()?;
167
172
writer.write_all(b"</body>\n").await.into_diagnostic()?;
168
173
writer.write_all(b"</html>\n").await.into_diagnostic()?;
169
174