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