tangled
alpha
login
or
join now
nonbinary.computer
/
weaver
atproto blogging
24
fork
atom
overview
issues
2
pulls
pipelines
more of an actual home page now
Orual
1 month ago
21e174ee
ee72d593
+641
-47
9 changed files
expand all
collapse all
unified
split
crates
weaver-app
assets
styling
entry-card.css
home.css
navbar.css
src
components
entry.rs
mod.rs
data.rs
fetch.rs
views
home.rs
navbar.rs
+60
-2
crates/weaver-app/assets/styling/entry-card.css
···
105
105
}
106
106
107
107
.entry-card-meta {
108
108
-
gap: 1rem;
109
108
margin-bottom: 0.5rem;
110
109
font-size: 0.875rem;
111
110
color: var(--color-subtle);
112
111
}
113
112
113
113
+
a.entry-card-author,
114
114
.entry-card-author {
115
115
-
margin-left: auto;
116
115
display: flex;
117
116
align-items: center;
118
117
gap: 0.5rem;
118
118
+
margin-top: 0.25rem;
119
119
+
text-decoration: none;
119
120
}
120
121
121
122
.entry-card-author .author-name {
122
123
font-weight: 500;
123
124
color: var(--color-text);
125
125
+
transition: color 0.2s ease;
126
126
+
}
127
127
+
128
128
+
.entry-card-author .meta-label {
129
129
+
color: var(--color-muted);
130
130
+
font-size: 0.8rem;
131
131
+
transition: color 0.2s ease;
132
132
+
}
133
133
+
134
134
+
.entry-card-author:hover .author-name,
135
135
+
.entry-card-author:hover .meta-label,
136
136
+
.feed-entry-card .entry-card-author:hover .meta-label {
137
137
+
color: var(--color-link);
124
138
}
125
139
126
140
.entry-card-date {
···
128
142
font-size: 0.8rem;
129
143
}
130
144
145
145
+
/* Feed entry card - matches existing entry-card patterns */
146
146
+
.feed-entry-card {
147
147
+
background: var(--color-surface);
148
148
+
box-shadow: 0 1px 2px color-mix(in srgb, var(--color-text) 6%, transparent);
149
149
+
padding: 1.25rem;
150
150
+
transition:
151
151
+
box-shadow 0.2s ease,
152
152
+
border-color 0.2s ease;
153
153
+
}
154
154
+
155
155
+
.feed-entry-card:hover {
156
156
+
border-left: 2px solid var(--color-secondary);
157
157
+
margin-left: -1px;
158
158
+
}
159
159
+
160
160
+
.feed-entry-card:hover .entry-card-title {
161
161
+
color: var(--color-secondary);
162
162
+
}
163
163
+
164
164
+
.feed-entry-card .entry-card-title {
165
165
+
margin-bottom: 0.5rem;
166
166
+
}
167
167
+
168
168
+
.feed-entry-card .entry-card-byline {
169
169
+
display: flex;
170
170
+
align-items: center;
171
171
+
gap: 0.5rem;
172
172
+
margin-bottom: 0.5rem;
173
173
+
}
174
174
+
175
175
+
.feed-entry-card .entry-card-byline .entry-card-date {
176
176
+
margin-left: auto;
177
177
+
}
178
178
+
131
179
.entry-card-tags {
132
180
display: flex;
133
181
gap: 0.4rem;
···
196
244
.entry-card-link {
197
245
box-shadow: none;
198
246
border: 1px solid var(--color-border);
247
247
+
}
248
248
+
249
249
+
.feed-entry-card {
250
250
+
box-shadow: none;
251
251
+
border-top: 1px solid var(--color-border);
252
252
+
border-right: 1px solid var(--color-border);
253
253
+
border-bottom: 1px solid var(--color-border);
254
254
+
255
255
+
border-left: 1px solid var(--color-border);
256
256
+
/* Keep border-left as accent */
199
257
}
200
258
}
+51
crates/weaver-app/assets/styling/home.css
···
1
1
+
.home-container {
2
2
+
max-width: 1000px;
3
3
+
margin: 0 auto;
4
4
+
padding: 1rem;
5
5
+
}
6
6
+
7
7
+
.section-header {
8
8
+
font-size: 1.25rem;
9
9
+
font-weight: 600;
10
10
+
color: var(--color-text-muted);
11
11
+
margin-bottom: 1rem;
12
12
+
padding-bottom: 0.5rem;
13
13
+
}
14
14
+
15
15
+
.pinned-section {
16
16
+
margin-bottom: 2rem;
17
17
+
}
18
18
+
19
19
+
.pinned-items {
20
20
+
display: flex;
21
21
+
flex-direction: column;
22
22
+
gap: 1rem;
23
23
+
}
24
24
+
25
25
+
.pinned-items .entry-card,
26
26
+
.pinned-items .feed-entry-card,
27
27
+
.pinned-items .notebook-card {
28
28
+
border-left: 3px solid var(--color-primary) !important;
29
29
+
}
30
30
+
31
31
+
.pinned-items .feed-entry-card:hover {
32
32
+
border-left: 3px solid var(--color-secondary) !important;
33
33
+
margin-left: 0;
34
34
+
}
35
35
+
36
36
+
.feed-section {
37
37
+
margin-bottom: 2rem;
38
38
+
}
39
39
+
40
40
+
.entries-feed {
41
41
+
display: flex;
42
42
+
flex-direction: column;
43
43
+
gap: 1rem;
44
44
+
}
45
45
+
46
46
+
.loading,
47
47
+
.pinned-item-loading {
48
48
+
color: var(--color-text-muted);
49
49
+
padding: 2rem;
50
50
+
text-align: center;
51
51
+
}
+19
crates/weaver-app/assets/styling/navbar.css
···
43
43
color: var(--color-text, #666);
44
44
font-weight: 500;
45
45
}
46
46
+
47
47
+
.nav-tools {
48
48
+
display: flex;
49
49
+
align-items: center;
50
50
+
gap: 1rem;
51
51
+
margin-left: auto;
52
52
+
margin-right: 1rem;
53
53
+
}
54
54
+
55
55
+
.nav-tool-link {
56
56
+
color: var(--color-text-muted);
57
57
+
text-decoration: none;
58
58
+
font-size: 0.875rem;
59
59
+
transition: color 0.2s ease;
60
60
+
}
61
61
+
62
62
+
.nav-tool-link:hover {
63
63
+
color: var(--color-primary);
64
64
+
}
+194
-10
crates/weaver-app/src/components/entry.rs
···
9
9
data::use_handle,
10
10
};
11
11
use dioxus::prelude::*;
12
12
-
use jacquard::IntoStatic;
13
12
use jacquard::types::aturi::AtUri;
13
13
+
use jacquard::{IntoStatic, types::string::Handle};
14
14
15
15
pub const ENTRY_CSS: Asset = asset!("/assets/styling/entry.css");
16
16
···
75
75
book_title(),
76
76
title()
77
77
);
78
78
-
tracing::debug!("[EntryPage] rendering, entry.is_some={}", entry.read().is_some());
78
78
+
tracing::debug!(
79
79
+
"[EntryPage] rendering, entry.is_some={}",
80
80
+
entry.read().is_some()
81
81
+
);
79
82
80
83
// Handle blob caching when entry data is available
81
84
// Use read() instead of read_unchecked() for proper reactive tracking
···
149
152
}
150
153
}
151
154
155
155
+
/// Truncate markdown content for preview (preserves markdown syntax)
156
156
+
/// Takes first few paragraphs up to max_chars, truncating at paragraph boundary
157
157
+
fn truncate_markdown_preview(content: &str, max_chars: usize, max_paragraphs: usize) -> String {
158
158
+
let mut result = String::new();
159
159
+
let mut char_count = 0;
160
160
+
let mut para_count = 0;
161
161
+
let mut in_code_block = false;
162
162
+
163
163
+
for line in content.lines() {
164
164
+
// Track code blocks to avoid breaking them
165
165
+
if line.trim().starts_with("```") {
166
166
+
in_code_block = !in_code_block;
167
167
+
// Skip code blocks in preview entirely
168
168
+
if in_code_block {
169
169
+
continue;
170
170
+
}
171
171
+
}
172
172
+
173
173
+
if in_code_block {
174
174
+
continue;
175
175
+
}
176
176
+
177
177
+
// Skip headings, images in preview
178
178
+
let trimmed = line.trim();
179
179
+
if trimmed.starts_with('#') || trimmed.starts_with('!') {
180
180
+
continue;
181
181
+
}
182
182
+
183
183
+
// Empty line = paragraph boundary
184
184
+
if trimmed.is_empty() {
185
185
+
if !result.is_empty() && !result.ends_with("\n\n") {
186
186
+
para_count += 1;
187
187
+
if para_count >= max_paragraphs || char_count >= max_chars {
188
188
+
break;
189
189
+
}
190
190
+
result.push_str("\n\n");
191
191
+
}
192
192
+
continue;
193
193
+
}
194
194
+
195
195
+
// Check if adding this line would exceed limit
196
196
+
if char_count + line.len() > max_chars && !result.is_empty() {
197
197
+
break;
198
198
+
}
199
199
+
200
200
+
if !result.is_empty() && !result.ends_with('\n') {
201
201
+
result.push('\n');
202
202
+
}
203
203
+
result.push_str(line);
204
204
+
char_count += line.len();
205
205
+
}
206
206
+
207
207
+
result.trim().to_string()
208
208
+
}
209
209
+
152
210
/// OpenGraph and Twitter Card meta tags for entries
153
211
#[component]
154
212
pub fn EntryOgMeta(
···
225
283
} else {
226
284
crate::env::WEAVER_APP_HOST.to_string()
227
285
};
228
228
-
let canonical_url = format!(
229
229
-
"{}/{}/{}/{}",
230
230
-
base,
231
231
-
ident(),
232
232
-
book_title(),
233
233
-
entry_path
234
234
-
);
286
286
+
let canonical_url = format!("{}/{}/{}/{}", base, ident(), book_title(), entry_path);
235
287
let og_image_url = format!(
236
288
"{}/og/{}/{}/{}.png",
237
289
base,
···
366
418
None
367
419
};
368
420
369
369
-
// Render preview from entry content
421
421
+
// Render preview from truncated entry content
370
422
let preview_html = parsed_entry.as_ref().map(|entry| {
371
423
let parser = markdown_weaver::Parser::new(&entry.content);
372
424
let mut html_buf = String::new();
···
451
503
if let Some(ref html) = preview_html {
452
504
div { class: "entry-card-preview", dangerous_inner_html: "{html}" }
453
505
}
506
506
+
if let Some(ref tags) = entry_view.tags {
507
507
+
if !tags.is_empty() {
508
508
+
div { class: "entry-card-tags",
509
509
+
for tag in tags.iter() {
510
510
+
span { class: "entry-card-tag", "{tag}" }
511
511
+
}
512
512
+
}
513
513
+
}
514
514
+
}
515
515
+
}
516
516
+
}
517
517
+
}
518
518
+
519
519
+
/// Card for entries in a feed (e.g., home page)
520
520
+
/// Takes EntryView directly (not BookEntryView) and always shows author info
521
521
+
#[component]
522
522
+
pub fn FeedEntryCard(entry_view: EntryView<'static>, entry: entry::Entry<'static>) -> Element {
523
523
+
use crate::Route;
524
524
+
use jacquard::from_data;
525
525
+
use weaver_api::sh_weaver::actor::ProfileDataViewInner;
526
526
+
527
527
+
let title = entry_view
528
528
+
.title
529
529
+
.as_ref()
530
530
+
.map(|t| t.as_ref())
531
531
+
.unwrap_or("Untitled");
532
532
+
533
533
+
// Extract DID and rkey from the entry URI
534
534
+
let uri = &entry_view.uri;
535
535
+
let parsed_uri = jacquard::types::aturi::AtUri::new(uri.as_ref()).ok();
536
536
+
537
537
+
let ident = parsed_uri
538
538
+
.as_ref()
539
539
+
.map(|u| u.authority().clone().into_static())
540
540
+
.unwrap_or_else(|| AtIdentifier::Handle(Handle::new_static("invalid.handle").unwrap()));
541
541
+
542
542
+
let rkey: SmolStr = parsed_uri
543
543
+
.as_ref()
544
544
+
.and_then(|u| u.rkey().map(|r| SmolStr::new(r.0.as_str())))
545
545
+
.unwrap_or_default();
546
546
+
547
547
+
// Format date from record's created_at
548
548
+
let formatted_date = entry.created_at.as_ref().format("%B %d, %Y").to_string();
549
549
+
550
550
+
// Get first author
551
551
+
let first_author = entry_view.authors.first();
552
552
+
553
553
+
// Render preview from truncated entry content
554
554
+
let preview_html = {
555
555
+
let parser = markdown_weaver::Parser::new(&entry.content);
556
556
+
let mut html_buf = String::new();
557
557
+
markdown_weaver::html::push_html(&mut html_buf, parser);
558
558
+
html_buf
559
559
+
};
560
560
+
561
561
+
rsx! {
562
562
+
div { class: "entry-card feed-entry-card",
563
563
+
// Title
564
564
+
Link {
565
565
+
to: Route::StandaloneEntry {
566
566
+
ident: ident.clone(),
567
567
+
rkey: rkey.clone().into()
568
568
+
},
569
569
+
class: "entry-card-title-link",
570
570
+
h3 { class: "entry-card-title", "{title}" }
571
571
+
}
572
572
+
573
573
+
// Byline: author + date
574
574
+
div { class: "entry-card-byline",
575
575
+
if let Some(author) = first_author {
576
576
+
{
577
577
+
match &author.record.inner {
578
578
+
ProfileDataViewInner::ProfileView(profile) => {
579
579
+
let display_name = profile.display_name.as_ref().map(|n| n.as_ref()).unwrap_or("Unknown");
580
580
+
let handle = profile.handle.clone();
581
581
+
rsx! {
582
582
+
Link {
583
583
+
to: Route::RepositoryIndex { ident: AtIdentifier::Handle(handle.clone()) },
584
584
+
class: "entry-card-author",
585
585
+
if let Some(ref avatar_url) = profile.avatar {
586
586
+
Avatar {
587
587
+
AvatarImage { src: avatar_url.as_ref() }
588
588
+
}
589
589
+
}
590
590
+
span { class: "author-name", "{display_name}" }
591
591
+
span { class: "meta-label", "@{handle}" }
592
592
+
}
593
593
+
}
594
594
+
}
595
595
+
ProfileDataViewInner::ProfileViewDetailed(profile) => {
596
596
+
let display_name = profile.display_name.as_ref().map(|n| n.as_ref()).unwrap_or("Unknown");
597
597
+
let handle = profile.handle.clone();
598
598
+
rsx! {
599
599
+
Link {
600
600
+
to: Route::RepositoryIndex { ident: AtIdentifier::Handle(handle.clone()) },
601
601
+
class: "entry-card-author",
602
602
+
if let Some(ref avatar_url) = profile.avatar {
603
603
+
Avatar {
604
604
+
AvatarImage { src: avatar_url.as_ref() }
605
605
+
}
606
606
+
}
607
607
+
span { class: "author-name", "{display_name}" }
608
608
+
span { class: "meta-label", "@{handle}" }
609
609
+
}
610
610
+
}
611
611
+
}
612
612
+
ProfileDataViewInner::TangledProfileView(profile) => {
613
613
+
let handle = profile.handle.clone();
614
614
+
rsx! {
615
615
+
Link {
616
616
+
to: Route::RepositoryIndex { ident: AtIdentifier::Handle(handle.clone()) },
617
617
+
class: "entry-card-author",
618
618
+
span { class: "author-name", "@{handle}" }
619
619
+
}
620
620
+
}
621
621
+
}
622
622
+
_ => {
623
623
+
rsx! {
624
624
+
div { class: "entry-card-author",
625
625
+
span { class: "author-name", "Unknown" }
626
626
+
}
627
627
+
}
628
628
+
}
629
629
+
}
630
630
+
}
631
631
+
}
632
632
+
div { class: "entry-card-date",
633
633
+
time { datetime: "{entry.created_at.as_str()}", "{formatted_date}" }
634
634
+
}
635
635
+
}
636
636
+
637
637
+
div { class: "entry-card-preview", dangerous_inner_html: "{preview_html}" }
454
638
if let Some(ref tags) = entry_view.tags {
455
639
if !tags.is_empty() {
456
640
div { class: "entry-card-tags",
+2
-2
crates/weaver-app/src/components/mod.rs
···
8
8
mod entry;
9
9
#[allow(unused_imports)]
10
10
pub use entry::{
11
11
-
ENTRY_CSS, EntryCard, EntryMarkdown, EntryMetadata, EntryOgMeta, EntryPage, NavButton,
12
12
-
extract_preview,
11
11
+
ENTRY_CSS, EntryCard, EntryMarkdown, EntryMetadata, EntryOgMeta, EntryPage, FeedEntryCard,
12
12
+
NavButton, extract_preview,
13
13
};
14
14
15
15
pub mod identity;
+77
-1
crates/weaver-app/src/data.rs
···
25
25
use std::sync::Arc;
26
26
use weaver_api::com_atproto::repo::strong_ref::StrongRef;
27
27
use weaver_api::sh_weaver::actor::ProfileDataView;
28
28
-
use weaver_api::sh_weaver::notebook::{BookEntryView, NotebookView, entry::Entry};
28
28
+
use weaver_api::sh_weaver::notebook::{BookEntryView, EntryView, NotebookView, entry::Entry};
29
29
// ============================================================================
30
30
// Wrapper Hooks (feature-gated)
31
31
// ============================================================================
···
633
633
.ok()
634
634
.map(|notebooks| {
635
635
notebooks
636
636
+
.iter()
637
637
+
.map(|arc| arc.as_ref().clone())
638
638
+
.collect::<Vec<_>>()
639
639
+
})
640
640
+
}
641
641
+
});
642
642
+
let memo = use_memo(move || res.read().clone().flatten());
643
643
+
(res, memo)
644
644
+
}
645
645
+
646
646
+
/// Fetches entries from UFOS with SSR support in fullstack mode
647
647
+
#[cfg(feature = "fullstack-server")]
648
648
+
pub fn use_entries_from_ufos() -> (
649
649
+
Result<Resource<Option<Vec<(serde_json::Value, serde_json::Value, u64)>>>, RenderError>,
650
650
+
Memo<Option<Vec<(EntryView<'static>, Entry<'static>, u64)>>>,
651
651
+
) {
652
652
+
let fetcher = use_context::<crate::fetch::Fetcher>();
653
653
+
let res = use_server_future(move || {
654
654
+
let fetcher = fetcher.clone();
655
655
+
async move {
656
656
+
match fetcher.fetch_entries_from_ufos().await {
657
657
+
Ok(entries) => {
658
658
+
Some(
659
659
+
entries
660
660
+
.iter()
661
661
+
.filter_map(|arc| {
662
662
+
let (view, entry, time) = arc.as_ref();
663
663
+
let view_json = serde_json::to_value(view).ok()?;
664
664
+
let entry_json = serde_json::to_value(entry).ok()?;
665
665
+
Some((view_json, entry_json, *time))
666
666
+
})
667
667
+
.collect::<Vec<_>>()
668
668
+
)
669
669
+
}
670
670
+
Err(e) => {
671
671
+
tracing::error!("[use_entries_from_ufos] fetch failed: {:?}", e);
672
672
+
None
673
673
+
}
674
674
+
}
675
675
+
}
676
676
+
});
677
677
+
let memo = use_memo(use_reactive!(|res| {
678
678
+
let res = res.as_ref().ok()?;
679
679
+
if let Some(Some(values)) = &*res.read() {
680
680
+
let result: Vec<_> = values
681
681
+
.iter()
682
682
+
.filter_map(|(view_json, entry_json, time)| {
683
683
+
let view = jacquard::from_json_value::<EntryView>(view_json.clone()).ok()?;
684
684
+
let entry = jacquard::from_json_value::<Entry>(entry_json.clone()).ok()?;
685
685
+
Some((view, entry, *time))
686
686
+
})
687
687
+
.collect();
688
688
+
Some(result)
689
689
+
} else {
690
690
+
None
691
691
+
}
692
692
+
}));
693
693
+
(res, memo)
694
694
+
}
695
695
+
696
696
+
/// Fetches entries from UFOS client-side only (no SSR)
697
697
+
#[cfg(not(feature = "fullstack-server"))]
698
698
+
pub fn use_entries_from_ufos() -> (
699
699
+
Resource<Option<Vec<(EntryView<'static>, Entry<'static>, u64)>>>,
700
700
+
Memo<Option<Vec<(EntryView<'static>, Entry<'static>, u64)>>>,
701
701
+
) {
702
702
+
let fetcher = use_context::<crate::fetch::Fetcher>();
703
703
+
let res = use_resource(move || {
704
704
+
let fetcher = fetcher.clone();
705
705
+
async move {
706
706
+
fetcher
707
707
+
.fetch_entries_from_ufos()
708
708
+
.await
709
709
+
.ok()
710
710
+
.map(|entries| {
711
711
+
entries
636
712
.iter()
637
713
.map(|arc| arc.as_ref().clone())
638
714
.collect::<Vec<_>>()
+62
crates/weaver-app/src/fetch.rs
···
480
480
Ok(notebooks)
481
481
}
482
482
483
483
+
/// Fetch entries from UFOS discovery service (reverse chronological)
484
484
+
pub async fn fetch_entries_from_ufos(
485
485
+
&self,
486
486
+
) -> Result<Vec<Arc<(EntryView<'static>, Entry<'static>, u64)>>> {
487
487
+
use jacquard::{IntoStatic, types::aturi::AtUri, types::ident::AtIdentifier};
488
488
+
489
489
+
let url = "https://ufos-api.microcosm.blue/records?collection=sh.weaver.notebook.entry";
490
490
+
491
491
+
let response = reqwest::get(url)
492
492
+
.await
493
493
+
.map_err(|e| {
494
494
+
tracing::error!("[fetch_entries_from_ufos] request failed: {:?}", e);
495
495
+
dioxus::CapturedError::from_display(e)
496
496
+
})?;
497
497
+
498
498
+
let mut records: Vec<UfosRecord> = response
499
499
+
.json()
500
500
+
.await
501
501
+
.map_err(|e| {
502
502
+
tracing::error!("[fetch_entries_from_ufos] json parse failed: {:?}", e);
503
503
+
dioxus::CapturedError::from_display(e)
504
504
+
})?;
505
505
+
506
506
+
// Sort by time_us descending (reverse chronological)
507
507
+
records.sort_by(|a, b| b.time_us.cmp(&a.time_us));
508
508
+
509
509
+
let mut entries = Vec::new();
510
510
+
let client = self.get_client();
511
511
+
512
512
+
for ufos_record in records {
513
513
+
// Parse DID
514
514
+
let did = match Did::new(&ufos_record.did) {
515
515
+
Ok(d) => d.into_static(),
516
516
+
Err(e) => {
517
517
+
tracing::warn!("[fetch_entries_from_ufos] invalid DID {}: {:?}", ufos_record.did, e);
518
518
+
continue;
519
519
+
}
520
520
+
};
521
521
+
let ident = AtIdentifier::Did(did);
522
522
+
523
523
+
// Fetch the entry view
524
524
+
match client
525
525
+
.fetch_entry_by_rkey(&ident, &ufos_record.rkey)
526
526
+
.await
527
527
+
{
528
528
+
Ok((entry_view, entry)) => {
529
529
+
entries.push(Arc::new((
530
530
+
entry_view.into_static(),
531
531
+
entry.into_static(),
532
532
+
ufos_record.time_us,
533
533
+
)));
534
534
+
}
535
535
+
Err(e) => {
536
536
+
tracing::warn!("[fetch_entries_from_ufos] failed to load entry {}: {:?}", ufos_record.rkey, e);
537
537
+
continue;
538
538
+
}
539
539
+
}
540
540
+
}
541
541
+
542
542
+
Ok(entries)
543
543
+
}
544
544
+
483
545
pub async fn fetch_notebooks_for_did(
484
546
&self,
485
547
ident: &AtIdentifier<'_>,
+134
-27
crates/weaver-app/src/views/home.rs
···
1
1
-
use crate::{Route, components::identity::NotebookCard, data};
1
1
+
use crate::{
2
2
+
components::{FeedEntryCard, NotebookCard, css::DefaultNotebookCss},
3
3
+
data,
4
4
+
};
2
5
use dioxus::prelude::*;
3
3
-
use jacquard::types::aturi::AtUri;
6
6
+
use jacquard::smol_str::SmolStr;
7
7
+
use jacquard::types::ident::AtIdentifier;
8
8
+
use jacquard::types::string::Did;
9
9
+
10
10
+
/// Pinned content items - can be notebooks or entries
11
11
+
#[derive(Clone, PartialEq)]
12
12
+
pub enum PinnedItem {
13
13
+
Notebook {
14
14
+
ident: AtIdentifier<'static>,
15
15
+
title: SmolStr,
16
16
+
},
17
17
+
#[allow(dead_code)]
18
18
+
Entry {
19
19
+
ident: AtIdentifier<'static>,
20
20
+
rkey: SmolStr,
21
21
+
},
22
22
+
}
23
23
+
24
24
+
/// Hardcoded pinned items
25
25
+
fn pinned_items() -> Vec<PinnedItem> {
26
26
+
vec![
27
27
+
// Add pinned items here, e.g.:
28
28
+
// PinnedItem::Notebook {
29
29
+
// ident: AtIdentifier::Did(Did::new_static("did:plc:yfvwmnlztr4dwkb7hwz55r2g").unwrap()),
30
30
+
// title: SmolStr::new_static("Weaver"),
31
31
+
// },
32
32
+
PinnedItem::Entry {
33
33
+
ident: AtIdentifier::Did(Did::new_static("did:plc:yfvwmnlztr4dwkb7hwz55r2g").unwrap()),
34
34
+
rkey: SmolStr::new_static("3m4rbphjzt62b"),
35
35
+
},
36
36
+
]
37
37
+
}
4
38
5
39
/// OpenGraph and Twitter Card meta tags for the homepage
6
40
#[component]
···
32
66
}
33
67
34
68
const NOTEBOOK_CARD_CSS: Asset = asset!("/assets/styling/notebook-card.css");
69
69
+
const ENTRY_CSS: Asset = asset!("/assets/styling/entry.css");
70
70
+
const ENTRY_CARD_CSS: Asset = asset!("/assets/styling/entry-card.css");
71
71
+
const HOME_CSS: Asset = asset!("/assets/styling/home.css");
35
72
36
73
/// The Home page component that will be rendered when the current route is `[Route::Home]`
37
74
#[component]
38
75
pub fn Home() -> Element {
39
39
-
// Fetch notebooks from UFOS with SSR support
40
40
-
let (notebooks_result, notebooks) = data::use_notebooks_from_ufos();
76
76
+
// Fetch entries from UFOS with SSR support
77
77
+
let (entries_result, entries) = data::use_entries_from_ufos();
78
78
+
79
79
+
let pinned = pinned_items();
80
80
+
let has_pinned = !pinned.is_empty();
41
81
42
82
#[cfg(feature = "fullstack-server")]
43
43
-
notebooks_result
44
44
-
.as_ref()
45
45
-
.ok()
46
46
-
.map(|r| r.suspend())
47
47
-
.transpose()?;
83
83
+
let _entries_res = entries_result?;
48
84
49
85
rsx! {
50
86
SiteOgMeta {}
87
87
+
88
88
+
document::Link { rel: "stylesheet", href: HOME_CSS }
51
89
document::Link { rel: "stylesheet", href: NOTEBOOK_CARD_CSS }
90
90
+
document::Link { rel: "stylesheet", href: ENTRY_CSS }
91
91
+
document::Link { rel: "stylesheet", href: ENTRY_CARD_CSS }
92
92
+
DefaultNotebookCss { }
52
93
div {
53
53
-
class: "record-view-container",
94
94
+
class: "home-container",
54
95
55
55
-
div { class: "notebooks-list",
56
56
-
match &*notebooks.read() {
57
57
-
Some(notebook_list) => rsx! {
58
58
-
for notebook in notebook_list.iter() {
59
59
-
{
60
60
-
let view = ¬ebook.0;
61
61
-
let entries = ¬ebook.1;
62
62
-
rsx! {
63
63
-
div {
64
64
-
key: "{view.cid}",
65
65
-
NotebookCard {
66
66
-
notebook: view.clone(),
67
67
-
entry_refs: entries.clone()
68
68
-
}
96
96
+
// Pinned section
97
97
+
if has_pinned {
98
98
+
section { class: "pinned-section",
99
99
+
h2 { class: "section-header", "Featured" }
100
100
+
div { class: "pinned-items",
101
101
+
for item in pinned.into_iter() {
102
102
+
PinnedItemCard { item }
103
103
+
}
104
104
+
}
105
105
+
}
106
106
+
}
107
107
+
108
108
+
// Main feed
109
109
+
section { class: "feed-section",
110
110
+
h2 { class: "section-header", "Recent" }
111
111
+
div { class: "entries-feed",
112
112
+
match &*entries.read() {
113
113
+
Some(entry_list) => rsx! {
114
114
+
for (entry_view, entry, _time_us) in entry_list.iter() {
115
115
+
div {
116
116
+
key: "{entry_view.cid}",
117
117
+
FeedEntryCard {
118
118
+
entry_view: entry_view.clone(),
119
119
+
entry: entry.clone()
69
120
}
70
121
}
71
122
}
123
123
+
},
124
124
+
_ => rsx! {
125
125
+
div { class: "loading", "Loading entries..." }
72
126
}
73
73
-
},
74
74
-
_ => rsx! {
75
75
-
div { "Loading notebooks..." }
76
127
}
77
128
}
78
129
}
79
130
}
131
131
+
}
132
132
+
}
80
133
134
134
+
#[component]
135
135
+
fn PinnedItemCard(item: PinnedItem) -> Element {
136
136
+
match item {
137
137
+
PinnedItem::Notebook { ident, title } => rsx! {
138
138
+
PinnedNotebookCard { ident, title }
139
139
+
},
140
140
+
PinnedItem::Entry { ident, rkey } => rsx! {
141
141
+
PinnedEntryCard { ident, rkey }
142
142
+
},
143
143
+
}
144
144
+
}
145
145
+
146
146
+
#[component]
147
147
+
fn PinnedNotebookCard(ident: AtIdentifier<'static>, title: SmolStr) -> Element {
148
148
+
let ident_memo = use_memo(move || ident.clone());
149
149
+
let title_memo = use_memo(move || title.clone());
150
150
+
let (note_res, notebook) = data::use_notebook(ident_memo.into(), title_memo.into());
151
151
+
152
152
+
#[cfg(feature = "fullstack-server")]
153
153
+
let _note_res = note_res?;
154
154
+
155
155
+
match &*notebook.read() {
156
156
+
Some((view, entries)) => rsx! {
157
157
+
NotebookCard {
158
158
+
notebook: view.clone(),
159
159
+
entry_refs: entries.clone()
160
160
+
}
161
161
+
},
162
162
+
None => rsx! {
163
163
+
div { class: "pinned-item-loading", "Loading notebook..." }
164
164
+
},
165
165
+
}
166
166
+
}
167
167
+
168
168
+
#[component]
169
169
+
fn PinnedEntryCard(ident: AtIdentifier<'static>, rkey: SmolStr) -> Element {
170
170
+
let ident_memo = use_memo(move || ident.clone());
171
171
+
let rkey_memo = use_memo(move || rkey.clone());
172
172
+
let (entry_res, entry_data) =
173
173
+
data::use_standalone_entry_data(ident_memo.into(), rkey_memo.into());
174
174
+
175
175
+
#[cfg(feature = "fullstack-server")]
176
176
+
let _entry_res = entry_res?;
177
177
+
178
178
+
match &*entry_data.read() {
179
179
+
Some(data) => rsx! {
180
180
+
FeedEntryCard {
181
181
+
entry_view: data.entry_view.clone(),
182
182
+
entry: data.entry.clone()
183
183
+
}
184
184
+
},
185
185
+
None => rsx! {
186
186
+
div { class: "pinned-item-loading", "Loading entry..." }
187
187
+
},
81
188
}
82
189
}
+42
-5
crates/weaver-app/src/views/navbar.rs
···
6
6
use crate::fetch::Fetcher;
7
7
use dioxus::prelude::*;
8
8
use dioxus_primitives::toast::{ToastOptions, use_toast};
9
9
+
use jacquard::types::ident::AtIdentifier;
9
10
use jacquard::types::string::Did;
10
11
11
12
const NAVBAR_CSS: Asset = asset!("/assets/styling/navbar.css");
···
63
64
div {
64
65
id: "navbar",
65
66
nav { class: "breadcrumbs",
66
66
-
Link {
67
67
-
to: Route::Home {},
68
68
-
class: "breadcrumb",
69
69
-
"Home"
67
67
+
// On home page: show profile link if authenticated, otherwise "Home"
68
68
+
match (&route, &auth_state.read().did) {
69
69
+
(Route::Home {}, Some(did)) => rsx! {
70
70
+
ProfileBreadcrumb { did: did.clone() }
71
71
+
},
72
72
+
_ => rsx! {
73
73
+
a {
74
74
+
href: "/",
75
75
+
class: "breadcrumb",
76
76
+
"Home"
77
77
+
}
78
78
+
}
70
79
}
71
80
72
81
// Show repository breadcrumb if we're on a repository page
73
73
-
match route {
82
82
+
match &route {
74
83
Route::RepositoryIndex { ident } => {
75
84
let route_handle = route_handle.read().clone();
76
85
let handle = route_handle.unwrap_or(ident.clone());
···
239
248
_ => rsx! {},
240
249
}
241
250
}
251
251
+
252
252
+
// Tool links (show on home page)
253
253
+
if matches!(route, Route::Home {}) {
254
254
+
nav { class: "nav-tools",
255
255
+
Link {
256
256
+
to: Route::RecordPage { uri: vec![] },
257
257
+
class: "nav-tool-link",
258
258
+
"Record Viewer"
259
259
+
}
260
260
+
Link {
261
261
+
to: Route::Editor { entry: None },
262
262
+
class: "nav-tool-link",
263
263
+
"Editor"
264
264
+
}
265
265
+
}
266
266
+
}
267
267
+
242
268
if auth_state.read().is_authenticated() {
243
269
if let Some(did) = &auth_state.read().did {
244
270
AuthButton { did: did.clone() }
···
260
286
}
261
287
262
288
Outlet::<Route> {}
289
289
+
}
290
290
+
}
291
291
+
292
292
+
#[component]
293
293
+
fn ProfileBreadcrumb(did: Did<'static>) -> Element {
294
294
+
rsx! {
295
295
+
Link {
296
296
+
to: Route::RepositoryIndex { ident: AtIdentifier::Did(did) },
297
297
+
class: "breadcrumb",
298
298
+
"Profile"
299
299
+
}
263
300
}
264
301
}
265
302