tangled
alpha
login
or
join now
nonbinary.computer
/
weaver
atproto blogging
24
fork
atom
overview
issues
2
pulls
pipelines
pretty record view
Orual
2 months ago
ac5685d4
29e57a4e
+874
-2
9 changed files
expand all
collapse all
unified
split
Cargo.lock
crates
weaver-app
Cargo.toml
assets
styling
record-view.css
src
components
mod.rs
main.rs
views
mod.rs
record.rs
weaver-renderer
src
code_pretty.rs
css.rs
+11
Cargo.lock
···
3659
3659
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
3660
3660
3661
3661
[[package]]
3662
3662
+
name = "humansize"
3663
3663
+
version = "2.1.3"
3664
3664
+
source = "registry+https://github.com/rust-lang/crates.io-index"
3665
3665
+
checksum = "6cb51c9a029ddc91b07a787f1d86b53ccfa49b0e86688c946ebe8d3555685dd7"
3666
3666
+
dependencies = [
3667
3667
+
"libm",
3668
3668
+
]
3669
3669
+
3670
3670
+
[[package]]
3662
3671
name = "hyper"
3663
3672
version = "1.7.0"
3664
3673
source = "registry+https://github.com/rust-lang/crates.io-index"
···
8634
8643
"dioxus",
8635
8644
"dioxus-free-icons",
8636
8645
"dioxus-primitives",
8646
8646
+
"hex_fmt",
8647
8647
+
"humansize",
8637
8648
"jacquard",
8638
8649
"jacquard-axum",
8639
8650
"js-sys",
+2
crates/weaver-app/Cargo.toml
···
37
37
chrono = { version = "0.4" }
38
38
serde = { version = "1.0", features = ["derive"] }
39
39
serde_json = "1.0"
40
40
+
hex_fmt = "0.3"
41
41
+
humansize = "2.0.0"
40
42
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
41
43
dioxus-free-icons = { version = "0.10.0" }
42
44
diesel = { version = "2.3", features = ["sqlite", "returning_clauses_for_sqlite_3_35", "chrono", "serde_json"] }
+332
crates/weaver-app/assets/styling/record-view.css
···
1
1
+
.record-view-container {
2
2
+
font-family: var(--font-mono);
3
3
+
max-width: 1200px;
4
4
+
margin: 2rem auto;
5
5
+
padding: 0 1rem;
6
6
+
}
7
7
+
8
8
+
.record-header {
9
9
+
margin-bottom: 2rem;
10
10
+
padding-bottom: 0.5rem;
11
11
+
border-bottom: 1px solid var(--color-border);
12
12
+
}
13
13
+
14
14
+
.record-header h1 {
15
15
+
font-family: var(--font-mono);
16
16
+
font-size: 1.2rem;
17
17
+
font-weight: 400;
18
18
+
margin-bottom: 1rem;
19
19
+
color: var(--color-text);
20
20
+
text-transform: uppercase;
21
21
+
letter-spacing: 0.15em;
22
22
+
}
23
23
+
24
24
+
.record-metadata {
25
25
+
display: flex-wrap;
26
26
+
flex-direction: column;
27
27
+
gap: 0.75rem;
28
28
+
}
29
29
+
30
30
+
.metadata-row {
31
31
+
display: flex;
32
32
+
gap: 0.5rem;
33
33
+
padding: 0.35rem;
34
34
+
align-items: baseline;
35
35
+
}
36
36
+
37
37
+
.metadata-label {
38
38
+
color: var(--color-muted);
39
39
+
font-size: 0.85rem;
40
40
+
text-transform: uppercase;
41
41
+
letter-spacing: 0.1em;
42
42
+
white-space: nowrap;
43
43
+
}
44
44
+
45
45
+
.metadata-label::after {
46
46
+
content: ":";
47
47
+
font-size: 0.8rem;
48
48
+
margin-left: 0.25rem;
49
49
+
}
50
50
+
51
51
+
.metadata-value {
52
52
+
font-family: var(--font-mono);
53
53
+
color: var(--color-text);
54
54
+
font-size: 1rem;
55
55
+
word-break: break-all;
56
56
+
}
57
57
+
58
58
+
.uri-link {
59
59
+
text-decoration: none;
60
60
+
}
61
61
+
62
62
+
.uri-link:hover .aturi-scheme,
63
63
+
.uri-link:hover .aturi-authority,
64
64
+
.uri-link:hover .aturi-collection,
65
65
+
.uri-link:hover .aturi-rkey,
66
66
+
.uri-link:hover .uri-scheme,
67
67
+
.uri-link:hover .uri-authority,
68
68
+
.uri-link:hover .uri-path {
69
69
+
text-decoration: underline;
70
70
+
}
71
71
+
72
72
+
.tab-bar {
73
73
+
display: flex;
74
74
+
gap: 0;
75
75
+
border-bottom: 1px solid var(--color-border);
76
76
+
margin-bottom: 1.5rem;
77
77
+
margin-top: 1.5rem;
78
78
+
}
79
79
+
80
80
+
.tab-button {
81
81
+
font-family: var(--font-mono);
82
82
+
background: transparent;
83
83
+
border: none;
84
84
+
padding: 0.5rem 1rem;
85
85
+
cursor: pointer;
86
86
+
color: var(--color-muted);
87
87
+
text-transform: uppercase;
88
88
+
font-size: 0.9rem;
89
89
+
letter-spacing: 0.1em;
90
90
+
border-bottom: 2px solid transparent;
91
91
+
margin-bottom: -1px;
92
92
+
transition: all 0.2s;
93
93
+
}
94
94
+
95
95
+
.tab-button:hover {
96
96
+
color: var(--color-text);
97
97
+
}
98
98
+
99
99
+
.tab-button.active {
100
100
+
color: var(--color-text);
101
101
+
border-bottom-color: var(--color-primary);
102
102
+
}
103
103
+
104
104
+
.tab-content {
105
105
+
min-height: 300px;
106
106
+
}
107
107
+
108
108
+
.pretty-record {
109
109
+
display: flex;
110
110
+
flex-direction: column;
111
111
+
align-items: flex-start;
112
112
+
}
113
113
+
114
114
+
.record-field {
115
115
+
display: flex;
116
116
+
flex-direction: column;
117
117
+
padding: 0rem 0 0rem 1rem;
118
118
+
119
119
+
padding-right: 1rem;
120
120
+
border-left: 2px solid var(--color-secondary);
121
121
+
border-bottom: 1px dashed var(--color-muted);
122
122
+
}
123
123
+
124
124
+
.field-label {
125
125
+
font-family: var(--font-mono);
126
126
+
color: var(--color-subtle);
127
127
+
font-size: 0.7rem;
128
128
+
padding-top: 0.5rem;
129
129
+
}
130
130
+
131
131
+
.field-value {
132
132
+
font-family: var(--font-mono);
133
133
+
color: var(--color-text);
134
134
+
font-size: 1rem;
135
135
+
padding-top: 0.2rem;
136
136
+
padding-bottom: 0.1rem;
137
137
+
word-break: break-word;
138
138
+
}
139
139
+
140
140
+
.field-value.muted {
141
141
+
color: var(--color-subtle);
142
142
+
font-style: italic;
143
143
+
}
144
144
+
145
145
+
.field-value.mime {
146
146
+
color: var(--color-subtle);
147
147
+
font-style: italic;
148
148
+
font-size: 0.85rem;
149
149
+
}
150
150
+
151
151
+
.field-value.bytes {
152
152
+
color: var(--color-subtle);
153
153
+
font-size: 0.75rem;
154
154
+
}
155
155
+
156
156
+
.record-section {
157
157
+
padding-left: 1.5rem;
158
158
+
position: relative;
159
159
+
border-left: 1px dashed var(--color-border);
160
160
+
}
161
161
+
162
162
+
.section-label {
163
163
+
font-family: var(--font-mono);
164
164
+
color: var(--color-primary);
165
165
+
font-size: 1rem;
166
166
+
font-weight: 600;
167
167
+
padding-left: 1rem;
168
168
+
padding-top: 0.5rem;
169
169
+
padding-bottom: 0.25rem;
170
170
+
border-left: 2px solid var(--color-primary);
171
171
+
border-bottom: 1px dashed var(--color-muted);
172
172
+
margin-left: -1.51rem;
173
173
+
}
174
174
+
175
175
+
.section-content .section-label {
176
176
+
font-family: var(--font-mono);
177
177
+
color: var(--color-tertiary);
178
178
+
font-size: 0.9rem;
179
179
+
font-weight: 600;
180
180
+
padding-left: 1rem;
181
181
+
padding-top: 0.5rem;
182
182
+
border-left: 2px solid var(--color-secondary);
183
183
+
}
184
184
+
185
185
+
.section-content {
186
186
+
display: flex;
187
187
+
flex-direction: column;
188
188
+
align-items: flex-start;
189
189
+
padding-right: 1rem;
190
190
+
}
191
191
+
192
192
+
.section-content .record-field {
193
193
+
border-left-color: var(--color-secondary);
194
194
+
opacity: 0.95;
195
195
+
}
196
196
+
197
197
+
.blob-image {
198
198
+
max-width: 600px;
199
199
+
max-height: 400px;
200
200
+
margin-top: 0.5rem;
201
201
+
border: 1px solid var(--color-border);
202
202
+
margin-bottom: 0.5rem;
203
203
+
}
204
204
+
205
205
+
.string-type-tag {
206
206
+
font-size: 0.7rem;
207
207
+
color: var(--color-muted);
208
208
+
text-transform: uppercase;
209
209
+
letter-spacing: 0.05em;
210
210
+
}
211
211
+
212
212
+
.string-did,
213
213
+
.string-handle,
214
214
+
.string-at-identifier {
215
215
+
color: var(--color-primary);
216
216
+
}
217
217
+
218
218
+
.string-at-uri,
219
219
+
.string-uri {
220
220
+
color: var(--color-secondary);
221
221
+
}
222
222
+
223
223
+
.string-cid,
224
224
+
.string-tid {
225
225
+
color: var(--color-tertiary);
226
226
+
font-family: var(--font-mono);
227
227
+
}
228
228
+
229
229
+
.string-nsid {
230
230
+
color: var(--color-emphasis);
231
231
+
}
232
232
+
233
233
+
.string-datetime {
234
234
+
color: var(--color-text);
235
235
+
font-style: italic;
236
236
+
}
237
237
+
238
238
+
/* NSID highlighting */
239
239
+
.nsid-dot {
240
240
+
color: var(--color-muted);
241
241
+
opacity: 0.6;
242
242
+
}
243
243
+
244
244
+
.nsid-segment-0 {
245
245
+
color: var(--color-primary);
246
246
+
}
247
247
+
248
248
+
.nsid-segment-1 {
249
249
+
color: var(--color-secondary);
250
250
+
}
251
251
+
252
252
+
.nsid-segment-2 {
253
253
+
color: var(--color-tertiary);
254
254
+
}
255
255
+
256
256
+
/* DID highlighting */
257
257
+
.did-scheme {
258
258
+
color: var(--color-muted);
259
259
+
opacity: 0.7;
260
260
+
}
261
261
+
262
262
+
.did-method {
263
263
+
color: var(--color-secondary);
264
264
+
font-weight: 500;
265
265
+
}
266
266
+
267
267
+
.did-separator {
268
268
+
color: var(--color-muted);
269
269
+
opacity: 0.6;
270
270
+
}
271
271
+
272
272
+
.did-identifier {
273
273
+
color: var(--color-primary);
274
274
+
}
275
275
+
276
276
+
/* Handle highlighting */
277
277
+
.handle-dot {
278
278
+
color: var(--color-muted);
279
279
+
opacity: 0.6;
280
280
+
}
281
281
+
282
282
+
.handle-segment-0 {
283
283
+
color: var(--color-primary);
284
284
+
font-weight: 500;
285
285
+
}
286
286
+
287
287
+
.handle-segment-1 {
288
288
+
color: var(--color-secondary);
289
289
+
}
290
290
+
291
291
+
/* AT URI highlighting */
292
292
+
.aturi-scheme {
293
293
+
color: var(--color-muted);
294
294
+
opacity: 0.7;
295
295
+
}
296
296
+
297
297
+
.aturi-authority {
298
298
+
color: var(--color-primary);
299
299
+
}
300
300
+
301
301
+
.aturi-slash {
302
302
+
color: var(--color-muted);
303
303
+
opacity: 0.6;
304
304
+
}
305
305
+
306
306
+
.aturi-collection {
307
307
+
color: var(--color-secondary);
308
308
+
}
309
309
+
310
310
+
.aturi-rkey {
311
311
+
color: var(--color-tertiary);
312
312
+
}
313
313
+
314
314
+
/* URI highlighting */
315
315
+
.uri-scheme {
316
316
+
color: var(--color-muted);
317
317
+
opacity: 0.7;
318
318
+
font-weight: 500;
319
319
+
}
320
320
+
321
321
+
.uri-separator {
322
322
+
color: var(--color-muted);
323
323
+
opacity: 0.6;
324
324
+
}
325
325
+
326
326
+
.uri-authority {
327
327
+
color: var(--color-primary);
328
328
+
}
329
329
+
330
330
+
.uri-path {
331
331
+
color: var(--color-secondary);
332
332
+
}
+1
-1
crates/weaver-app/src/components/mod.rs
···
6
6
pub use css::NotebookCss;
7
7
8
8
mod entry;
9
9
-
pub use entry::{Entry, EntryCard};
9
9
+
pub use entry::{Entry, EntryCard, EntryMarkdown};
10
10
11
11
pub mod identity;
12
12
pub use identity::{NotebookCard, Repository, RepositoryIndex};
+3
-1
crates/weaver-app/src/main.rs
···
17
17
18
18
use std::sync::Arc;
19
19
#[allow(unused)]
20
20
-
use views::{Home, Navbar, Notebook, NotebookIndex, NotebookPage};
20
20
+
use views::{Home, Navbar, Notebook, NotebookIndex, NotebookPage, RecordView};
21
21
22
22
#[cfg(feature = "server")]
23
23
mod blobcache;
···
46
46
#[route("/")]
47
47
Home {},
48
48
#[layout(ErrorLayout)]
49
49
+
#[route("/record#:uri")]
50
50
+
RecordView { uri: SmolStr },
49
51
#[nest("/:ident")]
50
52
#[layout(Repository)]
51
53
#[route("/")]
+3
crates/weaver-app/src/views/mod.rs
···
19
19
20
20
mod notebook;
21
21
pub use notebook::{Notebook, NotebookIndex};
22
22
+
23
23
+
mod record;
24
24
+
pub use record::RecordView;
+436
crates/weaver-app/src/views/record.rs
···
1
1
+
use crate::fetch::CachedFetcher;
2
2
+
use dioxus::prelude::*;
3
3
+
use hex_fmt::HexFmt;
4
4
+
use humansize::format_size;
5
5
+
use jacquard::{
6
6
+
client::AgentSessionExt,
7
7
+
common::{Data, IntoStatic},
8
8
+
smol_str::SmolStr,
9
9
+
types::aturi::AtUri,
10
10
+
};
11
11
+
use weaver_renderer::{code_pretty::highlight_code, css::generate_default_css};
12
12
+
13
13
+
#[derive(Clone, Copy, PartialEq)]
14
14
+
enum ViewMode {
15
15
+
Pretty,
16
16
+
Json,
17
17
+
}
18
18
+
19
19
+
#[component]
20
20
+
pub fn RecordView(uri: SmolStr) -> Element {
21
21
+
let fetcher = use_context::<CachedFetcher>();
22
22
+
let at_uri = AtUri::new_owned(uri.clone());
23
23
+
if let Err(err) = &at_uri {
24
24
+
let error = format!("{:?}", err);
25
25
+
return rsx! {
26
26
+
div {
27
27
+
h1 { "Record View" }
28
28
+
p { "URI: {uri}" }
29
29
+
p { "Error: {error}" }
30
30
+
}
31
31
+
};
32
32
+
}
33
33
+
let uri = use_signal(|| at_uri.unwrap());
34
34
+
let mut view_mode = use_signal(|| ViewMode::Pretty);
35
35
+
let record = use_resource(move || {
36
36
+
let fetcher = fetcher.clone();
37
37
+
async move { fetcher.client.fetch_record_slingshot(&uri()).await }
38
38
+
});
39
39
+
if let Some(Ok(record)) = &*record.read_unchecked() {
40
40
+
let record_value = record.value.clone().into_static();
41
41
+
let json = serde_json::to_string_pretty(&record_value).unwrap();
42
42
+
rsx! {
43
43
+
document::Stylesheet { href: asset!("/assets/styling/record-view.css") }
44
44
+
div {
45
45
+
class: "record-view-container",
46
46
+
div {
47
47
+
class: "record-header",
48
48
+
h1 { "Record Inspector" }
49
49
+
div {
50
50
+
class: "record-metadata",
51
51
+
div { class: "metadata-row",
52
52
+
span { class: "metadata-label", "URI" }
53
53
+
span { class: "metadata-value",
54
54
+
HighlightedUri { uri: uri().clone() }
55
55
+
}
56
56
+
}
57
57
+
if let Some(cid) = &record.cid {
58
58
+
div { class: "metadata-row",
59
59
+
span { class: "metadata-label", "CID" }
60
60
+
code { class: "metadata-value", "{cid}" }
61
61
+
}
62
62
+
}
63
63
+
}
64
64
+
}
65
65
+
div {
66
66
+
class: "tab-bar",
67
67
+
button {
68
68
+
class: if view_mode() == ViewMode::Pretty { "tab-button active" } else { "tab-button" },
69
69
+
onclick: move |_| view_mode.set(ViewMode::Pretty),
70
70
+
"View"
71
71
+
}
72
72
+
button {
73
73
+
class: if view_mode() == ViewMode::Json { "tab-button active" } else { "tab-button" },
74
74
+
onclick: move |_| view_mode.set(ViewMode::Json),
75
75
+
"JSON"
76
76
+
}
77
77
+
}
78
78
+
div {
79
79
+
class: "tab-content",
80
80
+
match view_mode() {
81
81
+
ViewMode::Pretty => rsx! {
82
82
+
PrettyRecordView { record: record_value.clone(), uri: uri().clone() }
83
83
+
},
84
84
+
ViewMode::Json => rsx! {
85
85
+
CodeView {
86
86
+
code: use_signal(|| json.clone()),
87
87
+
lang: Some("json".to_string()),
88
88
+
}
89
89
+
},
90
90
+
}
91
91
+
}
92
92
+
}
93
93
+
}
94
94
+
} else {
95
95
+
rsx! {
96
96
+
div {
97
97
+
class: "record-view-container",
98
98
+
h1 { "Record Inspector" }
99
99
+
p { "URI: {uri}" }
100
100
+
p { "Loading..." }
101
101
+
}
102
102
+
}
103
103
+
}
104
104
+
}
105
105
+
106
106
+
#[component]
107
107
+
fn PrettyRecordView(record: Data<'static>, uri: AtUri<'static>) -> Element {
108
108
+
let did = uri.authority().to_string();
109
109
+
rsx! {
110
110
+
div {
111
111
+
class: "pretty-record",
112
112
+
DataView { data: record, path: String::new(), did }
113
113
+
}
114
114
+
}
115
115
+
}
116
116
+
fn get_hex_rep(byte_array: &mut [u8]) -> String {
117
117
+
let build_string_vec: Vec<String> = byte_array
118
118
+
.chunks(2)
119
119
+
.enumerate()
120
120
+
.map(|(i, c)| {
121
121
+
let sep = if i % 16 == 0 && i > 0 {
122
122
+
"\n"
123
123
+
} else if i == 0 {
124
124
+
""
125
125
+
} else {
126
126
+
" "
127
127
+
};
128
128
+
if c.len() == 2 {
129
129
+
format!("{}{:02x}{:02x}", sep, c[0], c[1])
130
130
+
} else {
131
131
+
format!("{}{:02x}", sep, c[0])
132
132
+
}
133
133
+
})
134
134
+
.collect();
135
135
+
build_string_vec.join("")
136
136
+
}
137
137
+
138
138
+
#[component]
139
139
+
fn DataView(data: Data<'static>, path: String, did: String) -> Element {
140
140
+
match &data {
141
141
+
Data::Null => rsx! {
142
142
+
div { class: "record-field",
143
143
+
span { class: "field-label", "{path}" }
144
144
+
span { class: "field-value muted", "null" }
145
145
+
}
146
146
+
},
147
147
+
Data::Boolean(b) => rsx! {
148
148
+
div { class: "record-field",
149
149
+
span { class: "field-label", "{path}" }
150
150
+
span { class: "field-value", "{b}" }
151
151
+
}
152
152
+
},
153
153
+
Data::Integer(i) => rsx! {
154
154
+
div { class: "record-field",
155
155
+
span { class: "field-label", "{path}" }
156
156
+
span { class: "field-value", "{i}" }
157
157
+
}
158
158
+
},
159
159
+
Data::String(s) => {
160
160
+
use jacquard::types::string::AtprotoStr;
161
161
+
162
162
+
let type_label = match s {
163
163
+
AtprotoStr::Datetime(_) => "datetime",
164
164
+
AtprotoStr::Language(_) => "language",
165
165
+
AtprotoStr::Tid(_) => "tid",
166
166
+
AtprotoStr::Nsid(_) => "nsid",
167
167
+
AtprotoStr::Did(_) => "did",
168
168
+
AtprotoStr::Handle(_) => "handle",
169
169
+
AtprotoStr::AtIdentifier(_) => "at-identifier",
170
170
+
AtprotoStr::AtUri(_) => "at-uri",
171
171
+
AtprotoStr::Uri(_) => "uri",
172
172
+
AtprotoStr::Cid(_) => "cid",
173
173
+
AtprotoStr::RecordKey(_) => "record-key",
174
174
+
AtprotoStr::String(_) => "string",
175
175
+
};
176
176
+
177
177
+
rsx! {
178
178
+
div { class: "record-field",
179
179
+
span { class: "field-label", "{path}" }
180
180
+
span { class: "field-value",
181
181
+
182
182
+
HighlightedString { string_type: s.clone() }
183
183
+
if type_label != "string" {
184
184
+
span { class: "string-type-tag", " [{type_label}]" }
185
185
+
}
186
186
+
}
187
187
+
}
188
188
+
}
189
189
+
}
190
190
+
Data::Bytes(b) => {
191
191
+
let hex_string = get_hex_rep(&mut b.to_vec());
192
192
+
let byte_size = if b.len() > 128 {
193
193
+
format_size(b.len(), humansize::BINARY)
194
194
+
} else {
195
195
+
format!("{} bytes", b.len())
196
196
+
};
197
197
+
rsx! {
198
198
+
div { class: "record-field",
199
199
+
span { class: "field-label", "{path}" }
200
200
+
pre { class: "field-value bytes", "{hex_string} [{byte_size}]" }
201
201
+
}
202
202
+
}
203
203
+
}
204
204
+
Data::CidLink(cid) => rsx! {
205
205
+
div { class: "record-field",
206
206
+
span { class: "field-label", "{path}" }
207
207
+
span { class: "field-value", "{cid}" }
208
208
+
}
209
209
+
},
210
210
+
Data::Array(arr) => rsx! {
211
211
+
div { class: "record-section",
212
212
+
div { class: "section-label", "{path}" span { class: "field-label", "[{arr.len()}] " } }
213
213
+
214
214
+
div { class: "section-content",
215
215
+
for (idx, item) in arr.iter().enumerate() {
216
216
+
DataView {
217
217
+
data: item.clone(),
218
218
+
path: format!("{}[{}]", path, idx),
219
219
+
did: did.clone()
220
220
+
}
221
221
+
}
222
222
+
}
223
223
+
}
224
224
+
},
225
225
+
Data::Object(obj) => rsx! {
226
226
+
for (key, value) in obj.iter() {
227
227
+
{
228
228
+
let new_path = if path.is_empty() {
229
229
+
key.to_string()
230
230
+
} else {
231
231
+
format!("{}.{}", path, key)
232
232
+
};
233
233
+
let did_clone = did.clone();
234
234
+
235
235
+
match value {
236
236
+
Data::Object(_) | Data::Array(_) => rsx! {
237
237
+
div { class: "record-section",
238
238
+
div { class: "section-label", "{key}" }
239
239
+
div { class: "section-content",
240
240
+
DataView { data: value.clone(), path: new_path, did: did_clone }
241
241
+
}
242
242
+
}
243
243
+
},
244
244
+
_ => rsx! {
245
245
+
DataView { data: value.clone(), path: new_path, did: did_clone }
246
246
+
}
247
247
+
}
248
248
+
}
249
249
+
}
250
250
+
},
251
251
+
Data::Blob(blob) => {
252
252
+
let is_image = blob.mime_type.starts_with("image/");
253
253
+
let format = blob.mime_type.strip_prefix("image/").unwrap_or("jpeg");
254
254
+
let image_url = format!(
255
255
+
"https://cdn.bsky.app/img/feed_fullsize/plain/{}/{}@{}",
256
256
+
did,
257
257
+
blob.cid(),
258
258
+
format
259
259
+
);
260
260
+
261
261
+
let blob_size = format_size(blob.size, humansize::BINARY);
262
262
+
rsx! {
263
263
+
div { class: "record-field",
264
264
+
span { class: "field-label", "{path}" }
265
265
+
span { class: "field-value mime", "[mimeType: {blob.mime_type}, size: {blob_size}]" }
266
266
+
if is_image {
267
267
+
img {
268
268
+
src: "{image_url}",
269
269
+
alt: "Blob image",
270
270
+
class: "blob-image",
271
271
+
}
272
272
+
}
273
273
+
}
274
274
+
}
275
275
+
}
276
276
+
}
277
277
+
}
278
278
+
279
279
+
#[component]
280
280
+
fn HighlightedUri(uri: AtUri<'static>) -> Element {
281
281
+
let s = uri.as_str();
282
282
+
let link = format!("/record#{}", s);
283
283
+
284
284
+
if let Some(rest) = s.strip_prefix("at://") {
285
285
+
let parts: Vec<&str> = rest.splitn(3, '/').collect();
286
286
+
return rsx! {
287
287
+
a {
288
288
+
href: "{link}",
289
289
+
class: "uri-link",
290
290
+
span { class: "string-at-uri",
291
291
+
span { class: "aturi-scheme", "at://" }
292
292
+
span { class: "aturi-authority", "{uri.authority()}" }
293
293
+
294
294
+
if parts.len() > 1 {
295
295
+
span { class: "aturi-slash", "/" }
296
296
+
if let Some(collection) = uri.collection() {
297
297
+
span { class: "aturi-collection", "{collection.as_ref()}" }
298
298
+
}
299
299
+
}
300
300
+
if parts.len() > 2 {
301
301
+
span { class: "aturi-slash", "/" }
302
302
+
if let Some(rkey) = uri.rkey() {
303
303
+
span { class: "aturi-rkey", "{rkey.as_ref()}" }
304
304
+
}
305
305
+
}
306
306
+
}
307
307
+
}
308
308
+
};
309
309
+
}
310
310
+
311
311
+
rsx! { span { class: "string-at-uri", "{s}" } }
312
312
+
}
313
313
+
314
314
+
#[component]
315
315
+
fn HighlightedString(string_type: jacquard::types::string::AtprotoStr<'static>) -> Element {
316
316
+
use jacquard::types::string::AtprotoStr;
317
317
+
318
318
+
match &string_type {
319
319
+
AtprotoStr::Nsid(nsid) => {
320
320
+
let parts: Vec<&str> = nsid.as_str().split('.').collect();
321
321
+
rsx! {
322
322
+
span { class: "string-nsid",
323
323
+
for (i, part) in parts.iter().enumerate() {
324
324
+
span { class: "nsid-segment nsid-segment-{i % 3}", "{part}" }
325
325
+
if i < parts.len() - 1 {
326
326
+
span { class: "nsid-dot", "." }
327
327
+
}
328
328
+
}
329
329
+
}
330
330
+
}
331
331
+
}
332
332
+
AtprotoStr::Did(did) => {
333
333
+
let s = did.as_str();
334
334
+
if let Some(rest) = s.strip_prefix("did:") {
335
335
+
if let Some((method, identifier)) = rest.split_once(':') {
336
336
+
return rsx! {
337
337
+
span { class: "string-did",
338
338
+
span { class: "did-scheme", "did:" }
339
339
+
span { class: "did-method", "{method}" }
340
340
+
span { class: "did-separator", ":" }
341
341
+
span { class: "did-identifier", "{identifier}" }
342
342
+
}
343
343
+
};
344
344
+
}
345
345
+
}
346
346
+
rsx! { span { class: "string-did", "{s}" } }
347
347
+
}
348
348
+
AtprotoStr::Handle(handle) => {
349
349
+
let parts: Vec<&str> = handle.as_str().split('.').collect();
350
350
+
rsx! {
351
351
+
span { class: "string-handle",
352
352
+
for (i, part) in parts.iter().enumerate() {
353
353
+
span { class: "handle-segment handle-segment-{i % 2}", "{part}" }
354
354
+
if i < parts.len() - 1 {
355
355
+
span { class: "handle-dot", "." }
356
356
+
}
357
357
+
}
358
358
+
}
359
359
+
}
360
360
+
}
361
361
+
AtprotoStr::AtUri(uri) => {
362
362
+
rsx! {
363
363
+
HighlightedUri { uri: uri.clone().into_static() }
364
364
+
}
365
365
+
}
366
366
+
AtprotoStr::Uri(uri) => {
367
367
+
let s = uri.as_str();
368
368
+
if let Ok(at_uri) = AtUri::new(s) {
369
369
+
return rsx! {
370
370
+
HighlightedUri { uri: at_uri.into_static() }
371
371
+
};
372
372
+
}
373
373
+
374
374
+
// Try to parse scheme
375
375
+
if let Some((scheme, rest)) = s.split_once("://") {
376
376
+
// Split authority and path
377
377
+
let (authority, path) = if let Some(idx) = rest.find('/') {
378
378
+
(&rest[..idx], &rest[idx..])
379
379
+
} else {
380
380
+
(rest, "")
381
381
+
};
382
382
+
383
383
+
return rsx! {
384
384
+
a {
385
385
+
href: "{s}",
386
386
+
target: "_blank",
387
387
+
rel: "noopener noreferrer",
388
388
+
class: "uri-link",
389
389
+
span { class: "string-uri",
390
390
+
span { class: "uri-scheme", "{scheme}" }
391
391
+
span { class: "uri-separator", "://" }
392
392
+
span { class: "uri-authority", "{authority}" }
393
393
+
if !path.is_empty() {
394
394
+
span { class: "uri-path", "{path}" }
395
395
+
}
396
396
+
}
397
397
+
}
398
398
+
};
399
399
+
}
400
400
+
401
401
+
rsx! { span { class: "string-uri", "{s}" } }
402
402
+
}
403
403
+
_ => {
404
404
+
let value = string_type.as_str();
405
405
+
rsx! { "{value}" }
406
406
+
}
407
407
+
}
408
408
+
}
409
409
+
410
410
+
#[derive(Props, Clone, PartialEq)]
411
411
+
pub struct CodeViewProps {
412
412
+
#[props(default)]
413
413
+
id: Signal<String>,
414
414
+
#[props(default)]
415
415
+
class: Signal<String>,
416
416
+
code: ReadSignal<String>,
417
417
+
lang: Option<String>,
418
418
+
}
419
419
+
420
420
+
/// Render some text as markdown.
421
421
+
#[component]
422
422
+
pub fn CodeView(props: CodeViewProps) -> Element {
423
423
+
let code = &*props.code.read();
424
424
+
425
425
+
let mut html_buf = String::new();
426
426
+
highlight_code(props.lang.as_deref(), code, &mut html_buf).unwrap();
427
427
+
428
428
+
rsx! {
429
429
+
document::Style { {generate_default_css().unwrap()}}
430
430
+
div {
431
431
+
id: "{&*props.id.read()}",
432
432
+
class: "{&*props.class.read()}",
433
433
+
dangerous_inner_html: "{html_buf}"
434
434
+
}
435
435
+
}
436
436
+
}
+38
crates/weaver-renderer/src/code_pretty.rs
···
47
47
}
48
48
49
49
pub const CSS_PREFIX: &str = "wvc-";
50
50
+
51
51
+
pub fn highlight_code<M>(
52
52
+
lang: Option<&str>,
53
53
+
code: impl AsRef<str>,
54
54
+
writer: &mut M,
55
55
+
) -> Result<(), Box<dyn std::error::Error + Send + Sync + 'static>>
56
56
+
where
57
57
+
M: StrWrite,
58
58
+
<M as StrWrite>::Error: std::error::Error + Send + Sync + 'static,
59
59
+
{
60
60
+
let syn_set = SyntaxSet::load_defaults_newlines();
61
61
+
let lang_syn = if let Some(lang) = lang {
62
62
+
syn_set
63
63
+
.find_syntax_by_token(lang)
64
64
+
.unwrap_or_else(|| syn_set.find_syntax_plain_text())
65
65
+
} else {
66
66
+
syn_set
67
67
+
.find_syntax_by_first_line(code.as_ref())
68
68
+
.unwrap_or_else(|| syn_set.find_syntax_plain_text())
69
69
+
};
70
70
+
writer.write_str("<pre><code class=\"wvc-code language-")?;
71
71
+
writer.write_str(&lang_syn.name)?;
72
72
+
writer.write_str("\">")?;
73
73
+
74
74
+
let mut html_gen = ClassedHTMLGenerator::new_with_class_style(
75
75
+
lang_syn,
76
76
+
&syn_set,
77
77
+
ClassStyle::SpacedPrefixed { prefix: CSS_PREFIX },
78
78
+
);
79
79
+
for line in LinesWithEndings::from(code.as_ref()) {
80
80
+
html_gen
81
81
+
.parse_html_for_line_which_includes_newline(line)
82
82
+
.unwrap();
83
83
+
}
84
84
+
writer.write_str(&html_gen.finalize())?;
85
85
+
writer.write_str("</code></pre>")?;
86
86
+
Ok(())
87
87
+
}
+48
crates/weaver-renderer/src/css.rs
···
461
461
462
462
Ok(result)
463
463
}
464
464
+
465
465
+
pub fn generate_default_css() -> miette::Result<String> {
466
466
+
let mut theme_set = ThemeSet::load_defaults();
467
467
+
let rose_pine = {
468
468
+
let mut cursor = Cursor::new(ROSE_PINE_THEME.as_bytes());
469
469
+
ThemeSet::load_from_reader(&mut cursor)
470
470
+
.into_diagnostic()
471
471
+
.map_err(|e| miette::miette!("Failed to load embedded rose-pine theme: {}", e))?
472
472
+
};
473
473
+
let rose_pine_dawn = {
474
474
+
let mut cursor = Cursor::new(ROSE_PINE_DAWN_THEME.as_bytes());
475
475
+
ThemeSet::load_from_reader(&mut cursor)
476
476
+
.into_diagnostic()
477
477
+
.map_err(|e| miette::miette!("Failed to load embedded rose-pine-dawn theme: {}", e))?
478
478
+
};
479
479
+
theme_set.themes.insert("rose-pine".to_string(), rose_pine);
480
480
+
theme_set
481
481
+
.themes
482
482
+
.insert("rose-pine-dawn".to_string(), rose_pine_dawn);
483
483
+
// Generate dark mode CSS (default)
484
484
+
let dark_css = css_for_theme_with_class_style(
485
485
+
theme_set.themes.get("rose-pine").unwrap(),
486
486
+
ClassStyle::SpacedPrefixed {
487
487
+
prefix: crate::code_pretty::CSS_PREFIX,
488
488
+
},
489
489
+
)
490
490
+
.into_diagnostic()?;
491
491
+
492
492
+
// Generate light mode CSS
493
493
+
let light_css = css_for_theme_with_class_style(
494
494
+
theme_set.themes.get("rose-pine-dawn").unwrap(),
495
495
+
ClassStyle::SpacedPrefixed {
496
496
+
prefix: crate::code_pretty::CSS_PREFIX,
497
497
+
},
498
498
+
)
499
499
+
.into_diagnostic()?;
500
500
+
501
501
+
// Combine with media queries
502
502
+
let mut result = String::new();
503
503
+
result.push_str("/* Syntax highlighting - Light Mode (default) */\n");
504
504
+
result.push_str(&light_css);
505
505
+
result.push_str("\n\n/* Syntax highlighting - Dark Mode */\n");
506
506
+
result.push_str("@media (prefers-color-scheme: dark) {\n");
507
507
+
result.push_str(&dark_css);
508
508
+
result.push_str("}\n");
509
509
+
510
510
+
Ok(result)
511
511
+
}