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