learn and share notes on atproto (wip) 🦉 malfestio.stormlightlabs.org/
readability solid axum atproto srs

feat: refactor optional auth

* add lexicon publishing documentation.

Changed files
+176 -109
crates
server
lexicons
web
src
+27 -17
crates/server/src/firehose.rs
··· 1 1 //! Firehose consumption via AT Protocol Jetstream. 2 2 //! 3 3 //! Provides WebSocket subscription to Jetstream for indexing public records. 4 - //! Filters for `app.malfestio.*` collections and indexes them locally. 4 + //! Filters for `org.stormlightlabs.malfestio.*` collections and indexes them locally. 5 5 6 6 use crate::db::DbPool; 7 7 use async_trait::async_trait; ··· 15 15 pub const DEFAULT_JETSTREAM_URL: &str = "wss://jetstream2.us-west.bsky.network/subscribe"; 16 16 17 17 /// Collections we're interested in indexing. 18 - pub const MALFESTIO_COLLECTIONS: &[&str] = &["app.malfestio.deck", "app.malfestio.card", "app.malfestio.note"]; 18 + pub const MALFESTIO_COLLECTIONS: &[&str] = &[ 19 + "org.stormlightlabs.malfestio.deck", 20 + "org.stormlightlabs.malfestio.card", 21 + "org.stormlightlabs.malfestio.note", 22 + ]; 19 23 20 24 /// Deck record structure matching the Lexicon schema. 21 25 #[derive(Debug, Deserialize)] ··· 85 89 async fn index_deck( 86 90 &self, did: &str, rkey: &str, rev: &str, record: &Value, 87 91 ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> { 88 - let at_uri = format!("at://{}/app.malfestio.deck/{}", did, rkey); 92 + let at_uri = format!("at://{}/org.stormlightlabs.malfestio.deck/{}", did, rkey); 89 93 let deck: DeckRecord = serde_json::from_value(record.clone())?; 90 94 let created_at = parse_record_datetime(&deck.created_at); 91 95 ··· 122 126 async fn index_card( 123 127 &self, did: &str, rkey: &str, rev: &str, record: &Value, 124 128 ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> { 125 - let at_uri = format!("at://{}/app.malfestio.card/{}", did, rkey); 129 + let at_uri = format!("at://{}/org.stormlightlabs.malfestio.card/{}", did, rkey); 126 130 let card: CardRecord = serde_json::from_value(record.clone())?; 127 131 let created_at = parse_record_datetime(&card.created_at); 128 132 let card_type = card.card_type.unwrap_or_else(|| "basic".to_string()); ··· 160 164 async fn index_note( 161 165 &self, did: &str, rkey: &str, rev: &str, record: &Value, 162 166 ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> { 163 - let at_uri = format!("at://{}/app.malfestio.note/{}", did, rkey); 167 + let at_uri = format!("at://{}/org.stormlightlabs.malfestio.note/{}", did, rkey); 164 168 let note: NoteRecord = serde_json::from_value(record.clone())?; 165 169 let created_at = parse_record_datetime(&note.created_at); 166 170 let visibility = note.visibility.unwrap_or_else(|| "public".to_string()); ··· 201 205 let client = self.pool.get().await?; 202 206 203 207 let table = match collection { 204 - "app.malfestio.deck" => "indexed_decks", 205 - "app.malfestio.card" => "indexed_cards", 206 - "app.malfestio.note" => "indexed_notes", 208 + "org.stormlightlabs.malfestio.deck" => "indexed_decks", 209 + "org.stormlightlabs.malfestio.card" => "indexed_cards", 210 + "org.stormlightlabs.malfestio.note" => "indexed_notes", 207 211 _ => return Ok(()), 208 212 }; 209 213 ··· 289 293 match operation.as_str() { 290 294 "create" | "update" => { 291 295 let result = match collection.as_str() { 292 - "app.malfestio.deck" => self.index_deck(&did, rkey, rev, &commit.record).await, 293 - "app.malfestio.card" => self.index_card(&did, rkey, rev, &commit.record).await, 294 - "app.malfestio.note" => self.index_note(&did, rkey, rev, &commit.record).await, 296 + "org.stormlightlabs.malfestio.deck" => { 297 + self.index_deck(&did, rkey, rev, &commit.record).await 298 + } 299 + "org.stormlightlabs.malfestio.card" => { 300 + self.index_card(&did, rkey, rev, &commit.record).await 301 + } 302 + "org.stormlightlabs.malfestio.note" => { 303 + self.index_note(&did, rkey, rev, &commit.record).await 304 + } 295 305 _ => Ok(()), 296 306 }; 297 307 ··· 414 424 415 425 #[test] 416 426 fn test_malfestio_collections() { 417 - assert!(MALFESTIO_COLLECTIONS.contains(&"app.malfestio.deck")); 418 - assert!(MALFESTIO_COLLECTIONS.contains(&"app.malfestio.card")); 419 - assert!(MALFESTIO_COLLECTIONS.contains(&"app.malfestio.note")); 427 + assert!(MALFESTIO_COLLECTIONS.contains(&"org.stormlightlabs.malfestio.deck")); 428 + assert!(MALFESTIO_COLLECTIONS.contains(&"org.stormlightlabs.malfestio.card")); 429 + assert!(MALFESTIO_COLLECTIONS.contains(&"org.stormlightlabs.malfestio.note")); 420 430 } 421 431 422 432 #[test] ··· 425 435 "title": "Test Deck", 426 436 "description": "A test deck", 427 437 "tags": ["rust", "learning"], 428 - "cardRefs": ["at://did:plc:abc/app.malfestio.card/123"], 438 + "cardRefs": ["at://did:plc:abc/org.stormlightlabs.malfestio.card/123"], 429 439 "sourceRefs": [], 430 440 "license": "CC-BY-4.0", 431 441 "createdAt": "2024-01-01T00:00:00Z" ··· 442 452 #[test] 443 453 fn test_parse_card_record() { 444 454 let json = serde_json::json!({ 445 - "deckRef": "at://did:plc:abc/app.malfestio.deck/123", 455 + "deckRef": "at://did:plc:abc/org.stormlightlabs.malfestio.deck/123", 446 456 "front": "What is Rust?", 447 457 "back": "A systems programming language", 448 458 "cardType": "basic", ··· 451 461 }); 452 462 453 463 let card: CardRecord = serde_json::from_value(json).unwrap(); 454 - assert_eq!(card.deck_ref, "at://did:plc:abc/app.malfestio.deck/123"); 464 + assert_eq!(card.deck_ref, "at://did:plc:abc/org.stormlightlabs.malfestio.deck/123"); 455 465 assert_eq!(card.front, "What is Rust?"); 456 466 assert_eq!(card.back, "A systems programming language"); 457 467 assert_eq!(card.card_type, Some("basic".to_string()));
+4 -35
crates/server/src/middleware/auth.rs
··· 250 250 /// but continues without error if no token or invalid token. 251 251 /// 252 252 /// Used by endpoints that need to check permissions but don't require authentication. 253 - pub async fn optional_auth_middleware(mut req: Request, next: Next) -> Response { 254 - let auth_header = req.headers().get(http::header::AUTHORIZATION); 255 - 256 - let token = match auth_header.and_then(|h| h.to_str().ok()).and_then(parse_auth_header) { 257 - Some(AuthScheme::Bearer(t)) | Some(AuthScheme::DPoP(t)) => t, 258 - None => { 259 - return next.run(req).await; 260 - } 261 - }; 262 - 263 - let client = reqwest::Client::new(); 264 - let pds_url = std::env::var("PDS_URL").unwrap_or_else(|_| "https://bsky.social".to_string()); 265 - 266 - match client 267 - .get(format!("{}/xrpc/com.atproto.server.getSession", pds_url)) 268 - .header("Authorization", format!("Bearer {}", token)) 269 - .send() 270 - .await 271 - { 272 - Ok(response) if response.status().is_success() => { 273 - let body: serde_json::Value = response.json().await.unwrap_or_default(); 274 - let did = body["did"].as_str().unwrap_or("").to_string(); 275 - let handle = body["handle"].as_str().unwrap_or("").to_string(); 276 - 277 - req.extensions_mut().insert(UserContext { 278 - did, 279 - handle, 280 - access_token: token.to_string(), 281 - pds_url: pds_url.clone(), 282 - has_dpop: false, 283 - }); 284 - } 285 - _ => {} 253 + pub async fn optional_auth_middleware(State(state): State<SharedState>, req: Request, next: Next) -> Response { 254 + if req.headers().get(http::header::AUTHORIZATION).is_none() { 255 + return next.run(req).await; 286 256 } 287 - 288 - next.run(req).await 257 + auth_middleware(State(state), req, next).await 289 258 } 290 259 291 260 /// Cleanup expired nonces from the cache.
+4 -4
crates/server/src/pds/client.rs
··· 128 128 /// # Arguments 129 129 /// 130 130 /// * `did` - The user's DID (repository owner) 131 - /// * `collection` - The collection NSID (e.g., "app.malfestio.deck") 131 + /// * `collection` - The collection NSID (e.g., "org.stormlightlabs.malfestio.deck") 132 132 /// * `rkey` - The record key (TID) 133 133 /// * `record` - The record data as JSON 134 134 pub async fn put_record( ··· 284 284 fn test_put_record_request_serialization() { 285 285 let request = PutRecordRequest { 286 286 repo: "did:plc:abc123".to_string(), 287 - collection: "app.malfestio.deck".to_string(), 287 + collection: "org.stormlightlabs.malfestio.deck".to_string(), 288 288 rkey: "3k5abc123".to_string(), 289 289 record: serde_json::json!({ 290 290 "title": "Test Deck", ··· 297 297 298 298 let json = serde_json::to_string(&request).unwrap(); 299 299 assert!(json.contains("\"repo\":\"did:plc:abc123\"")); 300 - assert!(json.contains("\"collection\":\"app.malfestio.deck\"")); 300 + assert!(json.contains("\"collection\":\"org.stormlightlabs.malfestio.deck\"")); 301 301 assert!(json.contains("\"rkey\":\"3k5abc123\"")); 302 302 assert!(json.contains("\"validate\":true")); 303 303 } ··· 306 306 fn test_delete_record_request_serialization() { 307 307 let request = DeleteRecordRequest { 308 308 repo: "did:plc:abc123".to_string(), 309 - collection: "app.malfestio.deck".to_string(), 309 + collection: "org.stormlightlabs.malfestio.deck".to_string(), 310 310 rkey: "3k5abc123".to_string(), 311 311 swap_record: None, 312 312 swap_commit: None,
+24 -18
crates/server/src/pds/records.rs
··· 82 82 /// Create a DeckRecord from an internal Deck model. 83 83 pub fn from_deck(deck: &Deck, card_at_uris: Vec<String>) -> Self { 84 84 Self { 85 - record_type: "app.malfestio.deck".to_string(), 85 + record_type: "org.stormlightlabs.malfestio.deck".to_string(), 86 86 title: deck.title.clone(), 87 87 description: if deck.description.is_empty() { None } else { Some(deck.description.clone()) }, 88 88 tags: deck.tags.clone(), ··· 98 98 /// Create a CardRecord from an internal Card model. 99 99 pub fn from_card(card: &Card, deck_at_uri: &str) -> Self { 100 100 Self { 101 - record_type: "app.malfestio.card".to_string(), 101 + record_type: "org.stormlightlabs.malfestio.card".to_string(), 102 102 deck_ref: deck_at_uri.to_string(), 103 103 front: card.front.clone(), 104 104 back: card.back.clone(), ··· 117 117 /// Create a NoteRecord from an internal Note model. 118 118 pub fn from_note(note: &Note) -> Self { 119 119 Self { 120 - record_type: "app.malfestio.note".to_string(), 120 + record_type: "org.stormlightlabs.malfestio.note".to_string(), 121 121 title: note.title.clone(), 122 122 body: note.body.clone(), 123 123 tags: note.tags.clone(), ··· 143 143 let record = DeckRecord::from_deck(deck, card_at_uris); 144 144 PreparedRecord { 145 145 rkey: generate_tid(), 146 - collection: "app.malfestio.deck".to_string(), 146 + collection: "org.stormlightlabs.malfestio.deck".to_string(), 147 147 record: serde_json::to_value(record).expect("Failed to serialize deck record"), 148 148 } 149 149 } ··· 153 153 let record = CardRecord::from_card(card, deck_at_uri); 154 154 PreparedRecord { 155 155 rkey: generate_tid(), 156 - collection: "app.malfestio.card".to_string(), 156 + collection: "org.stormlightlabs.malfestio.card".to_string(), 157 157 record: serde_json::to_value(record).expect("Failed to serialize card record"), 158 158 } 159 159 } ··· 163 163 let record = NoteRecord::from_note(note); 164 164 PreparedRecord { 165 165 rkey: generate_tid(), 166 - collection: "app.malfestio.note".to_string(), 166 + collection: "org.stormlightlabs.malfestio.note".to_string(), 167 167 record: serde_json::to_value(record).expect("Failed to serialize note record"), 168 168 } 169 169 } ··· 221 221 let deck = sample_deck(); 222 222 let record = DeckRecord::from_deck(&deck, vec![]); 223 223 224 - assert_eq!(record.record_type, "app.malfestio.deck"); 224 + assert_eq!(record.record_type, "org.stormlightlabs.malfestio.deck"); 225 225 assert_eq!(record.title, "Test Deck"); 226 226 assert_eq!(record.description, Some("A test deck".to_string())); 227 227 assert_eq!(record.tags.len(), 2); ··· 230 230 #[test] 231 231 fn test_deck_record_serialization() { 232 232 let deck = sample_deck(); 233 - let record = DeckRecord::from_deck(&deck, vec!["at://did:plc:abc/app.malfestio.card/tid1".to_string()]); 233 + let record = DeckRecord::from_deck( 234 + &deck, 235 + vec!["at://did:plc:abc/org.stormlightlabs.malfestio.card/tid1".to_string()], 236 + ); 234 237 235 238 let json = serde_json::to_string(&record).unwrap(); 236 - assert!(json.contains("\"$type\":\"app.malfestio.deck\"")); 239 + assert!(json.contains("\"$type\":\"org.stormlightlabs.malfestio.deck\"")); 237 240 assert!(json.contains("\"title\":\"Test Deck\"")); 238 241 assert!(json.contains("cardRefs")); 239 242 } ··· 241 244 #[test] 242 245 fn test_card_record_from_card() { 243 246 let card = sample_card(); 244 - let deck_uri = "at://did:plc:abc123/app.malfestio.deck/tid123"; 247 + let deck_uri = "at://did:plc:abc123/org.stormlightlabs.malfestio.deck/tid123"; 245 248 let record = CardRecord::from_card(&card, deck_uri); 246 249 247 - assert_eq!(record.record_type, "app.malfestio.card"); 250 + assert_eq!(record.record_type, "org.stormlightlabs.malfestio.card"); 248 251 assert_eq!(record.deck_ref, deck_uri); 249 252 assert_eq!(record.front, "What is the capital of France?"); 250 253 assert_eq!(record.back, "Paris"); ··· 255 258 let note = sample_note(); 256 259 let record = NoteRecord::from_note(&note); 257 260 258 - assert_eq!(record.record_type, "app.malfestio.note"); 261 + assert_eq!(record.record_type, "org.stormlightlabs.malfestio.note"); 259 262 assert_eq!(record.title, "Test Note"); 260 263 assert_eq!(record.visibility, "public"); 261 264 } ··· 265 268 let deck = sample_deck(); 266 269 let prepared = prepare_deck_record(&deck, vec![]); 267 270 268 - assert_eq!(prepared.collection, "app.malfestio.deck"); 271 + assert_eq!(prepared.collection, "org.stormlightlabs.malfestio.deck"); 269 272 assert_eq!(prepared.rkey.len(), 13); // TID length 270 273 assert!(prepared.record.is_object()); 271 274 } ··· 273 276 #[test] 274 277 fn test_prepare_card_record() { 275 278 let card = sample_card(); 276 - let prepared = prepare_card_record(&card, "at://did:plc:abc/app.malfestio.deck/tid"); 279 + let prepared = prepare_card_record(&card, "at://did:plc:abc/org.stormlightlabs.malfestio.deck/tid"); 277 280 278 - assert_eq!(prepared.collection, "app.malfestio.card"); 281 + assert_eq!(prepared.collection, "org.stormlightlabs.malfestio.card"); 279 282 assert_eq!(prepared.rkey.len(), 13); 280 283 } 281 284 ··· 284 287 let note = sample_note(); 285 288 let prepared = prepare_note_record(&note); 286 289 287 - assert_eq!(prepared.collection, "app.malfestio.note"); 290 + assert_eq!(prepared.collection, "org.stormlightlabs.malfestio.note"); 288 291 assert_eq!(prepared.rkey.len(), 13); 289 292 } 290 293 291 294 #[test] 292 295 fn test_make_at_uri() { 293 - let uri = make_at_uri("did:plc:abc123", "app.malfestio.deck", "3k5abc123"); 294 - assert_eq!(uri.to_string(), "at://did:plc:abc123/app.malfestio.deck/3k5abc123"); 296 + let uri = make_at_uri("did:plc:abc123", "org.stormlightlabs.malfestio.deck", "3k5abc123"); 297 + assert_eq!( 298 + uri.to_string(), 299 + "at://did:plc:abc123/org.stormlightlabs.malfestio.deck/3k5abc123" 300 + ); 295 301 } 296 302 297 303 #[test]
+69
lexicons/README.md
··· 32 32 - **Private layer**: 33 33 - review schedule, lapses, grades, per-card performance, streaks 34 34 35 + ## Publishing Lexicons to AT Protocol Network 36 + 37 + ### Prerequisites 38 + 39 + **Goat CLI**: Install the official AT Protocol CLI tool 40 + 41 + ```bash 42 + # macOS 43 + brew install goat 44 + ``` 45 + 46 + ### Publishing Workflow 47 + 48 + 1. **Validate schemas locally**: 49 + 50 + ```bash 51 + goat lexicon lint lexicons/ 52 + ``` 53 + 54 + 2. **Check DNS configuration**: 55 + 56 + ```bash 57 + goat lexicon check-dns org.stormlightlabs.malfestio.card 58 + ``` 59 + 60 + 3. **Publish to network**: 61 + 62 + ```bash 63 + goat lexicon publish lexicons/org/stormlightlabs/malfestio/ 64 + ``` 65 + 66 + ### PDS Validation Modes 67 + 68 + AT Protocol PDSs support three lexicon validation modes: 69 + 70 + 1. **Explicit validation required**: Record must validate against schema; fails if PDS doesn't know the lexicon 71 + - This is the current mode causing `Lexicon not found` errors 72 + - Requires publishing lexicons or using optimistic validation 73 + 74 + 2. **Optimistic validation** (default): Validates if PDS knows the schema, allows creation if unknown 75 + - Most flexible for custom lexicons during development 76 + - Set via `validate: undefined` in create/update record calls 77 + 78 + 3. **Explicit no validation**: Skips validation even if PDS knows the schema 79 + - Set via `validate: false` in create/update record calls 80 + 81 + ### Version Updates 82 + 83 + When updating lexicon schemas: 84 + 85 + 1. **Minor Updates** (additive only): 86 + - Add new optional fields 87 + - Update descriptions 88 + - Add new `knownValues` (don't remove old ones) 89 + - Increment patch version in documentation 90 + 91 + 2. **Breaking Changes** (avoid if possible): 92 + - Create new lexicon with new NSID (e.g., `org.stormlightlabs.malfestio.cardV2`) 93 + - Maintain both versions during migration period 94 + - Update code to support both old and new schemas 95 + - Document migration path 96 + 97 + 3. **Republishing**: 98 + 99 + ```bash 100 + goat lexicon lint lexicons/ 101 + goat lexicon publish lexicons/org/stormlightlabs/malfestio/ 102 + ``` 103 + 35 104 ## Evolution Rules 36 105 37 106 1. **Additive Changes Only**: You can add new optional fields to existing records.
+1 -1
web/src/components/NoteCard.tsx
··· 32 32 {props.note.title || "Untitled"} 33 33 </h3> 34 34 <p class="text-xs text-slate-500 dark:text-slate-400"> 35 - {new Date(props.note.updated_at).toLocaleDateString()} 35 + {props.note.updated_at ? new Date(props.note.updated_at).toLocaleDateString() : ""} 36 36 </p> 37 37 </div> 38 38
+34 -29
web/src/components/NoteEditor.tsx
··· 5 5 import { toast } from "$lib/toast"; 6 6 import { Button } from "$ui/Button"; 7 7 import rehypeShiki from "@shikijs/rehype"; 8 + import { useNavigate } from "@solidjs/router"; 8 9 import { Textcomplete } from "@textcomplete/core"; 9 10 import { TextareaEditor } from "@textcomplete/textarea"; 10 11 import rehypeExternalLinks from "rehype-external-links"; ··· 20 21 21 22 type EditorTab = "write" | "preview"; 22 23 24 + function getFontName(font: EditorFont | (() => EditorFont)) { 25 + switch (typeof font === "function" ? font() : font) { 26 + case "neon": 27 + return "Monaspace Neon"; 28 + case "argon": 29 + return "Monaspace Argon"; 30 + case "krypton": 31 + return "Monaspace Krypton"; 32 + case "radon": 33 + return "Monaspace Radon"; 34 + case "xenon": 35 + return "Monaspace Xenon"; 36 + case "google": 37 + return "Google Sans Code"; 38 + default: 39 + return "JetBrains Mono"; 40 + } 41 + } 42 + 23 43 const processor = unified().use(remarkParse).use(remarkRehype).use(rehypeShiki, { theme: "vitesse-dark" }).use( 24 44 rehypeExternalLinks, 25 45 { target: "_blank", rel: ["nofollow"] }, 26 46 ).use(rehypeStringify); 27 47 28 48 export function NoteEditor(props: NoteEditorProps) { 49 + const navigate = useNavigate(); 29 50 const [title, setTitle] = createSignal(props.initialTitle || ""); 30 51 const [content, setContent] = createSignal(props.initialContent || ""); 31 52 const [preview, setPreview] = createSignal(""); ··· 76 97 textcomplete?.destroy(); 77 98 }); 78 99 79 - const fontValue = createMemo(() => { 80 - switch (editorFont()) { 81 - case "neon": 82 - return "Monaspace Neon"; 83 - case "argon": 84 - return "Monaspace Argon"; 85 - case "krypton": 86 - return "Monaspace Krypton"; 87 - case "radon": 88 - return "Monaspace Radon"; 89 - case "xenon": 90 - return "Monaspace Xenon"; 91 - case "google": 92 - return "Google Sans Code"; 93 - default: 94 - return "JetBrains Mono"; 95 - } 96 - }); 100 + const fontValue = createMemo(() => getFontName(editorFont)); 97 101 98 102 const insertAtCursor = (before: string, after: string = "") => { 99 103 if (!textareaRef) return; ··· 116 120 const handleCodeBlock = () => insertAtCursor("```\n", "\n```"); 117 121 const handleWikilink = () => insertAtCursor("[[", "]]"); 118 122 const handleList = () => insertAtCursor("- "); 119 - 120 - const handleHeading = (level: 1 | 2 | 3 | 4 | 5 | 6) => { 121 - const prefix = "#".repeat(level) + " "; 122 - insertAtCursor(prefix); 123 - }; 123 + const handleHeading = (level: 1 | 2 | 3 | 4 | 5 | 6) => insertAtCursor("#".repeat(level) + " "); 124 124 125 125 const handleKeyDown = (e: KeyboardEvent) => { 126 126 if (e.metaKey || e.ctrlKey) { ··· 162 162 163 163 if (res.ok) { 164 164 toast.success("Note saved!"); 165 - if (!props.noteId) { 166 - setTitle(""); 167 - setContent(""); 168 - setTags(""); 169 - setVisibilityType("Private"); 170 - setSharedWith(""); 165 + if (props.noteId) { 166 + navigate(`/notes/${props.noteId}`); 167 + } else { 168 + try { 169 + const newNote = await res.json(); 170 + navigate(`/notes/${newNote.id}`); 171 + } catch { 172 + navigate("/notes"); 173 + } 171 174 } 172 175 } else { 176 + const errorText = await res.text(); 177 + console.error("Failed to save note:", res.status, errorText); 173 178 toast.error("Failed to save note"); 174 179 } 175 180 } catch (e) {
+3 -2
web/src/lib/model.ts
··· 35 35 tags: string[]; 36 36 visibility: Visibility; 37 37 published_at?: string; 38 - created_at: string; 39 - updated_at: string; 38 + created_at?: string; 39 + updated_at?: string; 40 + links?: string[]; 40 41 }; 41 42 42 43 export type CreateDeckPayload = {
+10 -3
web/src/pages/Notes.tsx
··· 3 3 import { EmptyState } from "$components/ui/EmptyState"; 4 4 import { api } from "$lib/api"; 5 5 import type { Note } from "$lib/model"; 6 - import { A } from "@solidjs/router"; 6 + import { A, useLocation } from "@solidjs/router"; 7 7 import type { Component } from "solid-js"; 8 - import { createMemo, createResource, createSignal, For, Show } from "solid-js"; 8 + import { createEffect, createMemo, createResource, createSignal, For, Show } from "solid-js"; 9 9 10 10 const fetchNotes = async (): Promise<Note[]> => { 11 11 const res = await api.getNotes(); ··· 16 16 type ViewMode = "grid" | "list"; 17 17 18 18 const Notes: Component = () => { 19 - const [notes] = createResource(fetchNotes); 19 + const location = useLocation(); 20 + const [notes, { refetch }] = createResource(fetchNotes); 20 21 const [viewMode, setViewMode] = createSignal<ViewMode>("grid"); 21 22 const [searchQuery, setSearchQuery] = createSignal(""); 23 + 24 + createEffect(() => { 25 + if (location.pathname === "/notes") { 26 + refetch(); 27 + } 28 + }); 22 29 23 30 const filteredNotes = createMemo(() => { 24 31 const allNotes = notes() || [];