fonts and css tweaks

Orual 0a6fbed9 cd5b03e1

+1319 -227
crates/weaver-app/assets/fonts/adobe-caslon/AdobeCaslonPro-Bold.ttf

This is a binary file and will not be displayed.

crates/weaver-app/assets/fonts/adobe-caslon/AdobeCaslonPro-BoldItalic.ttf

This is a binary file and will not be displayed.

crates/weaver-app/assets/fonts/adobe-caslon/AdobeCaslonPro-Italic.ttf

This is a binary file and will not be displayed.

crates/weaver-app/assets/fonts/adobe-caslon/AdobeCaslonPro-Regular.ttf

This is a binary file and will not be displayed.

crates/weaver-app/assets/fonts/adobe-caslon/AdobeCaslonPro-Semibold.ttf

This is a binary file and will not be displayed.

crates/weaver-app/assets/fonts/cm-sans/CMSans-Bold.woff

This is a binary file and will not be displayed.

crates/weaver-app/assets/fonts/cm-sans/CMSans-BoldItalic.woff

This is a binary file and will not be displayed.

crates/weaver-app/assets/fonts/cm-sans/CMSans-Italic.woff

This is a binary file and will not be displayed.

crates/weaver-app/assets/fonts/cm-sans/CMSans-Regular.woff

This is a binary file and will not be displayed.

crates/weaver-app/assets/fonts/cm-serif/CMSerif-Bold.woff

This is a binary file and will not be displayed.

crates/weaver-app/assets/fonts/cm-serif/CMSerif-BoldItalic.woff

This is a binary file and will not be displayed.

crates/weaver-app/assets/fonts/cm-serif/CMSerif-Italic.woff

This is a binary file and will not be displayed.

crates/weaver-app/assets/fonts/cm-serif/CMSerif-Regular.woff

This is a binary file and will not be displayed.

crates/weaver-app/assets/fonts/junction/Junction-Bold.woff

This is a binary file and will not be displayed.

crates/weaver-app/assets/fonts/junction/Junction-Light.woff

This is a binary file and will not be displayed.

crates/weaver-app/assets/fonts/junction/Junction-Regular.woff

This is a binary file and will not be displayed.

crates/weaver-app/assets/fonts/latin-modern/LatinModernRoman-Bold.otf

This is a binary file and will not be displayed.

crates/weaver-app/assets/fonts/latin-modern/LatinModernRoman-BoldItalic.otf

This is a binary file and will not be displayed.

crates/weaver-app/assets/fonts/latin-modern/LatinModernRoman-Italic.otf

This is a binary file and will not be displayed.

crates/weaver-app/assets/fonts/latin-modern/LatinModernRoman-Regular.otf

This is a binary file and will not be displayed.

crates/weaver-app/assets/fonts/proza-libre/ProzaLibre-Bold.woff2

This is a binary file and will not be displayed.

crates/weaver-app/assets/fonts/proza-libre/ProzaLibre-BoldItalic.woff2

This is a binary file and will not be displayed.

crates/weaver-app/assets/fonts/proza-libre/ProzaLibre-Italic.woff2

This is a binary file and will not be displayed.

crates/weaver-app/assets/fonts/proza-libre/ProzaLibre-Medium.woff2

This is a binary file and will not be displayed.

crates/weaver-app/assets/fonts/proza-libre/ProzaLibre-Regular.woff2

This is a binary file and will not be displayed.

crates/weaver-app/assets/fonts/proza-libre/ProzaLibre-SemiBold.woff2

This is a binary file and will not be displayed.

