a tool for shared writing and social publishing

support adding theme to standalone published docs

+198 -34
+152
actions/publishToPublication.ts
··· 44 44 import { List, parseBlocksToList } from "src/utils/parseBlocksToList"; 45 45 import { getBlocksWithTypeLocal } from "src/hooks/queries/useBlocks"; 46 46 import { Lock } from "src/utils/lock"; 47 + import type { PubLeafletPublication } from "lexicons/api"; 47 48 48 49 export async function publishToPublication({ 49 50 root_entity, ··· 108 109 109 110 let existingRecord = 110 111 (draft?.documents?.data as PubLeafletDocument.Record | undefined) || {}; 112 + 113 + // Extract theme for standalone documents (not for publications) 114 + let theme: PubLeafletPublication.Theme | undefined; 115 + if (!publication_uri) { 116 + theme = await extractThemeFromFacts(facts, root_entity, agent); 117 + } 118 + 111 119 let record: PubLeafletDocument.Record = { 112 120 $type: "pub.leaflet.document", 113 121 author: credentialSession.did!, 114 122 ...(publication_uri && { publication: publication_uri }), 123 + ...(theme && { theme }), 115 124 publishedAt: new Date().toISOString(), 116 125 ...existingRecord, 117 126 title: title || "Untitled", ··· 606 615 ? never 607 616 : T /* maybe literal, not the whole `string` */ 608 617 : T; /* not a string */ 618 + 619 + async function extractThemeFromFacts( 620 + facts: Fact<any>[], 621 + root_entity: string, 622 + agent: AtpBaseClient, 623 + ): Promise<PubLeafletPublication.Theme | undefined> { 624 + let scan = scanIndexLocal(facts); 625 + 626 + let pageBackground = scan.eav(root_entity, "theme/page-background")?.[0]?.data 627 + .value; 628 + let cardBackground = scan.eav(root_entity, "theme/card-background")?.[0]?.data 629 + .value; 630 + let primary = scan.eav(root_entity, "theme/primary")?.[0]?.data.value; 631 + let accentBackground = scan.eav(root_entity, "theme/accent-background")?.[0] 632 + ?.data.value; 633 + let accentText = scan.eav(root_entity, "theme/accent-text")?.[0]?.data.value; 634 + let showPageBackground = !scan.eav( 635 + root_entity, 636 + "theme/card-border-hidden", 637 + )?.[0]?.data.value; 638 + let backgroundImage = scan.eav(root_entity, "theme/background-image")?.[0]; 639 + let backgroundImageRepeat = scan.eav( 640 + root_entity, 641 + "theme/background-image-repeat", 642 + )?.[0]; 643 + 644 + // Helper to convert hex/hsba color string to RGB/RGBA object 645 + const parseColorToRGB = ( 646 + colorStr: string, 647 + ): 648 + | { $type: "pub.leaflet.theme.color#rgb"; r: number; g: number; b: number } 649 + | { 650 + $type: "pub.leaflet.theme.color#rgba"; 651 + r: number; 652 + g: number; 653 + b: number; 654 + a: number; 655 + } 656 + | undefined => { 657 + // Try hex format first: #RRGGBB 658 + const hexMatch = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(colorStr); 659 + if (hexMatch) { 660 + return { 661 + $type: "pub.leaflet.theme.color#rgb" as const, 662 + r: parseInt(hexMatch[1], 16), 663 + g: parseInt(hexMatch[2], 16), 664 + b: parseInt(hexMatch[3], 16), 665 + }; 666 + } 667 + 668 + // Try hsba format: hsba(h, s%, b%, a) 669 + const hsbaMatch = 670 + /^hsba\((\d+),\s*(\d+)%,\s*(\d+)%,\s*(\d+(?:\.\d+)?)\)$/i.exec(colorStr); 671 + if (hsbaMatch) { 672 + const h = parseInt(hsbaMatch[1]); 673 + const s = parseInt(hsbaMatch[2]) / 100; 674 + const b = parseInt(hsbaMatch[3]) / 100; 675 + const a = Math.round(parseFloat(hsbaMatch[4]) * 100); 676 + 677 + // Convert HSB to RGB 678 + const c = b * s; 679 + const x = c * (1 - Math.abs(((h / 60) % 2) - 1)); 680 + const m = b - c; 681 + 682 + let r = 0, 683 + g = 0, 684 + bl = 0; 685 + if (h >= 0 && h < 60) { 686 + r = c; 687 + g = x; 688 + bl = 0; 689 + } else if (h >= 60 && h < 120) { 690 + r = x; 691 + g = c; 692 + bl = 0; 693 + } else if (h >= 120 && h < 180) { 694 + r = 0; 695 + g = c; 696 + bl = x; 697 + } else if (h >= 180 && h < 240) { 698 + r = 0; 699 + g = x; 700 + bl = c; 701 + } else if (h >= 240 && h < 300) { 702 + r = x; 703 + g = 0; 704 + bl = c; 705 + } else { 706 + r = c; 707 + g = 0; 708 + bl = x; 709 + } 710 + 711 + return { 712 + $type: "pub.leaflet.theme.color#rgba" as const, 713 + r: Math.round((r + m) * 255), 714 + g: Math.round((g + m) * 255), 715 + b: Math.round((bl + m) * 255), 716 + a, 717 + }; 718 + } 719 + 720 + return undefined; 721 + }; 722 + 723 + let theme: PubLeafletPublication.Theme = { 724 + showPageBackground: showPageBackground ?? true, 725 + }; 726 + 727 + if (pageBackground) theme.backgroundColor = parseColorToRGB(pageBackground); 728 + if (cardBackground) theme.pageBackground = parseColorToRGB(cardBackground); 729 + if (primary) theme.primary = parseColorToRGB(primary); 730 + if (accentBackground) 731 + theme.accentBackground = parseColorToRGB(accentBackground); 732 + if (accentText) theme.accentText = parseColorToRGB(accentText); 733 + 734 + // Upload background image if present 735 + if (backgroundImage?.data) { 736 + let imageData = await fetch(backgroundImage.data.src); 737 + if (imageData.status === 200) { 738 + let binary = await imageData.blob(); 739 + let blob = await agent.com.atproto.repo.uploadBlob(binary, { 740 + headers: { "Content-Type": binary.type }, 741 + }); 742 + 743 + theme.backgroundImage = { 744 + $type: "pub.leaflet.theme.backgroundImage", 745 + image: blob.data.blob, 746 + repeat: backgroundImageRepeat?.data.value ? true : false, 747 + ...(backgroundImageRepeat?.data.value && { 748 + width: backgroundImageRepeat.data.value, 749 + }), 750 + }; 751 + } 752 + } 753 + 754 + // Only return theme if at least one property is set 755 + if (Object.keys(theme).length > 1 || theme.showPageBackground !== true) { 756 + return theme; 757 + } 758 + 759 + return undefined; 760 + }
+1 -1
app/(home-pages)/discover/PubListing.tsx
··· 16 16 }, 17 17 ) => { 18 18 let record = props.record as PubLeafletPublication.Record; 19 - let theme = usePubTheme(record); 19 + let theme = usePubTheme(record.theme); 20 20 let backgroundImage = record?.theme?.backgroundImage?.image?.ref 21 21 ? blobRefToSrc( 22 22 record?.theme?.backgroundImage?.image?.ref,
+1 -1
app/(home-pages)/reader/ReaderContent.tsx
··· 102 102 let postRecord = props.documents.data as PubLeafletDocument.Record; 103 103 let postUri = new AtUri(props.documents.uri); 104 104 105 - let theme = usePubTheme(pubRecord); 105 + let theme = usePubTheme(pubRecord?.theme); 106 106 let backgroundImage = pubRecord?.theme?.backgroundImage?.image?.ref 107 107 ? blobRefToSrc( 108 108 pubRecord?.theme?.backgroundImage?.image?.ref,
+2 -2
app/lish/[did]/[publication]/[rkey]/page.tsx
··· 160 160 return ( 161 161 <PostPageContextProvider value={document}> 162 162 <PublicationThemeProvider 163 - record={pubRecord} 163 + theme={pubRecord?.theme || record.theme || null} 164 164 pub_creator={ 165 165 document.documents_in_publications[0].publications.identity_did 166 166 } 167 167 > 168 168 <PublicationBackgroundProvider 169 - record={pubRecord} 169 + theme={pubRecord?.theme || record.theme || null} 170 170 pub_creator={ 171 171 document.documents_in_publications[0].publications.identity_did 172 172 }
+2 -2
app/lish/[did]/[publication]/page.tsx
··· 59 59 try { 60 60 return ( 61 61 <PublicationThemeProvider 62 - record={record} 62 + theme={record?.theme} 63 63 pub_creator={publication.identity_did} 64 64 > 65 65 <PublicationBackgroundProvider 66 - record={record} 66 + theme={record?.theme} 67 67 pub_creator={publication.identity_did} 68 68 > 69 69 <div
+1 -1
components/ThemeManager/PubThemeSetter.tsx
··· 39 39 theme: localPubTheme, 40 40 setTheme, 41 41 changes, 42 - } = useLocalPubTheme(record, showPageBackground); 42 + } = useLocalPubTheme(record?.theme, showPageBackground); 43 43 let [image, setImage] = useState<ImageState | null>( 44 44 PubLeafletThemeBackgroundImage.isMain(record?.theme?.backgroundImage) 45 45 ? {
+24 -25
components/ThemeManager/PublicationThemeProvider.tsx
··· 26 26 } 27 27 28 28 let useColor = ( 29 - record: PubLeafletPublication.Record | null | undefined, 29 + theme: PubLeafletPublication.Record["theme"] | null | undefined, 30 30 c: keyof typeof PubThemeDefaults, 31 31 ) => { 32 32 return useMemo(() => { 33 - let v = record?.theme?.[c]; 33 + let v = theme?.[c]; 34 34 if (isColor(v)) { 35 35 return parseThemeColor(v); 36 36 } else return parseColor(PubThemeDefaults[c]); 37 - }, [record?.theme?.[c]]); 37 + }, [theme?.[c]]); 38 38 }; 39 39 let isColor = ( 40 40 c: any, ··· 53 53 return ( 54 54 <PublicationThemeProvider 55 55 pub_creator={pub?.identity_did || ""} 56 - record={pub?.record as PubLeafletPublication.Record} 56 + theme={(pub?.record as PubLeafletPublication.Record)?.theme} 57 57 > 58 58 <PublicationBackgroundProvider 59 - record={pub?.record as PubLeafletPublication.Record} 59 + theme={(pub?.record as PubLeafletPublication.Record)?.theme} 60 60 pub_creator={pub?.identity_did || ""} 61 61 > 62 62 {props.children} ··· 66 66 } 67 67 68 68 export function PublicationBackgroundProvider(props: { 69 - record?: PubLeafletPublication.Record | null; 69 + theme?: PubLeafletPublication.Record["theme"] | null; 70 70 pub_creator: string; 71 71 className?: string; 72 72 children: React.ReactNode; 73 73 }) { 74 - let backgroundImage = props.record?.theme?.backgroundImage?.image?.ref 75 - ? blobRefToSrc( 76 - props.record?.theme?.backgroundImage?.image?.ref, 77 - props.pub_creator, 78 - ) 74 + let backgroundImage = props.theme?.backgroundImage?.image?.ref 75 + ? blobRefToSrc(props.theme?.backgroundImage?.image?.ref, props.pub_creator) 79 76 : null; 80 77 81 - let backgroundImageRepeat = props.record?.theme?.backgroundImage?.repeat; 82 - let backgroundImageSize = props.record?.theme?.backgroundImage?.width || 500; 78 + let backgroundImageRepeat = props.theme?.backgroundImage?.repeat; 79 + let backgroundImageSize = props.theme?.backgroundImage?.width || 500; 83 80 return ( 84 81 <div 85 82 className="PubBackgroundWrapper w-full bg-bg-leaflet text-primary h-full flex flex-col bg-cover bg-center bg-no-repeat items-stretch" ··· 96 93 export function PublicationThemeProvider(props: { 97 94 local?: boolean; 98 95 children: React.ReactNode; 99 - record?: PubLeafletPublication.Record | null; 96 + theme?: PubLeafletPublication.Record["theme"] | null; 100 97 pub_creator: string; 101 98 }) { 102 - let colors = usePubTheme(props.record); 99 + let colors = usePubTheme(props.theme); 103 100 return ( 104 101 <BaseThemeProvider local={props.local} {...colors}> 105 102 {props.children} ··· 107 104 ); 108 105 } 109 106 110 - export const usePubTheme = (record?: PubLeafletPublication.Record | null) => { 111 - let bgLeaflet = useColor(record, "backgroundColor"); 112 - let bgPage = useColor(record, "pageBackground"); 113 - bgPage = record?.theme?.pageBackground ? bgPage : bgLeaflet; 114 - let showPageBackground = record?.theme?.showPageBackground; 107 + export const usePubTheme = ( 108 + theme?: PubLeafletPublication.Record["theme"] | null, 109 + ) => { 110 + let bgLeaflet = useColor(theme, "backgroundColor"); 111 + let bgPage = useColor(theme, "pageBackground"); 112 + bgPage = theme?.pageBackground ? bgPage : bgLeaflet; 113 + let showPageBackground = theme?.showPageBackground; 115 114 116 - let primary = useColor(record, "primary"); 115 + let primary = useColor(theme, "primary"); 117 116 118 - let accent1 = useColor(record, "accentBackground"); 119 - let accent2 = useColor(record, "accentText"); 117 + let accent1 = useColor(theme, "accentBackground"); 118 + let accent2 = useColor(theme, "accentText"); 120 119 121 120 let highlight1 = useEntity(null, "theme/highlight-1")?.data.value; 122 121 let highlight2 = useColorAttribute(null, "theme/highlight-2"); ··· 136 135 }; 137 136 138 137 export const useLocalPubTheme = ( 139 - record: PubLeafletPublication.Record | undefined, 138 + theme: PubLeafletPublication.Record["theme"] | undefined, 140 139 showPageBackground?: boolean, 141 140 ) => { 142 - const pubTheme = usePubTheme(record); 141 + const pubTheme = usePubTheme(theme); 143 142 const [localOverrides, setTheme] = useState<Partial<typeof pubTheme>>({}); 144 143 145 144 const mergedTheme = useMemo(() => {
+4 -2
components/ThemeManager/ThemeProvider.tsx
··· 73 73 return ( 74 74 <PublicationThemeProvider 75 75 {...props} 76 - record={pub.publications?.record as PubLeafletPublication.Record} 76 + theme={(pub.publications?.record as PubLeafletPublication.Record)?.theme} 77 77 pub_creator={pub.publications?.identity_did} 78 78 /> 79 79 ); ··· 339 339 return ( 340 340 <PublicationBackgroundProvider 341 341 pub_creator={pub?.publications.identity_did || ""} 342 - record={pub?.publications.record as PubLeafletPublication.Record} 342 + theme={ 343 + (pub.publications?.record as PubLeafletPublication.Record)?.theme 344 + } 343 345 > 344 346 {props.children} 345 347 </PublicationBackgroundProvider>
+4
lexicons/api/lexicons.ts
··· 1436 1436 type: 'string', 1437 1437 format: 'at-identifier', 1438 1438 }, 1439 + theme: { 1440 + type: 'ref', 1441 + ref: 'lex:pub.leaflet.publication#theme', 1442 + }, 1439 1443 pages: { 1440 1444 type: 'array', 1441 1445 items: {
+2
lexicons/api/types/pub/leaflet/document.ts
··· 6 6 import { validate as _validate } from '../../../lexicons' 7 7 import { type $Typed, is$typed as _is$typed, type OmitKey } from '../../../util' 8 8 import type * as ComAtprotoRepoStrongRef from '../../com/atproto/repo/strongRef' 9 + import type * as PubLeafletPublication from './publication' 9 10 import type * as PubLeafletPagesLinearDocument from './pages/linearDocument' 10 11 import type * as PubLeafletPagesCanvas from './pages/canvas' 11 12 ··· 21 22 publishedAt?: string 22 23 publication?: string 23 24 author: string 25 + theme?: PubLeafletPublication.Theme 24 26 pages: ( 25 27 | $Typed<PubLeafletPagesLinearDocument.Main> 26 28 | $Typed<PubLeafletPagesCanvas.Main>
+4
lexicons/pub/leaflet/document.json
··· 42 42 "type": "string", 43 43 "format": "at-identifier" 44 44 }, 45 + "theme": { 46 + "type": "ref", 47 + "ref": "pub.leaflet.publication#theme" 48 + }, 45 49 "pages": { 46 50 "type": "array", 47 51 "items": {
+1
lexicons/src/document.ts
··· 22 22 publishedAt: { type: "string", format: "datetime" }, 23 23 publication: { type: "string", format: "at-uri" }, 24 24 author: { type: "string", format: "at-identifier" }, 25 + theme: { type: "ref", ref: "pub.leaflet.publication#theme" }, 25 26 pages: { 26 27 type: "array", 27 28 items: {