a tool for shared writing and social publishing
0

Configure Feed

Select the types of activity you want to include in your feed.

update post template with alignment and link tweaks

Jared Pereira (May 10, 2026, 3:18 PM EDT) fde8b886 e13ace73

+158 -62
+158 -62
emails/post.tsx
··· 32 32 import { blobRefToSrc } from "src/utils/blobRefToSrc"; 33 33 import { atUriToUrl, didToBlueskyUrl } from "src/utils/mentionUtils"; 34 34 import { normalizePublicationRecord } from "src/utils/normalizeRecords"; 35 - import { supabaseServerClient } from "supabase/serverClient"; 36 35 import { emailPropsFromPublication } from "./fromPublication"; 37 36 import { 38 37 bgcolorAttr, ··· 45 44 type EmailTheme, 46 45 type ResolvedColors, 47 46 } from "./shared"; 47 + import { supabaseServerClient } from "supabase/serverClient"; 48 48 49 49 export type PostEmailProps = { 50 50 publicationName: string; ··· 223 223 text: "Aligned right", 224 224 url: "https://leaflet.pub", 225 225 }, 226 + }, 227 + { 228 + ...textBlock("This text is centered."), 229 + alignment: "lex:pub.leaflet.pages.linearDocument#textAlignCenter", 230 + }, 231 + { 232 + ...textBlock("This text is right-aligned."), 233 + alignment: "lex:pub.leaflet.pages.linearDocument#textAlignRight", 234 + }, 235 + { 236 + ...headingBlock("Centered heading", 2), 237 + alignment: "lex:pub.leaflet.pages.linearDocument#textAlignCenter", 226 238 }, 227 239 { 228 240 $type: "pub.leaflet.pages.linearDocument#block", ··· 627 639 628 640 export default PostEmailPreview; 629 641 630 - // Map the lexicon's alignment token to the simple left/center/right values 631 - // usable as HTML `align` attributes. `justify` falls through to `left` to 632 - // match published web behavior (`justify-start` flex). For buttons we 633 - // default to `center` when alignment is unset, matching PostContent.tsx. 634 - const resolveButtonAlignment = ( 642 + // Map the lexicon's alignment token to a CSS `text-align` value for 643 + // text-like blocks (text, header, blockquote, list). Returns `undefined` 644 + // when no alignment is set so the element inherits the default (left). 645 + const resolveTextAlignment = ( 646 + alignment: string | undefined, 647 + ): CSSProperties["textAlign"] => { 648 + switch (alignment) { 649 + case "lex:pub.leaflet.pages.linearDocument#textAlignRight": 650 + return "right"; 651 + case "lex:pub.leaflet.pages.linearDocument#textAlignCenter": 652 + return "center"; 653 + case "lex:pub.leaflet.pages.linearDocument#textAlignJustify": 654 + return "justify"; 655 + case "lex:pub.leaflet.pages.linearDocument#textAlignLeft": 656 + return "left"; 657 + default: 658 + return undefined; 659 + } 660 + }; 661 + 662 + // Map the lexicon's alignment token to a simple left/center/right value 663 + // usable as an HTML `align` attribute. `justify` falls through to `left` 664 + // to match published web behavior (`justify-start` flex). For buttons and 665 + // images we default to `center` when alignment is unset, matching 666 + // PostContent.tsx. 667 + const resolveBlockAlignment = ( 635 668 alignment: string | undefined, 636 669 ): "left" | "center" | "right" => { 637 670 switch (alignment) { ··· 670 703 fontSize: 16, 671 704 lineHeight: 1.5, 672 705 margin: BLOCK_MARGIN, 706 + textAlign: resolveTextAlignment(alignment), 673 707 }} 674 708 > 675 709 {block.plaintext ? ( ··· 700 734 fontSize: HEADING_FONT_SIZE_PX[level], 701 735 lineHeight: 1.25, 702 736 margin: HEADING_MARGIN, 737 + textAlign: resolveTextAlignment(alignment), 703 738 }} 704 739 > 705 740 <RichTextSpans ··· 725 760 fontSize: 16, 726 761 lineHeight: 1.5, 727 762 margin: "2px 0", 763 + textAlign: resolveTextAlignment(alignment), 728 764 }} 729 765 > 730 766 <RichTextSpans ··· 756 792 // smaller images from being upscaled. 757 793 const naturalWidth = block.aspectRatio?.width; 758 794 return ( 759 - <Img 795 + <ImageBlock 760 796 src={src} 761 797 alt={block.alt ?? ""} 762 - style={{ 763 - display: "block", 764 - height: "auto", 765 - margin: IMAGE_MARGIN, 766 - maxWidth: naturalWidth ? `${naturalWidth}px` : "100%", 767 - width: "100%", 768 - }} 798 + naturalWidth={naturalWidth} 799 + align={resolveBlockAlignment(alignment)} 769 800 /> 770 801 ); 771 802 } ··· 789 820 <ButtonBlock 790 821 text={block.text} 791 822 url={block.url} 792 - align={resolveButtonAlignment(alignment)} 823 + align={resolveBlockAlignment(alignment)} 793 824 theme={theme} 794 825 /> 795 826 ); ··· 814 845 did={did} 815 846 assetsBaseUrl={assetsBaseUrl} 816 847 theme={theme} 848 + textAlign={resolveTextAlignment(alignment)} 817 849 /> 818 850 ); 819 851 } ··· 825 857 did={did} 826 858 assetsBaseUrl={assetsBaseUrl} 827 859 theme={theme} 860 + textAlign={resolveTextAlignment(alignment)} 828 861 /> 829 862 ); 830 863 } ··· 904 937 did, 905 938 assetsBaseUrl, 906 939 theme = defaultEmailTheme, 940 + textAlign, 907 941 }: { 908 942 items: ListItem[]; 909 943 style: "ordered" | "unordered"; 910 944 did: string; 911 945 assetsBaseUrl: string; 912 946 theme?: EmailTheme; 947 + textAlign?: CSSProperties["textAlign"]; 913 948 }) => { 914 949 const Tag = style === "ordered" ? "ol" : "ul"; 915 950 return ( ··· 922 957 lineHeight: 1.5, 923 958 margin: 0, 924 959 paddingLeft: 24, 960 + textAlign, 925 961 }} 926 962 > 927 963 {items.map((item, i) => { ··· 999 1035 return url; 1000 1036 } 1001 1037 })(); 1038 + // Each column wraps its content in a single `display: block` <Link> so 1039 + // the entire padded area — text column and image column — is clickable. 1040 + // Two anchors instead of one outer anchor keeps the markup table-friendly 1041 + // for Outlook (which dislikes <a> wrapping <table>). 1042 + const cellLinkStyle: CSSProperties = { 1043 + color: "inherit", 1044 + display: "block", 1045 + textDecoration: "none", 1046 + }; 1002 1047 return ( 1003 1048 <Section style={{ margin: BLOCK_MARGIN, minWidth: "100%" }}> 1004 1049 <Row ··· 1010 1055 > 1011 1056 <Column 1012 1057 style={{ 1013 - padding: "8px 12px", 1014 1058 verticalAlign: "top", 1015 1059 // Long URL-like titles or domains have no break opportunities 1016 1060 // and would otherwise force the column wider than the card. 1017 1061 wordBreak: "break-word", 1018 1062 }} 1019 1063 > 1020 - <Link 1021 - href={url} 1022 - style={{ 1023 - color: theme.primary, 1024 - fontFamily: theme.bodyFont, 1025 - fontWeight: "bold", 1026 - fontSize: 16, 1027 - textDecoration: "none", 1028 - wordBreak: "break-word", 1029 - }} 1030 - > 1031 - {title || displayUrl} 1032 - </Link> 1033 - {description ? ( 1034 - <ReactEmailText 1064 + <Link href={url} style={{ ...cellLinkStyle, padding: "8px 12px" }}> 1065 + <span 1035 1066 style={{ 1036 - color: c.secondary, 1067 + color: theme.primary, 1068 + display: "block", 1037 1069 fontFamily: theme.bodyFont, 1070 + fontWeight: "bold", 1038 1071 fontSize: 16, 1039 - lineHeight: 1.4, 1040 - margin: "4px 0 0", 1041 1072 wordBreak: "break-word", 1042 1073 }} 1043 1074 > 1044 - {description} 1045 - </ReactEmailText> 1046 - ) : null} 1047 - <ReactEmailText 1048 - style={{ 1049 - color: theme.accentBackground, 1050 - fontFamily: theme.bodyFont, 1051 - fontSize: 14, 1052 - fontStyle: "italic", 1053 - lineHeight: 1.4, 1054 - margin: "4px 0 0", 1055 - wordBreak: "break-word", 1056 - }} 1057 - > 1058 - {displayUrl} 1059 - </ReactEmailText> 1075 + {title || displayUrl} 1076 + </span> 1077 + {description ? ( 1078 + <span 1079 + style={{ 1080 + color: c.secondary, 1081 + // 2-line clamp matches the editor's `line-clamp-2`. 1082 + // -webkit-line-clamp works in Apple Mail / iOS / Gmail; 1083 + // Outlook ignores it but max-height + overflow still 1084 + // truncates (just without an ellipsis). 1085 + display: "-webkit-box", 1086 + fontFamily: theme.bodyFont, 1087 + fontSize: 16, 1088 + lineHeight: 1.4, 1089 + marginTop: 4, 1090 + maxHeight: "2.8em", 1091 + overflow: "hidden", 1092 + WebkitBoxOrient: "vertical", 1093 + WebkitLineClamp: 2, 1094 + wordBreak: "break-word", 1095 + }} 1096 + > 1097 + {description} 1098 + </span> 1099 + ) : null} 1100 + <span 1101 + style={{ 1102 + color: theme.accentBackground, 1103 + display: "block", 1104 + fontFamily: theme.bodyFont, 1105 + fontSize: 14, 1106 + fontStyle: "italic", 1107 + lineHeight: 1.4, 1108 + marginTop: 4, 1109 + wordBreak: "break-word", 1110 + }} 1111 + > 1112 + {displayUrl} 1113 + </span> 1114 + </Link> 1060 1115 </Column> 1061 1116 {previewSrc ? ( 1062 1117 <Column 1063 1118 style={{ 1064 - padding: "8px 8px 8px 0", 1065 1119 verticalAlign: "top", 1066 1120 width: 112, 1067 1121 }} 1068 1122 > 1069 - <Img 1070 - src={previewSrc} 1071 - alt="" 1072 - style={{ 1073 - borderRadius: 4, 1074 - display: "block", 1075 - height: 88, 1076 - objectFit: "cover", 1077 - width: "100%", 1078 - }} 1079 - /> 1123 + <Link 1124 + href={url} 1125 + style={{ ...cellLinkStyle, padding: "8px 8px 8px 0" }} 1126 + > 1127 + <Img 1128 + src={previewSrc} 1129 + alt="" 1130 + style={{ 1131 + borderRadius: 4, 1132 + display: "block", 1133 + height: 88, 1134 + objectFit: "cover", 1135 + width: "100%", 1136 + }} 1137 + /> 1138 + </Link> 1080 1139 </Column> 1081 1140 ) : null} 1082 1141 </Row> 1083 1142 </Section> 1143 + ); 1144 + }; 1145 + 1146 + export const ImageBlock = ({ 1147 + src, 1148 + alt, 1149 + naturalWidth, 1150 + align = "center", 1151 + }: { 1152 + src: string; 1153 + alt?: string; 1154 + naturalWidth?: number; 1155 + align?: "left" | "center" | "right"; 1156 + }) => { 1157 + // For images smaller than the column, `margin-left/right: auto` shifts 1158 + // the block within the available space. For images that already fill 1159 + // the column (large naturals or no natural width), the auto margins 1160 + // collapse and alignment has no visible effect — which matches the 1161 + // web, where a column-wide image can't be "right aligned" either. 1162 + const margin = 1163 + align === "left" 1164 + ? `${BLOCK_MARGIN_TOP}px auto ${BLOCK_MARGIN_BOTTOM}px 0` 1165 + : align === "right" 1166 + ? `${BLOCK_MARGIN_TOP}px 0 ${BLOCK_MARGIN_BOTTOM}px auto` 1167 + : IMAGE_MARGIN; 1168 + return ( 1169 + <Img 1170 + src={src} 1171 + alt={alt ?? ""} 1172 + style={{ 1173 + display: "block", 1174 + height: "auto", 1175 + margin, 1176 + maxWidth: naturalWidth ? `${naturalWidth}px` : "100%", 1177 + width: "100%", 1178 + }} 1179 + /> 1084 1180 ); 1085 1181 }; 1086 1182