+13 -13
crates/weaver-app/assets/styling/editor.css
··· 12 grid-template-columns: 1fr auto; 13 grid-template-rows: auto auto 1fr; 14 height: 100%; 15 - max-width: 1200px; 16 margin: 0 auto; 17 padding: 0 20px; 18 font-family: var(--font-body); ··· 34 border-bottom: 2px solid var(--color-border); 35 background: transparent; 36 color: var(--color-text); 37 - font-family: var(--font-body); 38 font-size: 24px; 39 font-weight: 500; 40 outline: none; ··· 129 border-radius: 12px; 130 background: transparent; 131 color: var(--color-text); 132 - font-family: var(--font-body); 133 font-size: 13px; 134 min-width: 80px; 135 } ··· 314 color: var(--color-text); 315 cursor: pointer; 316 font-size: 0.9rem; 317 - font-family: var(--font-body); 318 } 319 320 .report-bug-button:hover { ··· 385 border-radius: 4px; 386 background: var(--color-base); 387 color: var(--color-text); 388 - font-family: var(--font-body); 389 resize: vertical; 390 } 391 ··· 412 border-radius: 4px; 413 color: var(--color-text); 414 cursor: pointer; 415 - font-family: var(--font-body); 416 } 417 418 .report-cancel:hover { ··· 427 color: var(--color-base); 428 cursor: pointer; 429 font-weight: 500; 430 - font-family: var(--font-body); 431 } 432 433 .report-submit:hover { ··· 452 color: var(--color-base); 453 cursor: pointer; 454 font-weight: 500; 455 - font-family: var(--font-body); 456 } 457 458 .publish-button:hover:not(:disabled) { ··· 513 border-radius: 4px; 514 background: var(--color-base); 515 color: var(--color-text); 516 - font-family: var(--font-body); 517 font-size: 14px; 518 box-sizing: border-box; 519 } ··· 572 border-radius: 4px; 573 color: var(--color-base); 574 cursor: pointer; 575 - font-family: var(--font-body); 576 } 577 578 .publish-actions { ··· 588 border-radius: 4px; 589 color: var(--color-text); 590 cursor: pointer; 591 - font-family: var(--font-body); 592 } 593 594 .publish-cancel:hover:not(:disabled) { ··· 608 color: var(--color-base); 609 cursor: pointer; 610 font-weight: 500; 611 - font-family: var(--font-body); 612 } 613 614 .publish-submit:hover:not(:disabled) { ··· 652 border-radius: 4px; 653 background: var(--color-base); 654 color: var(--color-text); 655 - font-family: var(--font-body); 656 resize: vertical; 657 } 658
··· 12 grid-template-columns: 1fr auto; 13 grid-template-rows: auto auto 1fr; 14 height: 100%; 15 + max-width: calc(95ch + 100px); 16 margin: 0 auto; 17 padding: 0 20px; 18 font-family: var(--font-body); ··· 34 border-bottom: 2px solid var(--color-border); 35 background: transparent; 36 color: var(--color-text); 37 + font-family: var(--font-heading); 38 font-size: 24px; 39 font-weight: 500; 40 outline: none; ··· 129 border-radius: 12px; 130 background: transparent; 131 color: var(--color-text); 132 + font-family: var(--font-ui); 133 font-size: 13px; 134 min-width: 80px; 135 } ··· 314 color: var(--color-text); 315 cursor: pointer; 316 font-size: 0.9rem; 317 + font-family: var(--font-ui); 318 } 319 320 .report-bug-button:hover { ··· 385 border-radius: 4px; 386 background: var(--color-base); 387 color: var(--color-text); 388 + font-family: var(--font-ui); 389 resize: vertical; 390 } 391 ··· 412 border-radius: 4px; 413 color: var(--color-text); 414 cursor: pointer; 415 + font-family: var(--font-ui); 416 } 417 418 .report-cancel:hover { ··· 427 color: var(--color-base); 428 cursor: pointer; 429 font-weight: 500; 430 + font-family: var(--font-ui); 431 } 432 433 .report-submit:hover { ··· 452 color: var(--color-base); 453 cursor: pointer; 454 font-weight: 500; 455 + font-family: var(--font-ui); 456 } 457 458 .publish-button:hover:not(:disabled) { ··· 513 border-radius: 4px; 514 background: var(--color-base); 515 color: var(--color-text); 516 + font-family: var(--font-ui); 517 font-size: 14px; 518 box-sizing: border-box; 519 } ··· 572 border-radius: 4px; 573 color: var(--color-base); 574 cursor: pointer; 575 + font-family: var(--font-ui); 576 } 577 578 .publish-actions { ··· 588 border-radius: 4px; 589 color: var(--color-text); 590 cursor: pointer; 591 + font-family: var(--font-ui); 592 } 593 594 .publish-cancel:hover:not(:disabled) { ··· 608 color: var(--color-base); 609 cursor: pointer; 610 font-weight: 500; 611 + font-family: var(--font-ui); 612 } 613 614 .publish-submit:hover:not(:disabled) { ··· 652 border-radius: 4px; 653 background: var(--color-base); 654 color: var(--color-text); 655 + font-family: var(--font-ui); 656 resize: vertical; 657 } 658
+7 -4
crates/weaver-app/assets/styling/entry-card.css
··· 3 /* Notebook layout - sidebar in left gutter on desktop, header on mobile */ 4 .notebook-layout { 5 display: grid; 6 - grid-template-columns: minmax(280px, 1fr) minmax(0, 90ch) minmax(280px, 1fr); 7 gap: 2rem; 8 - max-width: calc(90ch + 560px + 4rem); /* content + gutters + gaps */ 9 margin: 0 auto; 10 padding: 2.5rem 1.25rem 2.5rem 0; 11 } ··· 27 /* Mobile layout - sidebar becomes header */ 28 @media (max-width: 1400px) { 29 .notebook-layout { 30 - grid-template-columns: minmax(1rem, 1fr) minmax(0, 90ch) minmax(1rem, 1fr) !important; 31 gap: 0 !important; 32 max-width: 100vw !important; 33 box-sizing: border-box !important; ··· 211 .entry-card-preview { 212 margin-top: 0.5rem; 213 color: var(--color-subtle); 214 font-size: 0.875rem; 215 line-height: 1.5; 216 display: -webkit-box; ··· 227 .entry-card-preview h6 { 228 font-size: 0.875rem; 229 font-weight: 600; 230 - margin: 0.5rem 0; 231 } 232 233 .entry-card-preview p {
··· 3 /* Notebook layout - sidebar in left gutter on desktop, header on mobile */ 4 .notebook-layout { 5 display: grid; 6 + grid-template-columns: minmax(280px, 1fr) minmax(0, 90rem) minmax(280px, 1fr); 7 gap: 2rem; 8 + max-width: calc(90rem + 560px + 4rem); /* content + gutters + gaps */ 9 margin: 0 auto; 10 padding: 2.5rem 1.25rem 2.5rem 0; 11 } ··· 27 /* Mobile layout - sidebar becomes header */ 28 @media (max-width: 1400px) { 29 .notebook-layout { 30 + grid-template-columns: minmax(1rem, 1fr) minmax(0, 90rem) minmax(1rem, 1fr) !important; 31 gap: 0 !important; 32 max-width: 100vw !important; 33 box-sizing: border-box !important; ··· 211 .entry-card-preview { 212 margin-top: 0.5rem; 213 color: var(--color-subtle); 214 + font-family: var(--font-body); 215 font-size: 0.875rem; 216 line-height: 1.5; 217 display: -webkit-box; ··· 228 .entry-card-preview h6 { 229 font-size: 0.875rem; 230 font-weight: 600; 231 + margin-top: 0.5rem; 232 + margin-left: 0; 233 + margin-right: 0; 234 } 235 236 .entry-card-preview p {
+3 -2
crates/weaver-app/assets/styling/entry.css
··· 1 /* Entry page layout with gutter navigation */ 2 .entry-page-layout { 3 display: grid; 4 - grid-template-columns: minmax(200px, 1fr) minmax(0, 90ch) minmax(200px, 1fr); 5 gap: 2rem; 6 width: 100%; 7 min-height: 100vh; 8 background: var(--color-base); 9 - max-width: calc(90ch + 400px + 4rem); /* content + gutters + gaps */ 10 margin: 0 auto; 11 padding: 0 1rem 0 0; 12 box-sizing: border-box; ··· 132 .entry-tags { 133 display: flex; 134 align-items: center; 135 gap: 0.5rem; 136 } 137
··· 1 /* Entry page layout with gutter navigation */ 2 .entry-page-layout { 3 display: grid; 4 + grid-template-columns: minmax(200px, 1fr) minmax(0, 95ch) minmax(200px, 1fr); 5 gap: 2rem; 6 width: 100%; 7 min-height: 100vh; 8 background: var(--color-base); 9 + max-width: calc(95ch + 400px + 4rem); /* content + gutters + gaps */ 10 margin: 0 auto; 11 padding: 0 1rem 0 0; 12 box-sizing: border-box; ··· 132 .entry-tags { 133 display: flex; 134 align-items: center; 135 + font-family: var(--font-ui); 136 gap: 0.5rem; 137 } 138
+11 -7
crates/weaver-app/assets/styling/main.css
··· 1 body { 2 background-color: var(--color-base); 3 color: var(--color-text); 4 - font-family: var(--font-body); 5 } 6 7 #header { ··· 32 transition: border-color 0.2s; 33 } 34 35 .uri-input:focus { 36 border-color: var(--color-primary); 37 } ··· 40 color: var(--color-subtle); 41 opacity: 0.5; 42 } 43 - 44 - a {{ 45 - color: var(--color-link); 46 - text-decoration: none; 47 - }} 48 - 49 50 @font-face { 51 font-family: "Ioskeley Mono";
··· 1 body { 2 background-color: var(--color-base); 3 color: var(--color-text); 4 + font-family: var(--font-ui); 5 + } 6 + 7 + a { 8 + color: var(--color-link); 9 + text-decoration: none; 10 } 11 12 #header { ··· 37 transition: border-color 0.2s; 38 } 39 40 + .notebook-content { 41 + width: 100%; 42 + max-width: 95ch; 43 + } 44 + 45 .uri-input:focus { 46 border-color: var(--color-primary); 47 } ··· 50 color: var(--color-subtle); 51 opacity: 0.5; 52 } 53 54 @font-face { 55 font-family: "Ioskeley Mono";
+62 -7
crates/weaver-app/assets/styling/notebook-card.css
··· 4 5 .repository-layout { 6 display: grid; 7 - grid-template-columns: minmax(240px, 1fr) minmax(0, 90ch) minmax(240px, 1fr); 8 - gap: 2rem; 9 - max-width: calc(90ch + 480px + 4rem); /* content + gutters + gaps */ 10 margin: 0 auto; 11 padding: 2.25rem 1.25rem 2.25rem 0; 12 } ··· 27 /* Mobile layout - sidebar becomes header */ 28 @media (max-width: 1400px) { 29 .repository-layout { 30 - grid-template-columns: minmax(1rem, 1fr) minmax(0, 90ch) minmax(1rem, 1fr) !important; 31 gap: 0 !important; 32 max-width: 100vw !important; 33 box-sizing: border-box !important; ··· 112 color: var(--color-base); 113 } 114 115 .notebook-actions { 116 - margin-bottom: 1.5rem; 117 } 118 119 .notebook-card-title { ··· 128 .notebook-card-date { 129 color: var(--color-muted); 130 font-size: 0.85rem; 131 } 132 133 .notebook-card-authors { ··· 226 227 .entry-preview-content { 228 color: var(--color-subtle); 229 font-size: 0.875rem; 230 line-height: 1.5; 231 display: -webkit-box; ··· 250 .entry-preview-content h6 { 251 font-size: 0.875rem; 252 font-weight: 600; 253 - margin: 0; 254 } 255 256 .entry-preview-content code { ··· 289 .notebook-card-preview { 290 margin-top: 0.75rem; 291 color: var(--color-subtle); 292 font-size: 0.9rem; 293 line-height: 1.5; 294 display: -webkit-box; ··· 301 @media (prefers-color-scheme: dark) { 302 .notebook-card-container { 303 box-shadow: none; 304 - border: 1px dashed var(--color-border); 305 } 306 }
··· 4 5 .repository-layout { 6 display: grid; 7 + grid-template-columns: minmax(240px, 1fr) minmax(0, 95ch) minmax(240px, 1fr); 8 + gap: 1rem; 9 + max-width: calc(95ch + 480px + 4rem); /* content + gutters + gaps */ 10 margin: 0 auto; 11 padding: 2.25rem 1.25rem 2.25rem 0; 12 } ··· 27 /* Mobile layout - sidebar becomes header */ 28 @media (max-width: 1400px) { 29 .repository-layout { 30 + grid-template-columns: minmax(1rem, 1fr) minmax(0, 95ch) minmax(1rem, 1fr) !important; 31 gap: 0 !important; 32 max-width: 100vw !important; 33 box-sizing: border-box !important; ··· 112 color: var(--color-base); 113 } 114 115 + .notebook-header-actions { 116 + display: flex; 117 + align-items: center; 118 + gap: 0.25rem; 119 + } 120 + 121 + .notebook-header-actions .button { 122 + font-size: 0.85rem; 123 + color: var(--color-primary); 124 + background: transparent; 125 + padding: 0.25rem 0.5rem; 126 + border-radius: 0; 127 + border: 1px solid var(--color-border); 128 + transition: 129 + background-color 0.15s ease, 130 + color 0.15s ease; 131 + } 132 + 133 + .notebook-header-actions .button:hover { 134 + background-color: var(--color-primary); 135 + color: var(--color-base); 136 + } 137 + 138 .notebook-actions { 139 + display: flex; 140 + align-items: center; 141 + gap: 0.25rem; 142 + } 143 + 144 + .notebook-actions-dropdown { 145 + position: relative; 146 + } 147 + 148 + .notebook-action-link { 149 + text-decoration: none; 150 + } 151 + 152 + .notebook-actions .button { 153 + font-size: 0.85rem; 154 + color: var(--color-primary); 155 + background: transparent; 156 + padding: 0.25rem 0.5rem; 157 + border-radius: 0; 158 + border: 1px solid var(--color-border); 159 + transition: 160 + background-color 0.15s ease, 161 + color 0.15s ease; 162 + } 163 + 164 + .notebook-actions .button:hover { 165 + background-color: var(--color-primary); 166 + color: var(--color-base); 167 } 168 169 .notebook-card-title { ··· 178 .notebook-card-date { 179 color: var(--color-muted); 180 font-size: 0.85rem; 181 + margin-left: 0.25rem; 182 } 183 184 .notebook-card-authors { ··· 277 278 .entry-preview-content { 279 color: var(--color-subtle); 280 + font-family: var(--font-body); 281 font-size: 0.875rem; 282 line-height: 1.5; 283 display: -webkit-box; ··· 302 .entry-preview-content h6 { 303 font-size: 0.875rem; 304 font-weight: 600; 305 + margin-top: 0; 306 + margin-left: 0; 307 + margin-right: 0; 308 } 309 310 .entry-preview-content code { ··· 343 .notebook-card-preview { 344 margin-top: 0.75rem; 345 color: var(--color-subtle); 346 + font-family: var(--font-body); 347 font-size: 0.9rem; 348 line-height: 1.5; 349 display: -webkit-box; ··· 356 @media (prefers-color-scheme: dark) { 357 .notebook-card-container { 358 box-shadow: none; 359 + border: 1px solid var(--color-border); 360 } 361 }
+6 -4
crates/weaver-app/assets/styling/profile.css
··· 16 width: 100%; 17 height: 100%; 18 object-fit: cover; 19 } 20 21 .profile-content { ··· 73 .profile-stats { 74 display: grid; 75 grid-template-columns: repeat(auto-fit, minmax(100px, 1fr)); 76 - gap: 1.25rem; 77 padding: 1.25rem 0; 78 margin: 1.25rem 0; 79 border-top: 1.5px dashed var(--color-border); ··· 181 @media (prefers-color-scheme: dark) { 182 .profile-display { 183 background-color: var(--color-surface); 184 - border: 1px dashed var(--color-border); 185 } 186 } 187 188 /* Desktop: sidebar gets top and right borders */ 189 @media (min-width: 1400px) { 190 .profile-display { 191 - border-top: 1.5px dashed var(--color-border); 192 - border-right: 1.5px dashed var(--color-border); 193 } 194 }
··· 16 width: 100%; 17 height: 100%; 18 object-fit: cover; 19 + margin: 0; 20 + border-radius: 0; 21 } 22 23 .profile-content { ··· 75 .profile-stats { 76 display: grid; 77 grid-template-columns: repeat(auto-fit, minmax(100px, 1fr)); 78 + gap: 0.25rem; 79 padding: 1.25rem 0; 80 margin: 1.25rem 0; 81 border-top: 1.5px dashed var(--color-border); ··· 183 @media (prefers-color-scheme: dark) { 184 .profile-display { 185 background-color: var(--color-surface); 186 + border: 1px solid var(--color-border); 187 } 188 } 189 190 /* Desktop: sidebar gets top and right borders */ 191 @media (min-width: 1400px) { 192 .profile-display { 193 + border-top: 1.5px solid var(--color-border); 194 + border-right: 1.5px solid var(--color-border); 195 } 196 }
+254 -4
crates/weaver-app/assets/styling/theme-defaults.css
··· 1 /* Default theme variables for non-notebook pages */ 2 /* These match the Rose Pine light/dark defaults */ 3 4 @font-face { 5 font-family: "Ioskeley Mono"; 6 font-style: normal; ··· 38 src: url("/assets/IoskeleyMono-BoldItalic.woff2") format("woff2"); 39 } 40 41 - /* CSS Variables - Light Mode (default) */ 42 :root { 43 --color-base: #faf4ed; 44 --color-surface: #fffaf3; ··· 57 --color-link: #d7827e; 58 --color-highlight: #cecacd; 59 60 - --font-body: "IBM Plex", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; 61 - --font-heading: "IBM Plex Sans", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; 62 - --font-mono: "Ioskeley Mono", "IBM Plex Mono", "Berkeley Mono", "Cascadia Code", "Roboto Mono", Consolas, monospace; 63 64 --spacing-base: 16px; 65 --spacing-line-height: 1.6;
··· 1 /* Default theme variables for non-notebook pages */ 2 /* These match the Rose Pine light/dark defaults */ 3 4 + /* ========================================================================== 5 + FONT FACES 6 + Note: Dioxus serves all assets from /assets/ regardless of subdirectory 7 + ========================================================================== */ 8 + 9 + /* --- Ioskeley Mono (monospace) --- */ 10 @font-face { 11 font-family: "Ioskeley Mono"; 12 font-style: normal; ··· 44 src: url("/assets/IoskeleyMono-BoldItalic.woff2") format("woff2"); 45 } 46 47 + /* --- Adobe Caslon Pro (serif) --- */ 48 + @font-face { 49 + font-family: "Adobe Caslon Pro"; 50 + font-style: normal; 51 + font-weight: normal; 52 + src: url("/assets/AdobeCaslonPro-Regular.ttf") format("truetype"); 53 + } 54 + 55 + @font-face { 56 + font-family: "Adobe Caslon Pro"; 57 + font-style: normal; 58 + font-weight: bold; 59 + src: url("/assets/AdobeCaslonPro-Semibold.ttf") format("truetype"); 60 + } 61 + 62 + @font-face { 63 + font-family: "Adobe Caslon Pro"; 64 + font-style: italic; 65 + font-weight: normal; 66 + src: url("/assets/AdobeCaslonPro-Italic.ttf") format("truetype"); 67 + } 68 + 69 + @font-face { 70 + font-family: "Adobe Caslon Pro"; 71 + font-style: italic; 72 + font-weight: bold; 73 + src: url("/assets/AdobeCaslonPro-BoldItalic.ttf") format("truetype"); 74 + } 75 + 76 + /* --- Latin Modern Roman (serif) --- */ 77 + @font-face { 78 + font-family: "Latin Modern Roman"; 79 + font-style: normal; 80 + font-weight: normal; 81 + src: url("/assets/LatinModernRoman-Regular.otf") format("opentype"); 82 + } 83 + @font-face { 84 + font-family: "Latin Modern Roman"; 85 + font-style: normal; 86 + font-weight: bold; 87 + src: url("/assets/LatinModernRoman-Bold.otf") format("opentype"); 88 + } 89 + @font-face { 90 + font-family: "Latin Modern Roman"; 91 + font-style: italic; 92 + font-weight: normal; 93 + src: url("/assets/LatinModernRoman-Italic.otf") format("opentype"); 94 + } 95 + @font-face { 96 + font-family: "Latin Modern Roman"; 97 + font-style: italic; 98 + font-weight: bold; 99 + src: url("/assets/LatinModernRoman-BoldItalic.otf") format("opentype"); 100 + } 101 + 102 + /* --- Computer Modern Serif --- */ 103 + @font-face { 104 + font-family: "CM Serif"; 105 + font-style: normal; 106 + font-weight: normal; 107 + src: url("/assets/CMSerif-Regular.woff") format("woff"); 108 + } 109 + @font-face { 110 + font-family: "CM Serif"; 111 + font-style: normal; 112 + font-weight: bold; 113 + src: url("/assets/CMSerif-Bold.woff") format("woff"); 114 + } 115 + @font-face { 116 + font-family: "CM Serif"; 117 + font-style: italic; 118 + font-weight: normal; 119 + src: url("/assets/CMSerif-Italic.woff") format("woff"); 120 + } 121 + @font-face { 122 + font-family: "CM Serif"; 123 + font-style: italic; 124 + font-weight: bold; 125 + src: url("/assets/CMSerif-BoldItalic.woff") format("woff"); 126 + } 127 + 128 + /* --- Computer Modern Sans --- */ 129 + @font-face { 130 + font-family: "CM Sans"; 131 + font-style: normal; 132 + font-weight: normal; 133 + src: url("/assets/CMSans-Regular.woff") format("woff"); 134 + } 135 + @font-face { 136 + font-family: "CM Sans"; 137 + font-style: normal; 138 + font-weight: bold; 139 + src: url("/assets/CMSans-Bold.woff") format("woff"); 140 + } 141 + @font-face { 142 + font-family: "CM Sans"; 143 + font-style: italic; 144 + font-weight: normal; 145 + src: url("/assets/CMSans-Italic.woff") format("woff"); 146 + } 147 + @font-face { 148 + font-family: "CM Sans"; 149 + font-style: italic; 150 + font-weight: bold; 151 + src: url("/assets/CMSans-BoldItalic.woff") format("woff"); 152 + } 153 + 154 + /* --- Junction (geometric sans) --- */ 155 + @font-face { 156 + font-family: "Junction"; 157 + font-style: normal; 158 + font-weight: 300; 159 + src: url("/assets/Junction-Light.woff") format("woff"); 160 + } 161 + @font-face { 162 + font-family: "Junction"; 163 + font-style: normal; 164 + font-weight: normal; 165 + src: url("/assets/Junction-Regular.woff") format("woff"); 166 + } 167 + @font-face { 168 + font-family: "Junction"; 169 + font-style: normal; 170 + font-weight: bold; 171 + src: url("/assets/Junction-Bold.woff") format("woff"); 172 + } 173 + 174 + /* --- Proza Libre (humanist sans) --- */ 175 + @font-face { 176 + font-family: "Proza Libre"; 177 + font-style: normal; 178 + font-weight: normal; 179 + src: url("/assets/ProzaLibre-Regular.woff2") format("woff2"); 180 + } 181 + @font-face { 182 + font-family: "Proza Libre"; 183 + font-style: italic; 184 + font-weight: normal; 185 + src: url("/assets/ProzaLibre-Italic.woff2") format("woff2"); 186 + } 187 + @font-face { 188 + font-family: "Proza Libre"; 189 + font-style: normal; 190 + font-weight: 500; 191 + src: url("/assets/ProzaLibre-Medium.woff2") format("woff2"); 192 + } 193 + @font-face { 194 + font-family: "Proza Libre"; 195 + font-style: normal; 196 + font-weight: 600; 197 + src: url("/assets/ProzaLibre-SemiBold.woff2") format("woff2"); 198 + } 199 + @font-face { 200 + font-family: "Proza Libre"; 201 + font-style: normal; 202 + font-weight: bold; 203 + src: url("/assets/ProzaLibre-Bold.woff2") format("woff2"); 204 + } 205 + @font-face { 206 + font-family: "Proza Libre"; 207 + font-style: italic; 208 + font-weight: bold; 209 + src: url("/assets/ProzaLibre-BoldItalic.woff2") format("woff2"); 210 + } 211 + 212 + /* ========================================================================== 213 + CSS VARIABLES - LIGHT MODE 214 + 215 + CONTRAST OPTIONS: Uncomment ONE of the following sections 216 + - ORIGINAL: Rose Pine Dawn defaults (soft, low contrast) 217 + - MILD: ~25% darker text, subtle improvement 218 + - MODERATE: ~40% darker text, noticeably better contrast 219 + ========================================================================== */ 220 + 221 + /* --- ORIGINAL ROSE PINE DAWN (uncomment to restore) --- 222 :root { 223 --color-base: #faf4ed; 224 --color-surface: #fffaf3; ··· 237 --color-link: #d7827e; 238 --color-highlight: #cecacd; 239 240 + --font-ui: "Proza Libre", "Junction", system-ui, -apple-system, sans-serif; 241 + --font-body: "IBM Plex Sans", system-ui, sans-serif; 242 + --font-heading: "IBM Plex Sans", system-ui, sans-serif; 243 + --font-mono: "Ioskeley Mono", "IBM Plex Mono", Consolas, monospace; 244 + 245 + --spacing-base: 16px; 246 + --spacing-line-height: 1.6; 247 + --spacing-scale: 1.25; 248 + } 249 + */ 250 + 251 + /* --- MILD CONTRAST (uncomment to use) --- 252 + :root { 253 + --color-base: #faf4ed; 254 + --color-surface: #fffaf3; 255 + --color-overlay: #f2e9e1; 256 + --color-text: #453f5c; 257 + --color-muted: #7a7589; 258 + --color-subtle: #5f5a73; 259 + --color-emphasis: #2d2a3d; 260 + --color-primary: #907aa9; 261 + --color-secondary: #56949f; 262 + --color-tertiary: #286983; 263 + --color-error: #b4637a; 264 + --color-warning: #ea9d34; 265 + --color-success: #286983; 266 + --color-border: #908caa; 267 + --color-link: #d7827e; 268 + --color-highlight: #cecacd; 269 + 270 + --font-ui: "Proza Libre", "Junction", system-ui, -apple-system, sans-serif; 271 + --font-body: "Latin Modern Roman", "Adobe Caslon Pro", "CM Serif", Georgia, serif; 272 + --font-heading: "Proza Libre", "Junction", "CM Sans", system-ui, sans-serif; 273 + --font-mono: "Ioskeley Mono", "IBM Plex Mono", "Berkeley Mono", Consolas, monospace; 274 + 275 + --spacing-base: 16px; 276 + --spacing-line-height: 1.6; 277 + --spacing-scale: 1.25; 278 + } 279 + */ 280 + 281 + /* --- MODERATE CONTRAST (active) --- */ 282 + :root { 283 + --color-base: #faf4ed; 284 + --color-surface: #fffaf3; 285 + --color-overlay: #f2e9e1; 286 + /* Text colors darkened for better contrast */ 287 + --color-text: #1f1d2e; 288 + --color-muted: #635e74; 289 + --color-subtle: #4a4560; 290 + --color-emphasis: #1e1a2d; 291 + /* Accent colors kept at original Rose Pine Dawn values */ 292 + --color-primary: #907aa9; 293 + --color-secondary: #56949f; 294 + --color-tertiary: #286983; 295 + --color-error: #b4637a; 296 + --color-warning: #ea9d34; 297 + --color-success: #286983; 298 + --color-border: #908caa; 299 + --color-link: #d7827e; 300 + --color-highlight: #cecacd; 301 + 302 + /* UI FONT - Fixed sans stack for all UI elements (buttons, nav, labels, etc.) 303 + This does NOT follow theme - keeps UI consistent across all content themes */ 304 + --font-ui: "CM Sans", "Junction", "Proza Libre", system-ui, -apple-system, sans-serif; 305 + 306 + /* CONTENT FONT STACKS - Edit these to try different combinations 307 + Sans options: "Proza Libre", "Junction", "CM Sans", "IBM Plex Sans" 308 + Serif options: "Latin Modern Roman", "Adobe Caslon Pro", "CM Serif" 309 + */ 310 + --font-body: "Adobe Caslon Pro", "Latin Modern Roman", "CM Serif", Georgia, serif; 311 + --font-heading: "IBM Plex Sans", "CM Sans", "Junction", "Proza Libre", system-ui, sans-serif; 312 + --font-mono: "Ioskeley Mono", "IBM Plex Mono", "Berkeley Mono", Consolas, monospace; 313 314 --spacing-base: 16px; 315 --spacing-line-height: 1.6;
+30 -1
crates/weaver-app/src/components/entry.rs
··· 445 if is_owner { 446 EntryActions { 447 entry_uri, 448 entry_title: title.to_string(), 449 in_notebook: true, 450 notebook_title: Some(book_title.clone()), ··· 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 ··· 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); ··· 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}" } ··· 685 h1 { class: "entry-title", "{title}" } 686 EntryActions { 687 entry_uri: entry_uri.clone(), 688 entry_title, 689 in_notebook: book_title.is_some(), 690 notebook_title: book_title.clone(),
··· 445 if is_owner { 446 EntryActions { 447 entry_uri, 448 + entry_cid: entry_view.cid.clone().into_static(), 449 entry_title: title.to_string(), 450 in_notebook: true, 451 notebook_title: Some(book_title.clone()), ··· 520 /// Card for entries in a feed (e.g., home page) 521 /// Takes EntryView directly (not BookEntryView) and always shows author info 522 #[component] 523 + pub fn FeedEntryCard( 524 + entry_view: EntryView<'static>, 525 + entry: entry::Entry<'static>, 526 + #[props(default = false)] show_actions: bool, 527 + #[props(default = false)] is_pinned: bool, 528 + #[props(default)] on_pinned_changed: Option<EventHandler<bool>>, 529 + ) -> Element { 530 use crate::Route; 531 + use crate::auth::AuthState; 532 use jacquard::from_data; 533 use weaver_api::sh_weaver::actor::ProfileDataViewInner; 534 ··· 558 // Get first author 559 let first_author = entry_view.authors.first(); 560 561 + // Check ownership for actions 562 + let auth_state = use_context::<Signal<AuthState>>(); 563 + let is_owner = { 564 + let current_did = auth_state.read().did.clone(); 565 + match (&current_did, &ident) { 566 + (Some(did), AtIdentifier::Did(ident_did)) => *did == *ident_did, 567 + _ => false, 568 + } 569 + }; 570 + 571 // Render preview from truncated entry content 572 let preview_html = { 573 let parser = markdown_weaver::Parser::new(&entry.content); ··· 650 div { class: "entry-card-date", 651 time { datetime: "{entry.created_at.as_str()}", "{formatted_date}" } 652 } 653 + if show_actions && is_owner { 654 + crate::components::EntryActions { 655 + entry_uri: entry_view.uri.clone().into_static(), 656 + entry_cid: entry_view.cid.clone().into_static(), 657 + entry_title: title.to_string(), 658 + in_notebook: false, 659 + is_pinned, 660 + on_pinned_changed 661 + } 662 + } 663 } 664 665 div { class: "entry-card-preview", dangerous_inner_html: "{preview_html}" } ··· 713 h1 { class: "entry-title", "{title}" } 714 EntryActions { 715 entry_uri: entry_uri.clone(), 716 + entry_cid: entry_view.cid.clone().into_static(), 717 entry_title, 718 in_notebook: book_title.is_some(), 719 notebook_title: book_title.clone(),
+175 -2
crates/weaver-app/src/components/entry_actions.rs
··· 1 - //! Action buttons for entries (edit, delete, remove from notebook). 2 3 use crate::Route; 4 use crate::auth::AuthState; ··· 9 use jacquard::smol_str::SmolStr; 10 use jacquard::types::aturi::AtUri; 11 use jacquard::types::ident::AtIdentifier; 12 use jacquard::IntoStatic; 13 use weaver_api::com_atproto::repo::delete_record::DeleteRecord; 14 use weaver_api::com_atproto::repo::put_record::PutRecord; 15 16 const ENTRY_ACTIONS_CSS: Asset = asset!("/assets/styling/entry-actions.css"); 17 ··· 19 pub struct EntryActionsProps { 20 /// The AT-URI of the entry 21 pub entry_uri: AtUri<'static>, 22 /// The entry title (for display in confirmation) 23 pub entry_title: String, 24 /// Whether this entry is in a notebook (enables "remove from notebook") ··· 27 /// Notebook title (if in_notebook is true, used for edit route) 28 #[props(default)] 29 pub notebook_title: Option<SmolStr>, 30 /// Callback when entry is removed from notebook (for optimistic UI update) 31 #[props(default)] 32 pub on_removed: Option<EventHandler<()>>, 33 } 34 35 /// Action buttons for an entry: edit, delete, optionally remove from notebook. ··· 44 let mut show_dropdown = use_signal(|| false); 45 let mut deleting = use_signal(|| false); 46 let mut removing = use_signal(|| false); 47 let mut error = use_signal(|| None::<String>); 48 49 // Check ownership - compare auth DID with entry's authority ··· 142 let entry_uri_for_remove = props.entry_uri.clone(); 143 let notebook_title_for_remove = props.notebook_title.clone(); 144 let on_removed = props.on_removed.clone(); 145 let handle_remove_from_notebook = move |_| { 146 - let fetcher = fetcher.clone(); 147 let entry_uri = entry_uri_for_remove.clone(); 148 let notebook_title = notebook_title_for_remove.clone(); 149 let on_removed = on_removed.clone(); ··· 258 }); 259 }; 260 261 rsx! { 262 document::Link { rel: "stylesheet", href: ENTRY_ACTIONS_CSS } 263 ··· 282 283 if show_dropdown() { 284 div { class: "dropdown-menu", 285 if props.in_notebook { 286 button { 287 class: "dropdown-item", ··· 292 "Remove from notebook" 293 } 294 } 295 button { 296 class: "dropdown-item dropdown-item-danger", 297 onclick: move |_| {
··· 1 + //! Action buttons for entries (edit, delete, remove from notebook, pin/unpin). 2 3 use crate::Route; 4 use crate::auth::AuthState; ··· 9 use jacquard::smol_str::SmolStr; 10 use jacquard::types::aturi::AtUri; 11 use jacquard::types::ident::AtIdentifier; 12 + use jacquard::types::string::Cid; 13 use jacquard::IntoStatic; 14 use weaver_api::com_atproto::repo::delete_record::DeleteRecord; 15 use weaver_api::com_atproto::repo::put_record::PutRecord; 16 + use weaver_api::com_atproto::repo::strong_ref::StrongRef; 17 + use weaver_api::sh_weaver::actor::profile::Profile as WeaverProfile; 18 19 const ENTRY_ACTIONS_CSS: Asset = asset!("/assets/styling/entry-actions.css"); 20 ··· 22 pub struct EntryActionsProps { 23 /// The AT-URI of the entry 24 pub entry_uri: AtUri<'static>, 25 + /// The CID of the entry (for StrongRef when pinning) 26 + pub entry_cid: Cid<'static>, 27 /// The entry title (for display in confirmation) 28 pub entry_title: String, 29 /// Whether this entry is in a notebook (enables "remove from notebook") ··· 32 /// Notebook title (if in_notebook is true, used for edit route) 33 #[props(default)] 34 pub notebook_title: Option<SmolStr>, 35 + /// Whether this entry is currently pinned 36 + #[props(default = false)] 37 + pub is_pinned: bool, 38 /// Callback when entry is removed from notebook (for optimistic UI update) 39 #[props(default)] 40 pub on_removed: Option<EventHandler<()>>, 41 + /// Callback when pin state changes 42 + #[props(default)] 43 + pub on_pinned_changed: Option<EventHandler<bool>>, 44 } 45 46 /// Action buttons for an entry: edit, delete, optionally remove from notebook. ··· 55 let mut show_dropdown = use_signal(|| false); 56 let mut deleting = use_signal(|| false); 57 let mut removing = use_signal(|| false); 58 + let mut pinning = use_signal(|| false); 59 let mut error = use_signal(|| None::<String>); 60 61 // Check ownership - compare auth DID with entry's authority ··· 154 let entry_uri_for_remove = props.entry_uri.clone(); 155 let notebook_title_for_remove = props.notebook_title.clone(); 156 let on_removed = props.on_removed.clone(); 157 + let remove_fetcher = fetcher.clone(); 158 let handle_remove_from_notebook = move |_| { 159 + let fetcher = remove_fetcher.clone(); 160 let entry_uri = entry_uri_for_remove.clone(); 161 let notebook_title = notebook_title_for_remove.clone(); 162 let on_removed = on_removed.clone(); ··· 271 }); 272 }; 273 274 + // Handler for pinning/unpinning 275 + let entry_uri_for_pin = props.entry_uri.clone(); 276 + let entry_cid_for_pin = props.entry_cid.clone(); 277 + let is_currently_pinned = props.is_pinned; 278 + let on_pinned_changed = props.on_pinned_changed.clone(); 279 + let pin_fetcher = fetcher.clone(); 280 + let handle_pin_toggle = move |_| { 281 + let fetcher = pin_fetcher.clone(); 282 + let entry_uri = entry_uri_for_pin.clone(); 283 + let entry_cid = entry_cid_for_pin.clone(); 284 + let on_pinned_changed = on_pinned_changed.clone(); 285 + 286 + spawn(async move { 287 + use jacquard::{from_data, prelude::*, to_data, types::string::Nsid}; 288 + use weaver_api::app_bsky::actor::profile::Profile as BskyProfile; 289 + 290 + pinning.set(true); 291 + error.set(None); 292 + 293 + let client = fetcher.get_client(); 294 + 295 + let did = match fetcher.current_did().await { 296 + Some(d) => d, 297 + None => { 298 + error.set(Some("Not authenticated".to_string())); 299 + pinning.set(false); 300 + return; 301 + } 302 + }; 303 + 304 + let profile_uri_str = format!("at://{}/sh.weaver.actor.profile/self", did); 305 + 306 + // Try to fetch existing weaver profile 307 + let weaver_uri = match WeaverProfile::uri(&profile_uri_str) { 308 + Ok(u) => u, 309 + Err(_) => { 310 + error.set(Some("Invalid profile URI".to_string())); 311 + pinning.set(false); 312 + return; 313 + } 314 + }; 315 + let existing_profile: Option<WeaverProfile<'static>> = 316 + match client.fetch_record(&weaver_uri).await { 317 + Ok(output) => Some(output.value), 318 + Err(_) => None, 319 + }; 320 + 321 + // Build the new pinned list 322 + let new_pinned: Vec<StrongRef<'static>> = if is_currently_pinned { 323 + // Unpin: remove from list 324 + existing_profile 325 + .as_ref() 326 + .and_then(|p| p.pinned.as_ref()) 327 + .map(|pins| { 328 + pins.iter() 329 + .filter(|r| r.uri.as_ref() != entry_uri.as_ref()) 330 + .cloned() 331 + .collect() 332 + }) 333 + .unwrap_or_default() 334 + } else { 335 + // Pin: add to list 336 + let new_ref = StrongRef::new() 337 + .uri(entry_uri.clone().into_static()) 338 + .cid(entry_cid.clone()) 339 + .build(); 340 + let mut pins = existing_profile 341 + .as_ref() 342 + .and_then(|p| p.pinned.clone()) 343 + .unwrap_or_default(); 344 + // Don't add if already exists 345 + if !pins.iter().any(|r| r.uri.as_ref() == entry_uri.as_ref()) { 346 + pins.push(new_ref); 347 + } 348 + pins 349 + }; 350 + 351 + // Build the profile to save 352 + let profile_to_save = if let Some(existing) = existing_profile { 353 + // Update existing profile 354 + WeaverProfile { 355 + pinned: Some(new_pinned), 356 + ..existing 357 + } 358 + } else { 359 + // Create new profile from bsky data 360 + let bsky_uri_str = format!("at://{}/app.bsky.actor.profile/self", did); 361 + let bsky_profile: Option<BskyProfile<'static>> = 362 + match BskyProfile::uri(&bsky_uri_str) { 363 + Ok(bsky_uri) => match client.fetch_record(&bsky_uri).await { 364 + Ok(output) => Some(output.value), 365 + Err(_) => None, 366 + }, 367 + Err(_) => None, 368 + }; 369 + 370 + WeaverProfile::new() 371 + .maybe_display_name( 372 + bsky_profile 373 + .as_ref() 374 + .and_then(|p| p.display_name.clone()), 375 + ) 376 + .maybe_description( 377 + bsky_profile.as_ref().and_then(|p| p.description.clone()), 378 + ) 379 + .maybe_avatar(bsky_profile.as_ref().and_then(|p| p.avatar.clone())) 380 + .maybe_banner(bsky_profile.as_ref().and_then(|p| p.banner.clone())) 381 + .bluesky(true) 382 + .created_at(jacquard::types::string::Datetime::now()) 383 + .pinned(new_pinned) 384 + .build() 385 + }; 386 + 387 + // Serialize and save 388 + let profile_data = match to_data(&profile_to_save) { 389 + Ok(d) => d, 390 + Err(e) => { 391 + error.set(Some(format!("Failed to serialize profile: {:?}", e))); 392 + pinning.set(false); 393 + return; 394 + } 395 + }; 396 + 397 + let request = PutRecord::new() 398 + .repo(AtIdentifier::Did(did)) 399 + .collection(Nsid::new_static("sh.weaver.actor.profile").unwrap()) 400 + .rkey(jacquard::types::string::Rkey::new("self").unwrap()) 401 + .record(profile_data) 402 + .build(); 403 + 404 + match client.send(request).await { 405 + Ok(_) => { 406 + show_dropdown.set(false); 407 + if let Some(handler) = &on_pinned_changed { 408 + handler.call(!is_currently_pinned); 409 + } 410 + } 411 + Err(e) => { 412 + error.set(Some(format!("Failed to update profile: {:?}", e))); 413 + } 414 + } 415 + pinning.set(false); 416 + }); 417 + }; 418 + 419 rsx! { 420 document::Link { rel: "stylesheet", href: ENTRY_ACTIONS_CSS } 421 ··· 440 441 if show_dropdown() { 442 div { class: "dropdown-menu", 443 + // Pin/Unpin (first) 444 + button { 445 + class: "dropdown-item", 446 + disabled: pinning(), 447 + onclick: handle_pin_toggle, 448 + if pinning() { 449 + "Updating..." 450 + } else if props.is_pinned { 451 + "Unpin" 452 + } else { 453 + "Pin" 454 + } 455 + } 456 + // Remove from notebook (if in notebook) 457 if props.in_notebook { 458 button { 459 class: "dropdown-item", ··· 464 "Remove from notebook" 465 } 466 } 467 + // Delete (last, danger style) 468 button { 469 class: "dropdown-item dropdown-item-danger", 470 onclick: move |_| {
+55 -32
crates/weaver-app/src/components/identity.rs
··· 1 use crate::auth::AuthState; 2 use crate::components::{FeedEntryCard, ProfileActions, ProfileActionsMenubar}; 3 use crate::{Route, data, fetch}; 4 use dioxus::prelude::*; ··· 68 } 69 70 const NOTEBOOK_CARD_CSS: Asset = asset!("/assets/styling/notebook-card.css"); 71 72 #[component] 73 pub fn Repository(ident: ReadSignal<AtIdentifier<'static>>) -> Element { 74 rsx! { 75 div { 76 Outlet::<Route> {} 77 } ··· 84 use jacquard::from_data; 85 use weaver_api::sh_weaver::notebook::book::Book; 86 87 - let (notebooks_result, notebooks) = data::use_notebooks_for_did(ident); 88 - let (entries_result, all_entries) = data::use_entries_for_did(ident); 89 - let (profile_result, profile) = crate::data::use_profile_data(ident); 90 - 91 - #[cfg(feature = "fullstack-server")] 92 - notebooks_result?; 93 - 94 - #[cfg(feature = "fullstack-server")] 95 - entries_result?; 96 - 97 - #[cfg(feature = "fullstack-server")] 98 - profile_result?; 99 100 // Extract pinned URIs from profile (only Weaver ProfileView has pinned) 101 let pinned_uris = use_memo(move || { ··· 349 class: "pinned-item", 350 NotebookCard { 351 notebook: notebook.clone(), 352 - entry_refs: entries.clone() 353 } 354 } 355 } ··· 361 class: "pinned-item standalone-entry-item", 362 FeedEntryCard { 363 entry_view: entry_view.clone(), 364 - entry: entry.clone() 365 } 366 } 367 } ··· 392 key: "notebook-{notebook.cid}", 393 NotebookCard { 394 notebook: notebook.clone(), 395 - entry_refs: entries.clone() 396 } 397 } 398 } ··· 404 class: "standalone-entry-item", 405 FeedEntryCard { 406 entry_view: entry_view.clone(), 407 - entry: entry.clone() 408 } 409 } 410 } ··· 428 pub fn NotebookCard( 429 notebook: NotebookView<'static>, 430 entry_refs: Vec<StrongRef<'static>>, 431 ) -> Element { 432 use jacquard::{IntoStatic, from_data}; 433 use weaver_api::sh_weaver::notebook::book::Book; ··· 484 div { class: "notebook-card", 485 div { class: "notebook-card-container", 486 487 - Link { 488 - to: Route::EntryPage { 489 - ident: ident.clone(), 490 - book_title: notebook_path.clone().into(), 491 - title: "".into() // Will redirect to first entry 492 - }, 493 - class: "notebook-card-header-link", 494 - 495 - div { class: "notebook-card-header", 496 - div { class: "notebook-card-header-top", 497 h2 { class: "notebook-card-title", "{title}" } 498 - if is_owner { 499 Link { 500 to: Route::NewDraft { ident: notebook_ident.clone(), notebook: Some(book_title.clone()) }, 501 - class: "notebook-add-entry", 502 - "+ Add" 503 } 504 } 505 } 506 507 - div { class: "notebook-card-date", 508 - time { datetime: "{notebook.indexed_at.as_str()}", "{formatted_date}" } 509 - } 510 } 511 } 512 ··· 604 if is_owner { 605 crate::components::EntryActions { 606 entry_uri, 607 entry_title: entry_title.to_string(), 608 in_notebook: true, 609 notebook_title: Some(book_title.clone()) ··· 674 if is_owner { 675 crate::components::EntryActions { 676 entry_uri, 677 entry_title: entry_title.to_string(), 678 in_notebook: true, 679 notebook_title: Some(book_title.clone()) ··· 753 if is_owner { 754 crate::components::EntryActions { 755 entry_uri, 756 entry_title: entry_title.to_string(), 757 in_notebook: true, 758 notebook_title: Some(book_title.clone())
··· 1 use crate::auth::AuthState; 2 + use crate::components::css::DefaultNotebookCss; 3 use crate::components::{FeedEntryCard, ProfileActions, ProfileActionsMenubar}; 4 use crate::{Route, data, fetch}; 5 use dioxus::prelude::*; ··· 69 } 70 71 const NOTEBOOK_CARD_CSS: Asset = asset!("/assets/styling/notebook-card.css"); 72 + const ENTRY_CSS: Asset = asset!("/assets/styling/entry.css"); 73 + const ENTRY_CARD_CSS: Asset = asset!("/assets/styling/entry-card.css"); 74 75 #[component] 76 pub fn Repository(ident: ReadSignal<AtIdentifier<'static>>) -> Element { 77 rsx! { 78 + DefaultNotebookCss { } 79 + document::Link { rel: "stylesheet", href: NOTEBOOK_CARD_CSS } 80 + document::Link { rel: "stylesheet", href: ENTRY_CSS } 81 + document::Link { rel: "stylesheet", href: ENTRY_CARD_CSS } 82 div { 83 Outlet::<Route> {} 84 } ··· 91 use jacquard::from_data; 92 use weaver_api::sh_weaver::notebook::book::Book; 93 94 + // Use client-only versions to avoid SSR issues with concurrent server futures 95 + let (_profile_res, profile) = data::use_profile_data_client(ident); 96 + let (_notebooks_res, notebooks) = data::use_notebooks_for_did_client(ident); 97 + let (_entries_res, all_entries) = data::use_entries_for_did_client(ident); 98 99 // Extract pinned URIs from profile (only Weaver ProfileView has pinned) 100 let pinned_uris = use_memo(move || { ··· 348 class: "pinned-item", 349 NotebookCard { 350 notebook: notebook.clone(), 351 + entry_refs: entries.clone(), 352 + is_pinned: true 353 } 354 } 355 } ··· 361 class: "pinned-item standalone-entry-item", 362 FeedEntryCard { 363 entry_view: entry_view.clone(), 364 + entry: entry.clone(), 365 + show_actions: true, 366 + is_pinned: true 367 } 368 } 369 } ··· 394 key: "notebook-{notebook.cid}", 395 NotebookCard { 396 notebook: notebook.clone(), 397 + entry_refs: entries.clone(), 398 + is_pinned: false 399 } 400 } 401 } ··· 407 class: "standalone-entry-item", 408 FeedEntryCard { 409 entry_view: entry_view.clone(), 410 + entry: entry.clone(), 411 + show_actions: true, 412 + is_pinned: false 413 } 414 } 415 } ··· 433 pub fn NotebookCard( 434 notebook: NotebookView<'static>, 435 entry_refs: Vec<StrongRef<'static>>, 436 + #[props(default = false)] is_pinned: bool, 437 + #[props(default)] on_pinned_changed: Option<EventHandler<bool>>, 438 + #[props(default)] on_deleted: Option<EventHandler<()>>, 439 ) -> Element { 440 use jacquard::{IntoStatic, from_data}; 441 use weaver_api::sh_weaver::notebook::book::Book; ··· 492 div { class: "notebook-card", 493 div { class: "notebook-card-container", 494 495 + div { class: "notebook-card-header", 496 + div { class: "notebook-card-header-top", 497 + Link { 498 + to: Route::EntryPage { 499 + ident: ident.clone(), 500 + book_title: notebook_path.clone().into(), 501 + title: "".into() // Will redirect to first entry 502 + }, 503 + class: "notebook-card-header-link", 504 h2 { class: "notebook-card-title", "{title}" } 505 + } 506 + if is_owner { 507 + div { class: "notebook-header-actions", 508 Link { 509 to: Route::NewDraft { ident: notebook_ident.clone(), notebook: Some(book_title.clone()) }, 510 + class: "notebook-action-link", 511 + crate::components::button::Button { 512 + variant: crate::components::button::ButtonVariant::Ghost, 513 + "Add" 514 + } 515 + } 516 + crate::components::NotebookActions { 517 + notebook_uri: notebook.uri.clone().into_static(), 518 + notebook_cid: notebook.cid.clone().into_static(), 519 + notebook_title: title.to_string(), 520 + is_pinned, 521 + on_pinned_changed, 522 + on_deleted 523 } 524 } 525 } 526 + } 527 528 + div { class: "notebook-card-date", 529 + time { datetime: "{notebook.indexed_at.as_str()}", "{formatted_date}" } 530 } 531 } 532 ··· 624 if is_owner { 625 crate::components::EntryActions { 626 entry_uri, 627 + entry_cid: entry_view.entry.cid.clone().into_static(), 628 entry_title: entry_title.to_string(), 629 in_notebook: true, 630 notebook_title: Some(book_title.clone()) ··· 695 if is_owner { 696 crate::components::EntryActions { 697 entry_uri, 698 + entry_cid: first_entry.entry.cid.clone().into_static(), 699 entry_title: entry_title.to_string(), 700 in_notebook: true, 701 notebook_title: Some(book_title.clone()) ··· 775 if is_owner { 776 crate::components::EntryActions { 777 entry_uri, 778 + entry_cid: last_entry.entry.cid.clone().into_static(), 779 entry_title: entry_title.to_string(), 780 in_notebook: true, 781 notebook_title: Some(book_title.clone())
+201
crates/weaver-app/src/components/mod.rs
··· 127 .with_hash_suffix(false) 128 .into_asset_options() 129 ); 130 pub mod accordion; 131 pub mod button; 132 pub mod dialog; 133 pub mod editor; 134 pub mod entry_actions; 135 pub mod input; 136 pub mod profile_actions; 137 138 pub use entry_actions::EntryActions; 139 pub use profile_actions::{ProfileActions, ProfileActionsMenubar}; 140 pub mod toast;
··· 127 .with_hash_suffix(false) 128 .into_asset_options() 129 ); 130 + 131 + // Adobe Caslon Pro 132 + #[used] 133 + static _CASLON_REG: Asset = asset!( 134 + "/assets/fonts/adobe-caslon/AdobeCaslonPro-Regular.ttf", 135 + AssetOptions::builder() 136 + .with_hash_suffix(false) 137 + .into_asset_options() 138 + ); 139 + 140 + #[used] 141 + static _CASLON_BOLD: Asset = asset!( 142 + "/assets/fonts/adobe-caslon/AdobeCaslonPro-Bold.ttf", 143 + AssetOptions::builder() 144 + .with_hash_suffix(false) 145 + .into_asset_options() 146 + ); 147 + 148 + #[used] 149 + static _CASLON_ITAL: Asset = asset!( 150 + "/assets/fonts/adobe-caslon/AdobeCaslonPro-Italic.ttf", 151 + AssetOptions::builder() 152 + .with_hash_suffix(false) 153 + .into_asset_options() 154 + ); 155 + 156 + #[used] 157 + static _CASLON_SEMIBOLD: Asset = asset!( 158 + "/assets/fonts/adobe-caslon/AdobeCaslonPro-Semibold.ttf", 159 + AssetOptions::builder() 160 + .with_hash_suffix(false) 161 + .into_asset_options() 162 + ); 163 + 164 + #[used] 165 + static _CASLON_ITAL_BOLD: Asset = asset!( 166 + "/assets/fonts/adobe-caslon/AdobeCaslonPro-BoldItalic.ttf", 167 + AssetOptions::builder() 168 + .with_hash_suffix(false) 169 + .into_asset_options() 170 + ); 171 + 172 + // Latin Modern Roman 173 + #[used] 174 + static _LM_REG: Asset = asset!( 175 + "/assets/fonts/latin-modern/LatinModernRoman-Regular.otf", 176 + AssetOptions::builder() 177 + .with_hash_suffix(false) 178 + .into_asset_options() 179 + ); 180 + #[used] 181 + static _LM_BOLD: Asset = asset!( 182 + "/assets/fonts/latin-modern/LatinModernRoman-Bold.otf", 183 + AssetOptions::builder() 184 + .with_hash_suffix(false) 185 + .into_asset_options() 186 + ); 187 + #[used] 188 + static _LM_ITAL: Asset = asset!( 189 + "/assets/fonts/latin-modern/LatinModernRoman-Italic.otf", 190 + AssetOptions::builder() 191 + .with_hash_suffix(false) 192 + .into_asset_options() 193 + ); 194 + #[used] 195 + static _LM_BOLD_ITAL: Asset = asset!( 196 + "/assets/fonts/latin-modern/LatinModernRoman-BoldItalic.otf", 197 + AssetOptions::builder() 198 + .with_hash_suffix(false) 199 + .into_asset_options() 200 + ); 201 + 202 + // Computer Modern Serif 203 + #[used] 204 + static _CM_SERIF_REG: Asset = asset!( 205 + "/assets/fonts/cm-serif/CMSerif-Regular.woff", 206 + AssetOptions::builder() 207 + .with_hash_suffix(false) 208 + .into_asset_options() 209 + ); 210 + #[used] 211 + static _CM_SERIF_BOLD: Asset = asset!( 212 + "/assets/fonts/cm-serif/CMSerif-Bold.woff", 213 + AssetOptions::builder() 214 + .with_hash_suffix(false) 215 + .into_asset_options() 216 + ); 217 + #[used] 218 + static _CM_SERIF_ITAL: Asset = asset!( 219 + "/assets/fonts/cm-serif/CMSerif-Italic.woff", 220 + AssetOptions::builder() 221 + .with_hash_suffix(false) 222 + .into_asset_options() 223 + ); 224 + #[used] 225 + static _CM_SERIF_BOLD_ITAL: Asset = asset!( 226 + "/assets/fonts/cm-serif/CMSerif-BoldItalic.woff", 227 + AssetOptions::builder() 228 + .with_hash_suffix(false) 229 + .into_asset_options() 230 + ); 231 + 232 + // Computer Modern Sans 233 + #[used] 234 + static _CM_SANS_REG: Asset = asset!( 235 + "/assets/fonts/cm-sans/CMSans-Regular.woff", 236 + AssetOptions::builder() 237 + .with_hash_suffix(false) 238 + .into_asset_options() 239 + ); 240 + #[used] 241 + static _CM_SANS_BOLD: Asset = asset!( 242 + "/assets/fonts/cm-sans/CMSans-Bold.woff", 243 + AssetOptions::builder() 244 + .with_hash_suffix(false) 245 + .into_asset_options() 246 + ); 247 + #[used] 248 + static _CM_SANS_ITAL: Asset = asset!( 249 + "/assets/fonts/cm-sans/CMSans-Italic.woff", 250 + AssetOptions::builder() 251 + .with_hash_suffix(false) 252 + .into_asset_options() 253 + ); 254 + #[used] 255 + static _CM_SANS_BOLD_ITAL: Asset = asset!( 256 + "/assets/fonts/cm-sans/CMSans-BoldItalic.woff", 257 + AssetOptions::builder() 258 + .with_hash_suffix(false) 259 + .into_asset_options() 260 + ); 261 + 262 + // Junction 263 + #[used] 264 + static _JUNCTION_LIGHT: Asset = asset!( 265 + "/assets/fonts/junction/Junction-Light.woff", 266 + AssetOptions::builder() 267 + .with_hash_suffix(false) 268 + .into_asset_options() 269 + ); 270 + #[used] 271 + static _JUNCTION_REG: Asset = asset!( 272 + "/assets/fonts/junction/Junction-Regular.woff", 273 + AssetOptions::builder() 274 + .with_hash_suffix(false) 275 + .into_asset_options() 276 + ); 277 + #[used] 278 + static _JUNCTION_BOLD: Asset = asset!( 279 + "/assets/fonts/junction/Junction-Bold.woff", 280 + AssetOptions::builder() 281 + .with_hash_suffix(false) 282 + .into_asset_options() 283 + ); 284 + 285 + // Proza Libre 286 + #[used] 287 + static _PROZA_REG: Asset = asset!( 288 + "/assets/fonts/proza-libre/ProzaLibre-Regular.woff2", 289 + AssetOptions::builder() 290 + .with_hash_suffix(false) 291 + .into_asset_options() 292 + ); 293 + #[used] 294 + static _PROZA_ITAL: Asset = asset!( 295 + "/assets/fonts/proza-libre/ProzaLibre-Italic.woff2", 296 + AssetOptions::builder() 297 + .with_hash_suffix(false) 298 + .into_asset_options() 299 + ); 300 + #[used] 301 + static _PROZA_MEDIUM: Asset = asset!( 302 + "/assets/fonts/proza-libre/ProzaLibre-Medium.woff2", 303 + AssetOptions::builder() 304 + .with_hash_suffix(false) 305 + .into_asset_options() 306 + ); 307 + #[used] 308 + static _PROZA_SEMIBOLD: Asset = asset!( 309 + "/assets/fonts/proza-libre/ProzaLibre-SemiBold.woff2", 310 + AssetOptions::builder() 311 + .with_hash_suffix(false) 312 + .into_asset_options() 313 + ); 314 + #[used] 315 + static _PROZA_BOLD: Asset = asset!( 316 + "/assets/fonts/proza-libre/ProzaLibre-Bold.woff2", 317 + AssetOptions::builder() 318 + .with_hash_suffix(false) 319 + .into_asset_options() 320 + ); 321 + #[used] 322 + static _PROZA_BOLD_ITAL: Asset = asset!( 323 + "/assets/fonts/proza-libre/ProzaLibre-BoldItalic.woff2", 324 + AssetOptions::builder() 325 + .with_hash_suffix(false) 326 + .into_asset_options() 327 + ); 328 + 329 pub mod accordion; 330 pub mod button; 331 pub mod dialog; 332 pub mod editor; 333 pub mod entry_actions; 334 pub mod input; 335 + pub mod notebook_actions; 336 pub mod profile_actions; 337 338 pub use entry_actions::EntryActions; 339 + pub use notebook_actions::NotebookActions; 340 pub use profile_actions::{ProfileActions, ProfileActionsMenubar}; 341 pub mod toast;
+314
crates/weaver-app/src/components/notebook_actions.rs
···
··· 1 + //! Action buttons for notebooks (pin/unpin, delete). 2 + 3 + use crate::auth::AuthState; 4 + use crate::components::button::{Button, ButtonVariant}; 5 + use crate::components::dialog::{DialogContent, DialogDescription, DialogRoot, DialogTitle}; 6 + use crate::fetch::Fetcher; 7 + use dioxus::prelude::*; 8 + use jacquard::types::aturi::AtUri; 9 + use jacquard::types::ident::AtIdentifier; 10 + use jacquard::types::string::Cid; 11 + use jacquard::IntoStatic; 12 + use weaver_api::com_atproto::repo::delete_record::DeleteRecord; 13 + use weaver_api::com_atproto::repo::put_record::PutRecord; 14 + use weaver_api::com_atproto::repo::strong_ref::StrongRef; 15 + use weaver_api::sh_weaver::actor::profile::Profile as WeaverProfile; 16 + 17 + /// Action buttons for a notebook: pin/unpin, delete. 18 + #[component] 19 + pub fn NotebookActions( 20 + notebook_uri: AtUri<'static>, 21 + notebook_cid: Cid<'static>, 22 + notebook_title: String, 23 + #[props(default = false)] is_pinned: bool, 24 + #[props(default)] on_deleted: Option<EventHandler<()>>, 25 + #[props(default)] on_pinned_changed: Option<EventHandler<bool>>, 26 + ) -> Element { 27 + let auth_state = use_context::<Signal<AuthState>>(); 28 + let fetcher = use_context::<Fetcher>(); 29 + 30 + let mut show_delete_confirm = use_signal(|| false); 31 + let mut show_dropdown = use_signal(|| false); 32 + let mut deleting = use_signal(|| false); 33 + let mut pinning = use_signal(|| false); 34 + let mut error = use_signal(|| None::<String>); 35 + 36 + // Check ownership - compare auth DID with notebook's authority 37 + let current_did = auth_state.read().did.clone(); 38 + let notebook_authority = notebook_uri.authority(); 39 + let is_owner = match (&current_did, notebook_authority) { 40 + (Some(current), AtIdentifier::Did(notebook_did)) => *current == *notebook_did, 41 + _ => false, 42 + }; 43 + 44 + if !is_owner { 45 + return rsx! {}; 46 + } 47 + 48 + let notebook_uri_for_delete = notebook_uri.clone(); 49 + let title_for_display = notebook_title.clone(); 50 + let on_deleted_handler = on_deleted.clone(); 51 + 52 + let delete_fetcher = fetcher.clone(); 53 + let handle_delete = move |_| { 54 + let fetcher = delete_fetcher.clone(); 55 + let uri = notebook_uri_for_delete.clone(); 56 + let on_deleted = on_deleted_handler.clone(); 57 + 58 + spawn(async move { 59 + use jacquard::prelude::*; 60 + 61 + deleting.set(true); 62 + error.set(None); 63 + 64 + let client = fetcher.get_client(); 65 + let collection = uri.collection(); 66 + let rkey = uri.rkey(); 67 + 68 + if let (Some(collection), Some(rkey)) = (collection, rkey) { 69 + let did = match fetcher.current_did().await { 70 + Some(d) => d, 71 + None => { 72 + error.set(Some("Not authenticated".to_string())); 73 + deleting.set(false); 74 + return; 75 + } 76 + }; 77 + 78 + let request = DeleteRecord::new() 79 + .repo(AtIdentifier::Did(did)) 80 + .collection(collection.clone()) 81 + .rkey(rkey.clone()) 82 + .build(); 83 + 84 + match client.send(request).await { 85 + Ok(_) => { 86 + show_delete_confirm.set(false); 87 + if let Some(handler) = &on_deleted { 88 + handler.call(()); 89 + } 90 + } 91 + Err(e) => { 92 + error.set(Some(format!("Delete failed: {:?}", e))); 93 + } 94 + } 95 + } else { 96 + error.set(Some("Invalid notebook URI".to_string())); 97 + } 98 + deleting.set(false); 99 + }); 100 + }; 101 + 102 + // Handler for pinning/unpinning 103 + let notebook_uri_for_pin = notebook_uri.clone(); 104 + let notebook_cid_for_pin = notebook_cid.clone(); 105 + let is_currently_pinned = is_pinned; 106 + let on_pinned_changed_handler = on_pinned_changed.clone(); 107 + let pin_fetcher = fetcher.clone(); 108 + let handle_pin_toggle = move |_| { 109 + let fetcher = pin_fetcher.clone(); 110 + let notebook_uri = notebook_uri_for_pin.clone(); 111 + let notebook_cid = notebook_cid_for_pin.clone(); 112 + let on_pinned_changed = on_pinned_changed_handler.clone(); 113 + 114 + spawn(async move { 115 + use jacquard::{from_data, prelude::*, to_data, types::string::Nsid}; 116 + use weaver_api::app_bsky::actor::profile::Profile as BskyProfile; 117 + 118 + pinning.set(true); 119 + error.set(None); 120 + 121 + let client = fetcher.get_client(); 122 + 123 + let did = match fetcher.current_did().await { 124 + Some(d) => d, 125 + None => { 126 + error.set(Some("Not authenticated".to_string())); 127 + pinning.set(false); 128 + return; 129 + } 130 + }; 131 + 132 + let profile_uri_str = format!("at://{}/sh.weaver.actor.profile/self", did); 133 + 134 + // Try to fetch existing weaver profile 135 + let weaver_uri = match WeaverProfile::uri(&profile_uri_str) { 136 + Ok(u) => u, 137 + Err(_) => { 138 + error.set(Some("Invalid profile URI".to_string())); 139 + pinning.set(false); 140 + return; 141 + } 142 + }; 143 + let existing_profile: Option<WeaverProfile<'static>> = 144 + match client.fetch_record(&weaver_uri).await { 145 + Ok(output) => Some(output.value), 146 + Err(_) => None, 147 + }; 148 + 149 + // Build the new pinned list 150 + let new_pinned: Vec<StrongRef<'static>> = if is_currently_pinned { 151 + // Unpin: remove from list 152 + existing_profile 153 + .as_ref() 154 + .and_then(|p| p.pinned.as_ref()) 155 + .map(|pins| { 156 + pins.iter() 157 + .filter(|r| r.uri.as_ref() != notebook_uri.as_ref()) 158 + .cloned() 159 + .collect() 160 + }) 161 + .unwrap_or_default() 162 + } else { 163 + // Pin: add to list 164 + let new_ref = StrongRef::new() 165 + .uri(notebook_uri.clone().into_static()) 166 + .cid(notebook_cid.clone()) 167 + .build(); 168 + let mut pins = existing_profile 169 + .as_ref() 170 + .and_then(|p| p.pinned.clone()) 171 + .unwrap_or_default(); 172 + // Don't add if already exists 173 + if !pins.iter().any(|r| r.uri.as_ref() == notebook_uri.as_ref()) { 174 + pins.push(new_ref); 175 + } 176 + pins 177 + }; 178 + 179 + // Build the profile to save 180 + let profile_to_save = if let Some(existing) = existing_profile { 181 + // Update existing profile 182 + WeaverProfile { 183 + pinned: Some(new_pinned), 184 + ..existing 185 + } 186 + } else { 187 + // Create new profile from bsky data 188 + let bsky_uri_str = format!("at://{}/app.bsky.actor.profile/self", did); 189 + let bsky_profile: Option<BskyProfile<'static>> = 190 + match BskyProfile::uri(&bsky_uri_str) { 191 + Ok(bsky_uri) => match client.fetch_record(&bsky_uri).await { 192 + Ok(output) => Some(output.value), 193 + Err(_) => None, 194 + }, 195 + Err(_) => None, 196 + }; 197 + 198 + WeaverProfile::new() 199 + .maybe_display_name( 200 + bsky_profile 201 + .as_ref() 202 + .and_then(|p| p.display_name.clone()), 203 + ) 204 + .maybe_description( 205 + bsky_profile.as_ref().and_then(|p| p.description.clone()), 206 + ) 207 + .maybe_avatar(bsky_profile.as_ref().and_then(|p| p.avatar.clone())) 208 + .maybe_banner(bsky_profile.as_ref().and_then(|p| p.banner.clone())) 209 + .bluesky(true) 210 + .created_at(jacquard::types::string::Datetime::now()) 211 + .pinned(new_pinned) 212 + .build() 213 + }; 214 + 215 + // Serialize and save 216 + let profile_data = match to_data(&profile_to_save) { 217 + Ok(d) => d, 218 + Err(e) => { 219 + error.set(Some(format!("Failed to serialize profile: {:?}", e))); 220 + pinning.set(false); 221 + return; 222 + } 223 + }; 224 + 225 + let request = PutRecord::new() 226 + .repo(AtIdentifier::Did(did)) 227 + .collection(Nsid::new_static("sh.weaver.actor.profile").unwrap()) 228 + .rkey(jacquard::types::string::Rkey::new("self").unwrap()) 229 + .record(profile_data) 230 + .build(); 231 + 232 + match client.send(request).await { 233 + Ok(_) => { 234 + show_dropdown.set(false); 235 + if let Some(handler) = &on_pinned_changed { 236 + handler.call(!is_currently_pinned); 237 + } 238 + } 239 + Err(e) => { 240 + error.set(Some(format!("Failed to update profile: {:?}", e))); 241 + } 242 + } 243 + pinning.set(false); 244 + }); 245 + }; 246 + 247 + rsx! { 248 + div { class: "notebook-actions", 249 + // Dropdown for actions 250 + div { class: "notebook-actions-dropdown", 251 + Button { 252 + variant: ButtonVariant::Ghost, 253 + onclick: move |_| show_dropdown.toggle(), 254 + "⋮" 255 + } 256 + 257 + if show_dropdown() { 258 + div { class: "dropdown-menu", 259 + // Pin/Unpin (first) 260 + button { 261 + class: "dropdown-item", 262 + disabled: pinning(), 263 + onclick: handle_pin_toggle, 264 + if pinning() { 265 + "Updating..." 266 + } else if is_pinned { 267 + "Unpin" 268 + } else { 269 + "Pin" 270 + } 271 + } 272 + // Delete (danger style) 273 + button { 274 + class: "dropdown-item dropdown-item-danger", 275 + onclick: move |_| { 276 + show_dropdown.set(false); 277 + show_delete_confirm.set(true); 278 + }, 279 + "Delete" 280 + } 281 + } 282 + } 283 + } 284 + 285 + // Delete confirmation dialog 286 + DialogRoot { 287 + open: show_delete_confirm(), 288 + on_open_change: move |open: bool| show_delete_confirm.set(open), 289 + DialogContent { 290 + DialogTitle { "Delete Notebook?" } 291 + DialogDescription { 292 + "Delete \"{title_for_display}\"? The entries will remain but will no longer be part of this notebook." 293 + } 294 + if let Some(ref err) = error() { 295 + div { class: "dialog-error", "{err}" } 296 + } 297 + div { class: "dialog-actions", 298 + Button { 299 + variant: ButtonVariant::Destructive, 300 + onclick: handle_delete, 301 + disabled: deleting(), 302 + if deleting() { "Deleting..." } else { "Delete" } 303 + } 304 + Button { 305 + variant: ButtonVariant::Ghost, 306 + onclick: move |_| show_delete_confirm.set(false), 307 + "Cancel" 308 + } 309 + } 310 + } 311 + } 312 + } 313 + } 314 + }
+83 -2
crates/weaver-app/src/data.rs
··· 543 Memo<Option<ProfileDataView<'static>>>, 544 ) { 545 let fetcher = use_context::<crate::fetch::Fetcher>(); 546 - let res = use_resource(use_reactive!(|ident| { 547 let fetcher = fetcher.clone(); 548 async move { 549 fetcher ··· 555 } 556 })); 557 let memo = use_memo(use_reactive!(|res| { 558 if let Some(Some(value)) = &*res.read() { 559 jacquard::from_json_value::<ProfileDataView>(value.clone()).ok() 560 } else { 561 None 562 } 563 })); 564 - (Ok(res), memo) 565 } 566 567 /// Fetches profile data client-side only (no SSR) ··· 707 /// Fetches all entries for a specific DID client-side only (no SSR) 708 #[cfg(not(feature = "fullstack-server"))] 709 pub fn use_entries_for_did( 710 ident: ReadSignal<AtIdentifier<'static>>, 711 ) -> ( 712 Resource<Option<Vec<(EntryView<'static>, Entry<'static>)>>>,
··· 543 Memo<Option<ProfileDataView<'static>>>, 544 ) { 545 let fetcher = use_context::<crate::fetch::Fetcher>(); 546 + let res = use_server_future(use_reactive!(|ident| { 547 let fetcher = fetcher.clone(); 548 async move { 549 fetcher ··· 555 } 556 })); 557 let memo = use_memo(use_reactive!(|res| { 558 + let res = res.as_ref().ok()?; 559 if let Some(Some(value)) = &*res.read() { 560 jacquard::from_json_value::<ProfileDataView>(value.clone()).ok() 561 } else { 562 None 563 } 564 })); 565 + (res, memo) 566 } 567 568 /// Fetches profile data client-side only (no SSR) ··· 708 /// Fetches all entries for a specific DID client-side only (no SSR) 709 #[cfg(not(feature = "fullstack-server"))] 710 pub fn use_entries_for_did( 711 + ident: ReadSignal<AtIdentifier<'static>>, 712 + ) -> ( 713 + Resource<Option<Vec<(EntryView<'static>, Entry<'static>)>>>, 714 + Memo<Option<Vec<(EntryView<'static>, Entry<'static>)>>>, 715 + ) { 716 + let fetcher = use_context::<crate::fetch::Fetcher>(); 717 + let res = use_resource(move || { 718 + let fetcher = fetcher.clone(); 719 + async move { 720 + fetcher 721 + .fetch_entries_for_did(&ident()) 722 + .await 723 + .ok() 724 + .map(|entries| { 725 + entries 726 + .iter() 727 + .map(|arc| arc.as_ref().clone()) 728 + .collect::<Vec<_>>() 729 + }) 730 + } 731 + }); 732 + let memo = use_memo(move || res.read().clone().flatten()); 733 + (res, memo) 734 + } 735 + 736 + // ============================================================================ 737 + // Client-only versions (bypass SSR issues on profile page) 738 + // ============================================================================ 739 + 740 + /// Fetches profile data client-side only - use when SSR causes issues 741 + pub fn use_profile_data_client( 742 + ident: ReadSignal<AtIdentifier<'static>>, 743 + ) -> ( 744 + Resource<Option<ProfileDataView<'static>>>, 745 + Memo<Option<ProfileDataView<'static>>>, 746 + ) { 747 + let fetcher = use_context::<crate::fetch::Fetcher>(); 748 + let res = use_resource(move || { 749 + let fetcher = fetcher.clone(); 750 + async move { 751 + fetcher 752 + .fetch_profile(&ident()) 753 + .await 754 + .ok() 755 + .map(|arc| (*arc).clone()) 756 + } 757 + }); 758 + let memo = use_memo(move || res.read().clone().flatten()); 759 + (res, memo) 760 + } 761 + 762 + /// Fetches notebooks client-side only - use when SSR causes issues 763 + pub fn use_notebooks_for_did_client( 764 + ident: ReadSignal<AtIdentifier<'static>>, 765 + ) -> ( 766 + Resource<Option<Vec<(NotebookView<'static>, Vec<StrongRef<'static>>)>>>, 767 + Memo<Option<Vec<(NotebookView<'static>, Vec<StrongRef<'static>>)>>>, 768 + ) { 769 + let fetcher = use_context::<crate::fetch::Fetcher>(); 770 + let res = use_resource(move || { 771 + let fetcher = fetcher.clone(); 772 + async move { 773 + fetcher 774 + .fetch_notebooks_for_did(&ident()) 775 + .await 776 + .ok() 777 + .map(|notebooks| { 778 + notebooks 779 + .iter() 780 + .map(|arc| arc.as_ref().clone()) 781 + .collect::<Vec<_>>() 782 + }) 783 + } 784 + }); 785 + let memo = use_memo(move || res.read().clone().flatten()); 786 + (res, memo) 787 + } 788 + 789 + /// Fetches all entries client-side only - use when SSR causes issues 790 + pub fn use_entries_for_did_client( 791 ident: ReadSignal<AtIdentifier<'static>>, 792 ) -> ( 793 Resource<Option<Vec<(EntryView<'static>, Entry<'static>)>>>,
+18 -9
crates/weaver-app/src/fetch.rs
··· 92 request: http::Request<Vec<u8>>, 93 ) -> impl Future<Output = core::result::Result<http::Response<Vec<u8>>, Self::Error>> + Send 94 { 95 - self.oauth_client.client.send_http(request) 96 } 97 98 #[cfg(target_arch = "wasm32")] ··· 100 &self, 101 request: http::Request<Vec<u8>>, 102 ) -> impl Future<Output = core::result::Result<http::Response<Vec<u8>>, Self::Error>> { 103 - self.oauth_client.client.send_http(request) 104 } 105 } 106 ··· 661 }; 662 663 // Fetch all notebook records for this repo 664 let resp = client 665 - .xrpc(pds_url) 666 .send( 667 &ListRecords::new() 668 .repo(repo_did) ··· 671 .build(), 672 ) 673 .await 674 - .map_err(|e| dioxus::CapturedError::from_display(e))?; 675 676 let mut notebooks = Vec::new(); 677 ··· 769 if let Ok(list) = resp.parse() { 770 for record in list.records { 771 // Extract rkey from URI 772 - let rkey = record 773 - .uri 774 - .rkey() 775 - .map(|r| r.0.as_str()) 776 - .unwrap_or_default(); 777 778 // Fetch the entry with hydration 779 match client.fetch_entry_by_rkey(&ident_static, rkey).await {
··· 92 request: http::Request<Vec<u8>>, 93 ) -> impl Future<Output = core::result::Result<http::Response<Vec<u8>>, Self::Error>> + Send 94 { 95 + self.oauth_client.send_http(request) 96 } 97 98 #[cfg(target_arch = "wasm32")] ··· 100 &self, 101 request: http::Request<Vec<u8>>, 102 ) -> impl Future<Output = core::result::Result<http::Response<Vec<u8>>, Self::Error>> { 103 + self.oauth_client.send_http(request) 104 } 105 } 106 ··· 661 }; 662 663 // Fetch all notebook records for this repo 664 + tracing::info!( 665 + "fetch_notebooks_for_did: pds_url={}, repo_did={}", 666 + pds_url, 667 + repo_did 668 + ); 669 + 670 let resp = client 671 + .xrpc(pds_url.clone()) 672 .send( 673 &ListRecords::new() 674 .repo(repo_did) ··· 677 .build(), 678 ) 679 .await 680 + .map_err(|e| { 681 + tracing::error!( 682 + "fetch_notebooks_for_did: xrpc failed: {} pds url {}", 683 + e, 684 + pds_url 685 + ); 686 + dioxus::CapturedError::from_display(e) 687 + })?; 688 689 let mut notebooks = Vec::new(); 690 ··· 782 if let Ok(list) = resp.parse() { 783 for record in list.records { 784 // Extract rkey from URI 785 + let rkey = record.uri.rkey().map(|r| r.0.as_str()).unwrap_or_default(); 786 787 // Fetch the entry with hydration 788 match client.fetch_entry_by_rkey(&ident_static, rkey).await {
-1
crates/weaver-common/Cargo.toml
··· 26 tracing = { workspace = true } 27 reqwest = { version = "0.12", default-features = false, features = [ 28 "json", 29 - "rustls-tls", 30 ] } 31 32 markdown-weaver-escape = { workspace = true, features = ["std"] }
··· 26 tracing = { workspace = true } 27 reqwest = { version = "0.12", default-features = false, features = [ 28 "json", 29 ] } 30 31 markdown-weaver-escape = { workspace = true, features = ["std"] }
+72 -130
crates/weaver-common/src/agent.rs
··· 737 AgentError::from(ClientError::invalid_request("Invalid weaver profile URI")) 738 }, 739 )?; 740 - // let weaver_future = async { 741 - // if let Ok(weaver_record) = self.fetch_record(&weaver_uri).await { 742 - // // Convert blobs to CDN URLs 743 - // let avatar = weaver_record 744 - // .value 745 - // .avatar 746 - // .as_ref() 747 - // .map(|blob| { 748 - // let cid = blob.blob().cid(); 749 - // jacquard::types::string::Uri::new_owned(format!( 750 - // "https://cdn.bsky.app/img/avatar/plain/{}/{}@jpeg", 751 - // did, cid 752 - // )) 753 - // }) 754 - // .transpose() 755 - // .map_err(|_| { 756 - // AgentError::from(ClientError::invalid_request("Invalid avatar URI")) 757 - // })?; 758 - // let banner = weaver_record 759 - // .value 760 - // .banner 761 - // .as_ref() 762 - // .map(|blob| { 763 - // let cid = blob.blob().cid(); 764 - // jacquard::types::string::Uri::new_owned(format!( 765 - // "https://cdn.bsky.app/img/banner/plain/{}/{}@jpeg", 766 - // did, cid 767 - // )) 768 - // }) 769 - // .transpose() 770 - // .map_err(|_| { 771 - // AgentError::from(ClientError::invalid_request("Invalid banner URI")) 772 - // })?; 773 774 - // let profile_view = ProfileView::new() 775 - // .did(did.clone()) 776 - // .handle(handle.clone()) 777 - // .maybe_display_name(weaver_record.value.display_name.clone()) 778 - // .maybe_description(weaver_record.value.description.clone()) 779 - // .maybe_avatar(avatar) 780 - // .maybe_banner(banner) 781 - // .maybe_bluesky(weaver_record.value.bluesky) 782 - // .maybe_tangled(weaver_record.value.tangled) 783 - // .maybe_streamplace(weaver_record.value.streamplace) 784 - // .maybe_location(weaver_record.value.location.clone()) 785 - // .maybe_links(weaver_record.value.links.clone()) 786 - // .maybe_pronouns(weaver_record.value.pronouns.clone()) 787 - // .maybe_pinned(weaver_record.value.pinned.clone()) 788 - // .indexed_at(jacquard::types::string::Datetime::now()) 789 - // .maybe_created_at(weaver_record.value.created_at) 790 - // .build(); 791 792 - // Ok(( 793 - // Some(weaver_uri.as_uri().clone().into_static()), 794 - // ProfileDataView::new() 795 - // .inner(ProfileDataViewInner::ProfileView(Box::new(profile_view))) 796 - // .build() 797 - // .into_static(), 798 - // )) 799 - // } else { 800 - // Err(WeaverError::Agent( 801 - // ClientError::invalid_request("Invalid weaver profile URI").into(), 802 - // )) 803 - // } 804 - // }; 805 let bsky_appview_future = async { 806 if let Ok(bsky_resp) = self 807 .send(GetProfile::new().actor(did.clone()).build()) ··· 835 )) 836 } 837 }; 838 - // Fallback: fetch bsky profile record directly and construct minimal ProfileViewDetailed 839 - let bsky_uri = BskyProfile::uri(format!("at://{}/app.bsky.actor.profile/self", did)) 840 - .map_err(|_| { 841 - AgentError::from(ClientError::invalid_request("Invalid bsky profile URI")) 842 - })?; 843 844 - // let bsky_future = async { 845 - // let bsky_record = self.fetch_record(&bsky_uri).await?; 846 - 847 - // let avatar = bsky_record 848 - // .value 849 - // .avatar 850 - // .as_ref() 851 - // .map(|blob| { 852 - // let cid = blob.blob().cid(); 853 - // jacquard::types::string::Uri::new_owned(format!( 854 - // "https://cdn.bsky.app/img/avatar/plain/{}/{}@jpeg", 855 - // did, cid 856 - // )) 857 - // }) 858 - // .transpose() 859 - // .map_err(|_| { 860 - // AgentError::from(ClientError::invalid_request("Invalid avatar URI")) 861 - // })?; 862 - // let banner = bsky_record 863 - // .value 864 - // .banner 865 - // .as_ref() 866 - // .map(|blob| { 867 - // let cid = blob.blob().cid(); 868 - // jacquard::types::string::Uri::new_owned(format!( 869 - // "https://cdn.bsky.app/img/banner/plain/{}/{}@jpeg", 870 - // did, cid 871 - // )) 872 - // }) 873 - // .transpose() 874 - // .map_err(|_| { 875 - // AgentError::from(ClientError::invalid_request("Invalid banner URI")) 876 - // })?; 877 - 878 - // let profile_detailed = ProfileViewDetailed::new() 879 - // .did(did.clone()) 880 - // .handle(handle.clone()) 881 - // .maybe_display_name(bsky_record.value.display_name.clone()) 882 - // .maybe_description(bsky_record.value.description.clone()) 883 - // .maybe_avatar(avatar) 884 - // .maybe_banner(banner) 885 - // .indexed_at(jacquard::types::string::Datetime::now()) 886 - // .maybe_created_at(bsky_record.value.created_at) 887 - // .build(); 888 - 889 - // Ok(( 890 - // Some(bsky_uri.as_uri().clone().into_static()), 891 - // ProfileDataView::new() 892 - // .inner(ProfileDataViewInner::ProfileViewDetailed(Box::new( 893 - // profile_detailed, 894 - // ))) 895 - // .build() 896 - // .into_static(), 897 - // )) 898 - // }; 899 - 900 - // n0_future::future::or( 901 - // weaver_future, 902 - // n0_future::future::or(bsky_appview_future, bsky_future), 903 - // ) 904 - // .await 905 - bsky_appview_future.await 906 } 907 } 908
··· 737 AgentError::from(ClientError::invalid_request("Invalid weaver profile URI")) 738 }, 739 )?; 740 + let weaver_future = async { 741 + if let Ok(weaver_record) = self.fetch_record(&weaver_uri).await { 742 + // Convert blobs to CDN URLs 743 + let avatar = weaver_record 744 + .value 745 + .avatar 746 + .as_ref() 747 + .map(|blob| { 748 + let cid = blob.blob().cid(); 749 + jacquard::types::string::Uri::new_owned(format!( 750 + "https://cdn.bsky.app/img/avatar/plain/{}/{}@jpeg", 751 + did, cid 752 + )) 753 + }) 754 + .transpose() 755 + .map_err(|_| { 756 + AgentError::from(ClientError::invalid_request("Invalid avatar URI")) 757 + })?; 758 + let banner = weaver_record 759 + .value 760 + .banner 761 + .as_ref() 762 + .map(|blob| { 763 + let cid = blob.blob().cid(); 764 + jacquard::types::string::Uri::new_owned(format!( 765 + "https://cdn.bsky.app/img/banner/plain/{}/{}@jpeg", 766 + did, cid 767 + )) 768 + }) 769 + .transpose() 770 + .map_err(|_| { 771 + AgentError::from(ClientError::invalid_request("Invalid banner URI")) 772 + })?; 773 774 + let profile_view = ProfileView::new() 775 + .did(did.clone()) 776 + .handle(handle.clone()) 777 + .maybe_display_name(weaver_record.value.display_name.clone()) 778 + .maybe_description(weaver_record.value.description.clone()) 779 + .maybe_avatar(avatar) 780 + .maybe_banner(banner) 781 + .maybe_bluesky(weaver_record.value.bluesky) 782 + .maybe_tangled(weaver_record.value.tangled) 783 + .maybe_streamplace(weaver_record.value.streamplace) 784 + .maybe_location(weaver_record.value.location.clone()) 785 + .maybe_links(weaver_record.value.links.clone()) 786 + .maybe_pronouns(weaver_record.value.pronouns.clone()) 787 + .maybe_pinned(weaver_record.value.pinned.clone()) 788 + .indexed_at(jacquard::types::string::Datetime::now()) 789 + .maybe_created_at(weaver_record.value.created_at) 790 + .build(); 791 792 + Ok(( 793 + Some(weaver_uri.as_uri().clone().into_static()), 794 + ProfileDataView::new() 795 + .inner(ProfileDataViewInner::ProfileView(Box::new(profile_view))) 796 + .build() 797 + .into_static(), 798 + )) 799 + } else { 800 + Err(WeaverError::Agent( 801 + ClientError::invalid_request("Invalid weaver profile URI").into(), 802 + )) 803 + } 804 + }; 805 let bsky_appview_future = async { 806 if let Ok(bsky_resp) = self 807 .send(GetProfile::new().actor(did.clone()).build()) ··· 835 )) 836 } 837 }; 838 839 + if let Ok((profile_uri, weaver_profileview)) = weaver_future.await { 840 + return Ok((profile_uri, weaver_profileview)); 841 + } else if let Ok((profile_uri, bsky_profileview)) = bsky_appview_future.await { 842 + return Ok((profile_uri, bsky_profileview)); 843 + } else { 844 + Err(WeaverError::Agent(AgentError::from( 845 + ClientError::invalid_request("couldn't fetch profile"), 846 + ))) 847 + } 848 } 849 } 850
+4 -2
crates/weaver-renderer/src/css.rs
··· 87 font-family: var(--font-body); 88 color: var(--color-text); 89 background-color: var(--color-base); 90 - max-width: 90ch; 91 margin: 0 auto; 92 - padding: 2rem 1rem; 93 word-wrap: break-word; 94 overflow-wrap: break-word; 95 }} ··· 187 188 /* Code blocks inside pre are handled by syntax theme */ 189 pre code {{ 190 display: block; 191 width: fit-content; 192 min-width: 100%; ··· 652 .embed-fields {{ 653 display: block; 654 margin-top: 0.5rem; 655 font-size: 0.85rem; 656 color: var(--color-muted); 657 }} ··· 844 padding: 0.5rem; 845 text-align: center; 846 font-size: 0.85em; 847 color: var(--color-muted); 848 background: var(--color-overlay); 849 border-top: 1px solid var(--color-border);
··· 87 font-family: var(--font-body); 88 color: var(--color-text); 89 background-color: var(--color-base); 90 margin: 0 auto; 91 + padding: 1rem 0rem; 92 word-wrap: break-word; 93 overflow-wrap: break-word; 94 }} ··· 186 187 /* Code blocks inside pre are handled by syntax theme */ 188 pre code {{ 189 + 190 display: block; 191 width: fit-content; 192 min-width: 100%; ··· 652 .embed-fields {{ 653 display: block; 654 margin-top: 0.5rem; 655 + font-family: var(--font-ui); 656 font-size: 0.85rem; 657 color: var(--color-muted); 658 }} ··· 845 padding: 0.5rem; 846 text-align: center; 847 font-size: 0.85em; 848 + font-family: var(--font-ui); 849 color: var(--color-muted); 850 background: var(--color-overlay); 851 border-top: 1px solid var(--color-border);
+11 -7
crates/weaver-renderer/src/theme.rs
··· 53 54 pub fn default_fonts() -> ThemeFonts<'static> { 55 ThemeFonts { 56 body: CowStr::new_static( 57 - "'IBM Plex', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif", 58 ), 59 heading: CowStr::new_static( 60 - "'IBM Plex Sans', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif", 61 ), 62 monospace: CowStr::new_static( 63 - "'Ioskeley Mono', 'IBM Plex Mono', 'Berkeley Mono', 'Cascadia Code', 'Roboto Mono', Consolas, monospace", 64 ), 65 ..Default::default() 66 } ··· 76 } 77 78 pub fn default_colour_scheme_light() -> ColourSchemeColours<'static> { 79 ColourSchemeColours { 80 base: CowStr::new_static("#faf4ed"), 81 surface: CowStr::new_static("#fffaf3"), 82 overlay: CowStr::new_static("#f2e9e1"), 83 - text: CowStr::new_static("#575279"), 84 - muted: CowStr::new_static("#9893a5"), 85 - subtle: CowStr::new_static("#797593"), 86 - emphasis: CowStr::new_static("#575279"), 87 primary: CowStr::new_static("#907aa9"), 88 secondary: CowStr::new_static("#56949f"), 89 tertiary: CowStr::new_static("#286983"),
··· 53 54 pub fn default_fonts() -> ThemeFonts<'static> { 55 ThemeFonts { 56 + // Serif for body text, sans for headings/UI 57 body: CowStr::new_static( 58 + "'Adobe Caslon Pro', 'Latin Modern Roman', 'CM Serif', Georgia, serif", 59 ), 60 heading: CowStr::new_static( 61 + "'IBM Plex Sans', 'CM Sans','Junction', 'Proza Libre', system-ui, sans-serif", 62 ), 63 monospace: CowStr::new_static( 64 + "'Ioskeley Mono', 'IBM Plex Mono', 'Berkeley Mono', Consolas, monospace", 65 ), 66 ..Default::default() 67 } ··· 77 } 78 79 pub fn default_colour_scheme_light() -> ColourSchemeColours<'static> { 80 + // Rose Pine Dawn with moderate contrast text (text/muted/subtle/emphasis darkened) 81 ColourSchemeColours { 82 base: CowStr::new_static("#faf4ed"), 83 surface: CowStr::new_static("#fffaf3"), 84 overlay: CowStr::new_static("#f2e9e1"), 85 + // Text colors darkened for better contrast 86 + text: CowStr::new_static("#1f1d2e"), 87 + muted: CowStr::new_static("#635e74"), 88 + subtle: CowStr::new_static("#4a4560"), 89 + emphasis: CowStr::new_static("#1e1a2d"), 90 + // Accent colors kept at original Rose Pine Dawn values 91 primary: CowStr::new_static("#907aa9"), 92 secondary: CowStr::new_static("#56949f"), 93 tertiary: CowStr::new_static("#286983"),