a tool for shared writing and social publishing
0

Configure Feed

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

Merge branch 'main' of https://github.com/hyperlink-academy/minilink into refactor/sign-in

celine (Apr 15, 2026, 11:38 PM EDT) 3e7901bd c3d2b447

+3162 -1112
+30
components/EmptyState.tsx
··· 1 + export function EmptyState({ 2 + title, 3 + description, 4 + className, 5 + container = "frosted", 6 + children, 7 + }: { 8 + title?: string; 9 + description?: string; 10 + className?: string; 11 + container?: "frosted" | "opaque" | "none"; 12 + children?: React.ReactNode; 13 + }) { 14 + const containerClass = 15 + container === "frosted" 16 + ? "frosted-container" 17 + : container === "opaque" 18 + ? "opaque-container" 19 + : ""; 20 + 21 + return ( 22 + <div 23 + className={`w-full text-sm flex flex-col gap-1 italic text-tertiary text-center sm:p-4 p-3 ${containerClass} ${className ?? ""}`} 24 + > 25 + {title && <div className="text-base font-bold">{title}</div>} 26 + {description && <span>{description}</span>} 27 + {children} 28 + </div> 29 + ); 30 + }
+3 -1
components/Modal.tsx
··· 41 41 ${className}`} 42 42 > 43 43 {title ? ( 44 - <Dialog.Title className="text-lg mb-1">{title}</Dialog.Title> 44 + <Dialog.Title asChild> 45 + <h3 className="pb-1">{title}</h3> 46 + </Dialog.Title> 45 47 ) : ( 46 48 <Dialog.Title /> 47 49 )}
+1 -3
components/PageHeader.tsx
··· 21 21 } 22 22 }, []); 23 23 24 - let headerBGColor = !cardBorderHidden 25 - ? "var(--bg-leaflet)" 26 - : "var(--bg-page)"; 24 + let headerBGColor = cardBorderHidden ? "var(--bg-leaflet)" : "var(--bg-page)"; 27 25 28 26 return ( 29 27 <div
+2 -1
components/Tab.tsx
··· 11 11 }) => { 12 12 return ( 13 13 <div 14 - className={`pubTabs px-1 py-0 flex gap-1 items-center rounded-md hover:cursor-pointer ${props.selected ? "text-accent-2 bg-accent-1 font-bold -mb-px" : "text-tertiary"}`} 14 + className={`pubTabs px-1 py-0 flex gap-1 items-center rounded-md hover:cursor-pointer ${props.selected ? "text-accent-2 bg-accent-1 font-bold -mb-px" : "text-secondary"}`} 15 15 onClick={() => props.onSelect()} 16 16 onMouseEnter={props.onMouseEnter} 17 17 onPointerDown={props.onPointerDown} 18 + aria-label={props.icon ? props.name : undefined} 18 19 > 19 20 {props.icon ? props.icon : props.name} 20 21 {props.href && <ExternalLinkTiny />}
+1 -1
components/Toast.tsx
··· 105 105 className={`toastAnimationWrapper fixed top-0 bottom-0 right-0 left-0 z-50 h-fit`} 106 106 > 107 107 <div 108 - className={`toast absolute right-2 w-max shadow-md px-3 py-1 flex flex-row gap-2 rounded-full border text-center ${ 108 + className={`toast absolute right-2 w-max shadow-md px-3 py-1 flex flex-row gap-2 rounded-full border text-center font-bold ${ 109 109 props.toast?.type === "error" 110 110 ? "border-white bg-[#dc143c] text-white border font-bold" 111 111 : props.toast?.type === "success"
+4 -2
components/ActionBar/ProfileButton.tsx
··· 9 9 import { SpeedyLink } from "components/SpeedyLink"; 10 10 import { Popover } from "components/Popover"; 11 11 import { Modal } from "components/Modal"; 12 - import { InlineUpgrade } from "app/lish/[did]/[publication]/UpgradeModal"; 12 + import { InlineUpgradeToPro } from "app/lish/[did]/[publication]/UpgradeModal"; 13 13 import { ManageProSubscription } from "app/lish/[did]/[publication]/dashboard/settings/ManageProSubscription"; 14 14 import { ManageDomains } from "components/Domains/ManageDomains"; 15 15 import { WebSmall } from "components/Icons/WebSmall"; ··· 98 98 {" "} 99 99 <hr className="border-border-light border-dashed" /> 100 100 <div className="py-2"> 101 - <InlineUpgrade /> 101 + <div className="p-2 accent-container"> 102 + <InlineUpgradeToPro compact /> 103 + </div> 102 104 </div> 103 105 </> 104 106 )}
+2 -4
components/Domains/PublicationDomains.tsx
··· 17 17 assignDomainToPublication, 18 18 removeDomainAssignment, 19 19 } from "actions/domains"; 20 - import { PubSettingsHeader } from "app/lish/[did]/[publication]/dashboard/settings/PublicationSettings"; 20 + 21 21 import { AddDomainForm } from "./AddDomainForm"; 22 22 import { DomainSettingsView } from "./DomainSettingsView"; 23 23 import { PinTiny } from "components/Icons/PinTiny"; ··· 44 44 45 45 return ( 46 46 <div> 47 - <PubSettingsHeader backToMenuAction={props.backToMenu}> 48 - Domains 49 - </PubSettingsHeader> 47 + <h4>Domains</h4> 50 48 <div className="flex flex-col gap-1 py-1"> 51 49 <h4 className="">This Publication's Domains</h4> 52 50 <div className="text-xs text-tertiary -mb-1 ">DEFAULT</div>
+19
components/Icons/PasteTiny.tsx
··· 1 + import { Props } from "./Props"; 2 + 3 + export const PasteTiny = (props: Props) => { 4 + return ( 5 + <svg 6 + width="16" 7 + height="16" 8 + viewBox="0 0 16 16" 9 + fill="none" 10 + xmlns="http://www.w3.org/2000/svg" 11 + {...props} 12 + > 13 + <path 14 + d="M8.78343 1.30539C9.48078 0.593592 10.6233 0.581819 11.3352 1.27902L14.4504 4.33175C15.2886 5.15308 15.3029 6.49837 14.4817 7.33664L9.31175 12.614C8.71235 13.2257 7.75963 13.28 7.09593 12.7732L6.21214 13.6755C5.97056 13.9219 5.57486 13.9258 5.32835 13.6843L5.15843 13.5173L2.62229 15.1384C2.37716 15.2947 2.05558 15.2608 1.84788 15.0573L1.09886 14.323C0.891251 14.1194 0.851748 13.7997 1.00315 13.5515L2.56956 10.9812L2.39964 10.8152C2.15307 10.5736 2.14929 10.1779 2.39085 9.93136L3.33714 8.96457C2.92145 8.17051 3.04042 7.16777 3.7014 6.49289L8.78343 1.30539ZM2.32835 13.778L2.36839 13.8171L4.24241 12.6198L3.48558 11.8796L2.32835 13.778ZM3.72093 10.3591L5.75706 12.3542L6.19358 11.9079L4.15647 9.91378L3.72093 10.3591ZM10.4612 2.17257C10.2424 1.95827 9.89031 1.96164 9.67601 2.18039L4.59397 7.36691C4.25597 7.71196 4.26193 8.266 4.60667 8.60421L7.81272 11.7448C7.98168 11.9103 8.25367 11.9079 8.41917 11.739L13.5891 6.46164C13.927 6.11657 13.9211 5.56251 13.5764 5.22433L10.4612 2.17257ZM8.07249 4.61203C8.31404 4.3658 8.70981 4.36189 8.95628 4.60324C9.20284 4.84479 9.2066 5.24046 8.96507 5.48703L6.89183 7.60324C6.65032 7.84974 6.25462 7.85438 6.00804 7.613C5.76156 7.37143 5.75771 6.97575 5.99925 6.72921L8.07249 4.61203ZM9.43284 3.22824C9.67693 2.98484 10.0718 2.98449 10.3157 3.22824C10.5597 3.47232 10.5597 3.86893 10.3157 4.113L10.1438 4.28488C9.89974 4.52863 9.504 4.52771 9.25999 4.2839C9.01624 4.03983 9.01609 3.6441 9.25999 3.40011L9.43284 3.22824Z" 15 + fill="currentColor" 16 + /> 17 + </svg> 18 + ); 19 + };
+2 -2
components/PageLayouts/DashboardLayout.tsx
··· 187 187 </div> 188 188 </MediaContents> 189 189 <div 190 - className={`w-full h-full flex flex-col gap-2 relative overflow-y-scroll pt-3 pb-3 px-3 sm:pt-8 sm:pb-3 sm:pl-6 sm:pr-4 `} 190 + className={`w-full h-full flex flex-col gap-2 relative overflow-y-scroll pt-3 pb-3 px-3 sm:pt-8 sm:pb-6 sm:pl-8 sm:pr-4 `} 191 191 ref={ref} 192 192 id="home-content" 193 193 > ··· 217 217 })} 218 218 </div> 219 219 )} 220 - {props.publication && ( 220 + {props.publication && controls && ( 221 221 <button 222 222 className={`sm:hidden block text-tertiary`} 223 223 onClick={() => {
+1 -1
components/Pages/Page.tsx
··· 116 116 // this div wraps the contents AND the page options. 117 117 // it needs to be its own div because this container does NOT scroll, and therefore doesn't clip the absolutely positioned pageOptions 118 118 <div 119 - className={`pageWrapper relative shrink-0 ${props.fullPageScroll ? "w-full" : "w-max"}`} 119 + className={`pageWrapper relative shrink-0 h-full ${props.fullPageScroll ? "w-full" : "w-max"}`} 120 120 > 121 121 {/* 122 122 this div is the scrolling container that wraps only the contents div.
+1 -1
components/Pages/PublicationMetadata.tsx
··· 81 81 } 82 82 postTitle={ 83 83 <TextField 84 - className="leading-tight pt-0.5 text-xl font-bold outline-hidden bg-transparent" 84 + className="leading-tight pt-0.5 text-2xl font-bold outline-hidden bg-transparent" 85 85 value={title} 86 86 onChange={async (newTitle) => { 87 87 await rep?.mutate.updatePublicationDraft({
+6 -2
components/Popover/index.tsx
··· 16 16 border?: string; 17 17 className?: string; 18 18 open?: boolean; 19 + defaultOpen?: boolean; 19 20 onOpenChange?: (open: boolean) => void; 20 21 onOpenAutoFocus?: (e: Event) => void; 21 22 asChild?: boolean; 22 23 arrowFill?: string; 23 24 noArrow?: boolean; 25 + onInteractOutside?: (e: Event) => void; 24 26 }) => { 25 - let [open, setOpen] = useState(props.open || false); 27 + let [open, setOpen] = useState(props.open || props.defaultOpen || false); 26 28 useEffect(() => { 27 29 if (props.open !== undefined) setOpen(props.open); 28 30 }, [props.open]); 29 31 return ( 30 32 <RadixPopover.Root 33 + defaultOpen={props.defaultOpen} 31 34 open={props.open} 32 35 onOpenChange={(o) => { 33 36 setOpen(o); ··· 44 47 className={` 45 48 z-20 relative bg-bg-page 46 49 text-primary 47 - flex flex-col overflow-hidden 50 + flex flex-col 48 51 px-3 py-2 49 52 max-w-(--radix-popover-content-available-width) 50 53 max-h-(--radix-popover-content-available-height) ··· 56 59 sideOffset={props.sideOffset ? props.sideOffset : 4} 57 60 collisionPadding={16} 58 61 onOpenAutoFocus={props.onOpenAutoFocus} 62 + onInteractOutside={props.onInteractOutside} 59 63 > 60 64 {props.children} 61 65 {!props.noArrow && (
+263 -326
components/ThemeManager/PubThemeSetter.tsx
··· 4 4 } from "app/lish/[did]/[publication]/dashboard/PublicationSWRProvider"; 5 5 import { useState } from "react"; 6 6 import { pickers, SectionArrow } from "./ThemeSetter"; 7 - import { Color } from "react-aria-components"; 8 7 import { PubLeafletThemeBackgroundImage } from "lexicons/api"; 9 8 import { AtUri } from "@atproto/syntax"; 10 9 import { useLocalPubTheme } from "./PublicationThemeProvider"; 11 - import { BaseThemeProvider } from "./ThemeProvider"; 10 + import { BaseThemeProvider, CardBorderHiddenContext } from "./ThemeProvider"; 12 11 import { blobRefToSrc } from "src/utils/blobRefToSrc"; 13 12 import { updatePublicationTheme } from "app/lish/createPub/updatePublication"; 14 13 import { PagePickers } from "./PubPickers/PubTextPickers"; 15 14 import { BackgroundPicker } from "./PubPickers/PubBackgroundPickers"; 16 15 import { PubAccentPickers } from "./PubPickers/PubAcccentPickers"; 17 16 import { Separator } from "components/Layout"; 18 - import { PubSettingsHeader } from "app/lish/[did]/[publication]/dashboard/settings/PublicationSettings"; 17 + 19 18 import { ColorToRGB, ColorToRGBA } from "./colorToLexicons"; 20 19 import { useToaster } from "components/Toast"; 21 20 import { OAuthErrorMessage, isOAuthSessionError } from "components/OAuthError"; 22 21 import { PubPageWidthSetter } from "./PubPickers/PubPageWidthSetter"; 23 22 import { FontPicker } from "./Pickers/TextPickers"; 23 + import { GoToArrow } from "components/Icons/GoToArrow"; 24 + import { ButtonPrimary } from "components/Buttons"; 25 + import { PresetThemePicker } from "./PubPickers/PubPresetPicker"; 24 26 25 27 export type ImageState = { 26 28 src: string; 27 29 file?: File; 28 30 repeat: number | null; 29 31 }; 30 - export const PubThemeSetter = (props: { 31 - backToMenu: () => void; 32 - loading: boolean; 33 - setLoading: (l: boolean) => void; 34 - }) => { 35 - let [sample, setSample] = useState<"pub" | "post">("pub"); 32 + 33 + export function usePubThemeEditorState() { 36 34 let [openPicker, setOpenPicker] = useState<pickers>("null"); 37 35 let { data, mutate } = usePublicationData(); 38 36 let { publication: pub } = data || {}; ··· 44 42 theme: localPubTheme, 45 43 setTheme, 46 44 changes, 45 + resetChanges, 47 46 } = useLocalPubTheme(record?.theme, showPageBackground); 48 47 let [image, setImage] = useState<ImageState | null>( 49 48 PubLeafletThemeBackgroundImage.isMain(record?.theme?.backgroundImage) ··· 61 60 let [pageWidth, setPageWidth] = useState<number>( 62 61 record?.theme?.pageWidth || 624, 63 62 ); 64 - let [headingFont, setHeadingFont] = useState<string | undefined>(record?.theme?.headingFont); 65 - let [bodyFont, setBodyFont] = useState<string | undefined>(record?.theme?.bodyFont); 63 + let [headingFont, setHeadingFont] = useState<string | undefined>( 64 + record?.theme?.headingFont, 65 + ); 66 + let [bodyFont, setBodyFont] = useState<string | undefined>( 67 + record?.theme?.bodyFont, 68 + ); 66 69 let pubBGImage = image?.src || null; 67 70 let leafletBGRepeat = image?.repeat || null; 68 71 let toaster = useToaster(); 69 72 73 + let submitTheme = async (setLoading: (l: boolean) => void) => { 74 + if (!pub) return; 75 + setLoading(true); 76 + let result = await updatePublicationTheme({ 77 + uri: pub.uri, 78 + theme: { 79 + pageBackground: ColorToRGBA(localPubTheme.bgPage), 80 + showPageBackground: showPageBackground, 81 + backgroundColor: image 82 + ? ColorToRGBA(localPubTheme.bgLeaflet) 83 + : ColorToRGB(localPubTheme.bgLeaflet), 84 + backgroundRepeat: image?.repeat, 85 + backgroundImage: image ? image.file : null, 86 + pageWidth: pageWidth, 87 + primary: ColorToRGB(localPubTheme.primary), 88 + accentBackground: ColorToRGB(localPubTheme.accent1), 89 + accentText: ColorToRGB(localPubTheme.accent2), 90 + headingFont: headingFont, 91 + bodyFont: bodyFont, 92 + }, 93 + }); 94 + 95 + if (!result.success) { 96 + setLoading(false); 97 + if (result.error && isOAuthSessionError(result.error)) { 98 + toaster({ 99 + content: <OAuthErrorMessage error={result.error} />, 100 + type: "error", 101 + }); 102 + } else { 103 + toaster({ 104 + content: "Failed to update theme", 105 + type: "error", 106 + }); 107 + } 108 + return result; 109 + } 110 + 111 + mutate((pub) => { 112 + if (result.publication && pub?.publication) 113 + return { 114 + ...pub, 115 + publication: { ...pub.publication, ...result.publication }, 116 + }; 117 + return pub; 118 + }, false); 119 + resetChanges(); 120 + setLoading(false); 121 + return result; 122 + }; 123 + 124 + return { 125 + openPicker, 126 + setOpenPicker, 127 + pub, 128 + record, 129 + mutate, 130 + showPageBackground, 131 + setShowPageBackground, 132 + localPubTheme, 133 + setTheme, 134 + changes, 135 + resetChanges, 136 + image, 137 + setImage, 138 + pageWidth, 139 + setPageWidth, 140 + headingFont, 141 + setHeadingFont, 142 + bodyFont, 143 + setBodyFont, 144 + pubBGImage, 145 + leafletBGRepeat, 146 + toaster, 147 + submitTheme, 148 + }; 149 + } 150 + 151 + export type PubThemeEditorState = ReturnType<typeof usePubThemeEditorState>; 152 + 153 + export function PubThemePickerPanel(props: { state: PubThemeEditorState }) { 154 + let { 155 + openPicker, 156 + setOpenPicker, 157 + showPageBackground, 158 + setShowPageBackground, 159 + localPubTheme, 160 + setTheme, 161 + image, 162 + setImage, 163 + pageWidth, 164 + setPageWidth, 165 + headingFont, 166 + setHeadingFont, 167 + bodyFont, 168 + setBodyFont, 169 + pubBGImage, 170 + leafletBGRepeat, 171 + } = props.state; 172 + 70 173 return ( 71 - <BaseThemeProvider 72 - local 73 - {...localPubTheme} 74 - headingFontId={headingFont} 75 - bodyFontId={bodyFont} 76 - hasBackgroundImage={!!image} 77 - className="min-h-0!" 78 - > 79 - <div className="min-h-0 flex-1 flex flex-col pb-0.5"> 80 - <form 81 - className="flex-shrink-0" 82 - onSubmit={async (e) => { 83 - e.preventDefault(); 84 - if (!pub) return; 85 - props.setLoading(true); 86 - let result = await updatePublicationTheme({ 87 - uri: pub.uri, 88 - theme: { 89 - pageBackground: ColorToRGBA(localPubTheme.bgPage), 90 - showPageBackground: showPageBackground, 91 - backgroundColor: image 92 - ? ColorToRGBA(localPubTheme.bgLeaflet) 93 - : ColorToRGB(localPubTheme.bgLeaflet), 94 - backgroundRepeat: image?.repeat, 95 - backgroundImage: image ? image.file : null, 96 - pageWidth: pageWidth, 97 - primary: ColorToRGB(localPubTheme.primary), 98 - accentBackground: ColorToRGB(localPubTheme.accent1), 99 - accentText: ColorToRGB(localPubTheme.accent2), 100 - headingFont: headingFont, 101 - bodyFont: bodyFont, 102 - }, 103 - }); 104 - 105 - if (!result.success) { 106 - props.setLoading(false); 107 - if (result.error && isOAuthSessionError(result.error)) { 108 - toaster({ 109 - content: <OAuthErrorMessage error={result.error} />, 110 - type: "error", 111 - }); 112 - } else { 113 - toaster({ 114 - content: "Failed to update theme", 115 - type: "error", 116 - }); 117 - } 118 - return; 119 - } 120 - 121 - mutate((pub) => { 122 - if (result.publication && pub?.publication) 123 - return { 124 - ...pub, 125 - publication: { ...pub.publication, ...result.publication }, 126 - }; 127 - return pub; 128 - }, false); 129 - props.setLoading(false); 130 - }} 174 + <div className="themeSetterContent flex flex-col w-full"> 175 + <div className="themeBGLeaflet flex flex-col"> 176 + <div 177 + className={`themeBgPicker flex flex-col gap-0 -mb-[6px] z-10 w-full `} 131 178 > 132 - <PubSettingsHeader 133 - loading={props.loading} 134 - setLoadingAction={props.setLoading} 135 - backToMenuAction={props.backToMenu} 136 - > 137 - Theme and Layout 138 - </PubSettingsHeader> 139 - </form> 140 - 141 - <div className="themeSetterContent flex flex-col w-full overflow-y-scroll min-h-0 -mb-2 pt-2 "> 142 - <PubPageWidthSetter 143 - pageWidth={pageWidth} 144 - setPageWidth={setPageWidth} 145 - thisPicker="page-width" 146 - openPicker={openPicker} 147 - setOpenPicker={setOpenPicker} 148 - /> 149 - <div className="themeBGLeaflet flex flex-col"> 150 - <div 151 - className={`themeBgPicker flex flex-col gap-0 -mb-[6px] z-10 w-full `} 152 - > 153 - <div className="bgPickerBody w-full flex flex-col gap-2 p-2 mt-1 border border-[#CCCCCC] rounded-md text-[#595959] bg-white"> 154 - <BackgroundPicker 155 - bgImage={image} 156 - setBgImage={setImage} 157 - backgroundColor={localPubTheme.bgLeaflet} 158 - pageBackground={localPubTheme.bgPage} 159 - setPageBackground={(color) => { 160 - setTheme((t) => ({ ...t, bgPage: color })); 161 - }} 162 - setBackgroundColor={(color) => { 163 - setTheme((t) => ({ ...t, bgLeaflet: color })); 164 - }} 165 - openPicker={openPicker} 166 - setOpenPicker={setOpenPicker} 167 - hasPageBackground={!!showPageBackground} 168 - setHasPageBackground={setShowPageBackground} 169 - /> 170 - </div> 171 - 172 - <SectionArrow 173 - fill="white" 174 - stroke="#CCCCCC" 175 - className="ml-2 -mt-[1px]" 176 - /> 177 - </div> 178 - </div> 179 - 180 - <div 181 - style={{ 182 - backgroundImage: pubBGImage ? `url(${pubBGImage})` : undefined, 183 - backgroundRepeat: leafletBGRepeat ? "repeat" : "no-repeat", 184 - backgroundPosition: "center", 185 - backgroundSize: !leafletBGRepeat 186 - ? "cover" 187 - : `calc(${leafletBGRepeat}px / 2 )`, 188 - }} 189 - className={` relative bg-bg-leaflet px-3 py-4 flex flex-col rounded-md border border-border `} 190 - > 191 - <div className={`flex flex-col gap-3 z-10`}> 192 - <PagePickers 193 - pageBackground={localPubTheme.bgPage} 194 - primary={localPubTheme.primary} 195 - setPageBackground={(color) => { 196 - setTheme((t) => ({ ...t, bgPage: color })); 197 - }} 198 - setPrimary={(color) => { 199 - setTheme((t) => ({ ...t, primary: color })); 200 - }} 201 - openPicker={openPicker} 202 - setOpenPicker={(pickers) => setOpenPicker(pickers)} 203 - hasPageBackground={showPageBackground} 204 - /> 205 - <div className="bg-bg-page p-2 rounded-md border border-primary shadow-[0_0_0_1px_rgb(var(--bg-page))] flex flex-col gap-1"> 206 - <FontPicker 207 - label="Heading" 208 - value={headingFont} 209 - onChange={setHeadingFont} 210 - /> 211 - <FontPicker 212 - label="Body" 213 - value={bodyFont} 214 - onChange={setBodyFont} 215 - /> 216 - </div> 217 - <PubAccentPickers 218 - accent1={localPubTheme.accent1} 219 - setAccent1={(color) => { 220 - setTheme((t) => ({ ...t, accent1: color })); 221 - }} 222 - accent2={localPubTheme.accent2} 223 - setAccent2={(color) => { 224 - setTheme((t) => ({ ...t, accent2: color })); 225 - }} 226 - openPicker={openPicker} 227 - setOpenPicker={(pickers) => setOpenPicker(pickers)} 228 - /> 229 - </div> 230 - </div> 231 - <div className="flex flex-col mt-4 "> 232 - <div className="flex gap-2 items-center text-sm text-[#8C8C8C]"> 233 - <div className="text-sm">Preview</div> 234 - <Separator classname="h-4!" />{" "} 235 - <button 236 - className={`${sample === "pub" ? "font-bold text-[#595959]" : ""}`} 237 - onClick={() => setSample("pub")} 238 - > 239 - Pub 240 - </button> 241 - <button 242 - className={`${sample === "post" ? "font-bold text-[#595959]" : ""}`} 243 - onClick={() => setSample("post")} 244 - > 245 - Post 246 - </button> 247 - </div> 248 - {sample === "pub" ? ( 249 - <SamplePub 250 - pubBGImage={pubBGImage} 251 - pubBGRepeat={leafletBGRepeat} 252 - showPageBackground={showPageBackground} 253 - /> 254 - ) : ( 255 - <SamplePost 256 - pubBGImage={pubBGImage} 257 - pubBGRepeat={leafletBGRepeat} 258 - showPageBackground={showPageBackground} 259 - /> 260 - )} 261 - </div> 262 - </div> 263 - </div> 264 - </BaseThemeProvider> 265 - ); 266 - }; 267 - 268 - const SamplePub = (props: { 269 - pubBGImage: string | null; 270 - pubBGRepeat: number | null; 271 - showPageBackground: boolean; 272 - }) => { 273 - let { data } = usePublicationData(); 274 - let { publication } = data || {}; 275 - let record = useNormalizedPublicationRecord(); 276 - 277 - return ( 278 - <div 279 - style={{ 280 - backgroundImage: props.pubBGImage 281 - ? `url(${props.pubBGImage})` 282 - : undefined, 283 - backgroundRepeat: props.pubBGRepeat ? "repeat" : "no-repeat", 284 - backgroundPosition: "center", 285 - backgroundSize: !props.pubBGRepeat 286 - ? "cover" 287 - : `calc(${props.pubBGRepeat}px / 2 )`, 288 - }} 289 - className={`bg-bg-leaflet p-3 pb-0 flex flex-col gap-3 rounded-t-md border border-border border-b-0 h-[148px] overflow-hidden `} 290 - > 291 - <div 292 - className="pubWrapper sampleContent rounded-t-md border-border pb-4 px-[10px] flex flex-col gap-[14px] w-[250px] mx-auto" 293 - style={{ 294 - background: props.showPageBackground 295 - ? "rgba(var(--bg-page), var(--bg-page-alpha))" 296 - : undefined, 297 - }} 298 - > 299 - <div className="flex flex-col justify-center text-center pt-2"> 300 - {record?.icon && publication?.uri && ( 301 - <div 302 - style={{ 303 - backgroundRepeat: "no-repeat", 304 - backgroundPosition: "center", 305 - backgroundSize: "cover", 306 - backgroundImage: `url(/api/atproto_images?did=${new AtUri(publication.uri).host}&cid=${(record.icon?.ref as unknown as { $link: string })["$link"]})`, 307 - }} 308 - className="w-4 h-4 rounded-full place-self-center" 179 + <PresetThemePicker state={props.state} /> 180 + <div className="bgPickerBody w-full flex flex-col gap-2 p-2 border border-[#CCCCCC] rounded-md text-[#595959] bg-white"> 181 + <PubPageWidthSetter 182 + pageWidth={pageWidth} 183 + setPageWidth={setPageWidth} 184 + thisPicker="page-width" 185 + openPicker={openPicker} 186 + setOpenPicker={setOpenPicker} 309 187 /> 310 - )} 188 + <hr className="border-[#CCCCCC] my-0.5" /> 189 + <BackgroundPicker 190 + bgImage={image} 191 + setBgImage={setImage} 192 + backgroundColor={localPubTheme.bgLeaflet} 193 + pageBackground={localPubTheme.bgPage} 194 + setPageBackground={(color) => { 195 + setTheme((t) => ({ ...t, bgPage: color })); 196 + }} 197 + setBackgroundColor={(color) => { 198 + setTheme((t) => ({ ...t, bgLeaflet: color })); 199 + }} 200 + openPicker={openPicker} 201 + setOpenPicker={setOpenPicker} 202 + hasPageBackground={!!showPageBackground} 203 + setHasPageBackground={setShowPageBackground} 204 + /> 205 + </div> 311 206 312 - <div className="text-[11px] font-bold pt-[5px] text-accent-contrast" style={{ fontFamily: "var(--theme-heading-font, var(--theme-font, var(--font-quattro)))" }}> 313 - {record?.name} 314 - </div> 315 - <div className="text-[7px] font-normal text-tertiary"> 316 - {record?.description} 317 - </div> 318 - <div className=" flex gap-1 items-center mt-[6px] bg-accent-1 text-accent-2 py-px px-[4px] text-[7px] w-fit font-bold rounded-[2px] mx-auto"> 319 - <div className="h-[7px] w-[7px] rounded-full bg-accent-2" /> 320 - Subscribe with Bluesky 321 - </div> 207 + <SectionArrow 208 + fill="white" 209 + stroke="#CCCCCC" 210 + className="ml-2 -mt-[1px]" 211 + /> 322 212 </div> 213 + </div> 323 214 324 - <div className="flex flex-col text-[8px] rounded-md "> 325 - <div className="font-bold" style={{ fontFamily: "var(--theme-heading-font, var(--theme-font, var(--font-quattro)))" }}>A Sample Post</div> 326 - <div className="text-secondary italic text-[6px]"> 327 - This is a sample description about the sample post 215 + <div 216 + style={{ 217 + backgroundImage: pubBGImage ? `url(${pubBGImage})` : undefined, 218 + backgroundRepeat: leafletBGRepeat ? "repeat" : "no-repeat", 219 + backgroundPosition: "center", 220 + backgroundSize: !leafletBGRepeat 221 + ? "cover" 222 + : `calc(${leafletBGRepeat}px / 2 )`, 223 + }} 224 + className={` relative bg-bg-leaflet px-3 py-4 flex flex-col rounded-md border border-border `} 225 + > 226 + <div className={`flex flex-col gap-3 z-10`}> 227 + <PagePickers 228 + pageBackground={localPubTheme.bgPage} 229 + primary={localPubTheme.primary} 230 + setPageBackground={(color) => { 231 + setTheme((t) => ({ ...t, bgPage: color })); 232 + }} 233 + setPrimary={(color) => { 234 + setTheme((t) => ({ ...t, primary: color })); 235 + }} 236 + openPicker={openPicker} 237 + setOpenPicker={(pickers) => setOpenPicker(pickers)} 238 + hasPageBackground={showPageBackground} 239 + /> 240 + <div className="bg-bg-page p-2 rounded-md border border-primary shadow-[0_0_0_1px_rgb(var(--bg-page))] flex flex-col gap-1"> 241 + <FontPicker 242 + label="Heading" 243 + value={headingFont} 244 + onChange={setHeadingFont} 245 + /> 246 + <FontPicker label="Body" value={bodyFont} onChange={setBodyFont} /> 328 247 </div> 329 - <div className="text-tertiary text-[5px] pt-[2px]">Jan 1, 20XX </div> 248 + <PubAccentPickers 249 + accent1={localPubTheme.accent1} 250 + setAccent1={(color) => { 251 + setTheme((t) => ({ ...t, accent1: color })); 252 + }} 253 + accent2={localPubTheme.accent2} 254 + setAccent2={(color) => { 255 + setTheme((t) => ({ ...t, accent2: color })); 256 + }} 257 + openPicker={openPicker} 258 + setOpenPicker={(pickers) => setOpenPicker(pickers)} 259 + /> 330 260 </div> 331 261 </div> 332 262 </div> 333 263 ); 334 - }; 264 + } 335 265 336 - const SamplePost = (props: { 337 - pubBGImage: string | null; 338 - pubBGRepeat: number | null; 339 - showPageBackground: boolean; 266 + export const PubThemeSetter = (props: { 267 + backToMenu: () => void; 268 + loading: boolean; 269 + setLoading: (l: boolean) => void; 340 270 }) => { 341 - let { data } = usePublicationData(); 342 - let { publication } = data || {}; 343 - let record = useNormalizedPublicationRecord(); 271 + let [sample, setSample] = useState<"pub" | "post">("pub"); 272 + let state = usePubThemeEditorState(); 273 + let { 274 + localPubTheme, 275 + headingFont, 276 + bodyFont, 277 + image, 278 + pageWidth, 279 + pubBGImage, 280 + leafletBGRepeat, 281 + pub, 282 + record, 283 + showPageBackground, 284 + submitTheme, 285 + } = state; 286 + 344 287 return ( 345 - <div 346 - style={{ 347 - backgroundImage: props.pubBGImage 348 - ? `url(${props.pubBGImage})` 349 - : undefined, 350 - backgroundRepeat: props.pubBGRepeat ? "repeat" : "no-repeat", 351 - backgroundPosition: "center", 352 - backgroundSize: !props.pubBGRepeat 353 - ? "cover" 354 - : `calc(${props.pubBGRepeat}px / 2 )`, 355 - }} 356 - className={`bg-bg-leaflet p-3 max-w-full flex flex-col gap-3 rounded-t-md border border-border border-b-0 pb-0 h-[148px] overflow-hidden`} 357 - > 358 - <div 359 - className="pubWrapper sampleContent rounded-t-md border-border pb-0 px-[6px] flex flex-col w-[250px] mx-auto" 360 - style={{ 361 - background: props.showPageBackground 362 - ? "rgba(var(--bg-page), var(--bg-page-alpha))" 363 - : undefined, 364 - }} 288 + <CardBorderHiddenContext.Provider value={!showPageBackground}> 289 + <BaseThemeProvider 290 + local 291 + {...localPubTheme} 292 + headingFontId={headingFont} 293 + bodyFontId={bodyFont} 294 + hasBackgroundImage={!!image} 295 + className="min-h-0!" 365 296 > 366 - <div className="flex flex-col "> 367 - <div className="text-[6px] font-bold pt-[6px] text-accent-contrast" style={{ fontFamily: "var(--theme-heading-font, var(--theme-font, var(--font-quattro)))" }}> 368 - {record?.name} 369 - </div> 370 - <div className="text-[11px] font-bold text-primary" style={{ fontFamily: "var(--theme-heading-font, var(--theme-font, var(--font-quattro)))" }}> 371 - A Sample Post 372 - </div> 373 - <div className="text-[7px] font-normal text-secondary italic"> 374 - A short sample description about the sample post 375 - </div> 376 - <div className="text-tertiary text-[5px] pt-[2px]">Jan 1, 20XX </div> 377 - </div> 378 - <div className="text-[6px] pt-[8px] flex flex-col gap-[6px]"> 379 - <div> 380 - Lorem ipsum dolor sit amet consectetur adipiscing elit. Quisque 381 - faucibus ex sapien vitae pellentesque sem placerat. In id cursus mi 382 - pretium tellus duis convallis. Tempus leo eu aenean sed diam urna 383 - tempor. 297 + <div className="min-h-0 flex-1 flex flex-col pb-0.5"> 298 + <div className="flex-shrink-0"> 299 + <button type="button" onClick={props.backToMenu}> 300 + <GoToArrow /> 301 + </button> 384 302 </div> 385 303 386 - <div> 387 - Pulvinar vivamus fringilla lacus nec metus bibendum egestas. Iaculis 388 - massa nisl malesuada lacinia integer nunc posuere. Ut hendrerit 389 - semper vel class aptent taciti sociosqu. Ad litora torquent per 390 - conubia nostra inceptos himenaeos. 391 - </div> 392 - <div> 393 - Sed et nisi semper, egestas purus a, egestas nulla. Nulla ultricies, 394 - purus non dapibus tincidunt, nunc sem rhoncus sem, vel malesuada 395 - tellus enim sit amet magna. Donec ac justo a ipsum fermentum 396 - vulputate. Etiam sit amet viverra leo. Aenean accumsan consectetur 397 - velit. Vivamus at justo a nisl imperdiet dictum. Donec scelerisque 398 - ex eget turpis scelerisque tincidunt. Proin non convallis nibh, eget 399 - aliquet ex. Curabitur ornare a ipsum in ultrices. 304 + <div className="themeSetterContent flex flex-col w-full overflow-y-scroll min-h-0 -mb-2 pt-2 "> 305 + <PubThemePickerPanel state={state} /> 306 + <div className="flex flex-col mt-4 "> 307 + <div className="flex gap-2 items-center text-sm text-[#8C8C8C]"> 308 + <div className="text-sm">Preview</div> 309 + <Separator classname="h-4!" />{" "} 310 + <button 311 + type="button" 312 + className={`${sample === "pub" ? "font-bold text-[#595959]" : ""}`} 313 + onClick={() => setSample("pub")} 314 + > 315 + Pub 316 + </button> 317 + <button 318 + type="button" 319 + className={`${sample === "post" ? "font-bold text-[#595959]" : ""}`} 320 + onClick={() => setSample("post")} 321 + > 322 + Post 323 + </button> 324 + </div> 325 + </div> 326 + <div className="pt-2"> 327 + <ButtonPrimary 328 + fullWidth 329 + disabled={props.loading} 330 + onClick={async () => { 331 + await submitTheme(props.setLoading); 332 + }} 333 + > 334 + {props.loading ? "Saving..." : "Save Theme"} 335 + </ButtonPrimary> 336 + </div> 400 337 </div> 401 338 </div> 402 - </div> 403 - </div> 339 + </BaseThemeProvider> 340 + </CardBorderHiddenContext.Provider> 404 341 ); 405 342 };
+22 -6
components/ThemeManager/PublicationThemeProvider.tsx
··· 69 69 pub_creator: string; 70 70 className?: string; 71 71 children: React.ReactNode; 72 + localBgImage?: string | null; 73 + localBgImageRepeat?: number | null; 72 74 }) { 73 - let backgroundImage = props.theme?.backgroundImage?.image?.ref 74 - ? blobRefToSrc(props.theme?.backgroundImage?.image?.ref, props.pub_creator) 75 - : null; 75 + let backgroundImage = 76 + props.localBgImage !== undefined 77 + ? props.localBgImage 78 + : props.theme?.backgroundImage?.image?.ref 79 + ? blobRefToSrc( 80 + props.theme?.backgroundImage?.image?.ref, 81 + props.pub_creator, 82 + ) 83 + : null; 76 84 77 - let backgroundImageRepeat = props.theme?.backgroundImage?.repeat; 78 - let backgroundImageSize = props.theme?.backgroundImage?.width || 500; 85 + let backgroundImageRepeat = 86 + props.localBgImageRepeat !== undefined 87 + ? !!props.localBgImageRepeat 88 + : props.theme?.backgroundImage?.repeat; 89 + let backgroundImageSize = 90 + (props.localBgImageRepeat !== undefined 91 + ? props.localBgImageRepeat 92 + : props.theme?.backgroundImage?.width) || 500; 93 + 79 94 return ( 80 95 <div 81 - className="PubBackgroundWrapper w-full bg-bg-leaflet text-primary h-full flex flex-col bg-cover bg-center bg-no-repeat items-stretch" 96 + className={`PubBackgroundWrapper w-full bg-bg-leaflet text-primary h-full flex flex-col bg-cover bg-center bg-no-repeat items-stretch ${props.className}`} 82 97 style={{ 83 98 backgroundImage: backgroundImage 84 99 ? `url(${backgroundImage})` ··· 184 199 return { 185 200 theme: mergedTheme, 186 201 setTheme, 202 + resetChanges: () => setTheme({}), 187 203 changes: Object.keys(localOverrides).length > 0, 188 204 }; 189 205 };
+1 -1
app/(home-pages)/notifications/BskyPostEmbedNotification.tsx
··· 28 28 <ContentLayout postTitle={docRecord.title} pubRecord={pubRecord}> 29 29 {props.bskyPostText && ( 30 30 <pre 31 - style={{ wordBreak: "break-word" }} 31 + style={{ wordBreak: "break-word", fontFamily: "inherit" }} 32 32 className="whitespace-pre-wrap text-secondary line-clamp-3 text-sm" 33 33 > 34 34 {props.bskyPostText}
+3 -8
app/(home-pages)/notifications/Notification.tsx
··· 23 23 <div 24 24 className={`relative flex flex-col w-full pb-3 sm:pb-4 pt-2 ${ 25 25 cardBorderHidden 26 - ? " first:pt-0! " 27 - : " block-border border-border! hover:outline-border sm:px-4 px-3 pl-2 sm:pl-3 " 26 + ? "first:pt-0! " 27 + : "container rounded-lg! block-border hover:outline-border! border-border! sm:px-4 px-3 pl-2 sm:pl-3 " 28 28 }`} 29 - style={{ 30 - backgroundColor: cardBorderHidden 31 - ? "transparent" 32 - : "rgba(var(--bg-page), var(--bg-page-alpha))", 33 - }} 34 29 > 35 30 <a 36 31 href={props.href} ··· 102 97 <div className=" flex gap-2 text-sm w-full "> 103 98 <Avatar src={props.avatar} displayName={props.displayName} /> 104 99 <pre 105 - style={{ wordBreak: "break-word" }} 100 + style={{ wordBreak: "break-word", fontFamily: "inherit" }} 106 101 className={`whitespace-pre-wrap text-secondary line-clamp-3 sm:line-clamp-6 ${props.className}`} 107 102 > 108 103 <BaseTextBlock
+9 -7
app/(home-pages)/notifications/NotificationList.tsx
··· 12 12 import { MentionNotification } from "./MentionNotification"; 13 13 import { CommentMentionNotification } from "./CommentMentionNotification"; 14 14 import { RecommendNotification } from "./RecommendNotification"; 15 + import { EmptyState } from "components/EmptyState"; 15 16 16 17 export function NotificationList({ 17 18 notifications, ··· 30 31 31 32 if (notifications.length === 0) 32 33 return ( 33 - <div className="w-full text-sm flex flex-col gap-1 frosted-container italic text-tertiary text-center sm:p-4 p-3"> 34 - <div className="text-base font-bold">no notifications yet...</div> 35 - Here, you&apos;ll find notifications about new follows, comments, 36 - mentions, and replies! 37 - </div> 34 + <EmptyState 35 + title="no notifications yet..." 36 + description="Here, you'll find notifications about new follows, comments, mentions, and replies!" 37 + /> 38 38 ); 39 39 return ( 40 - <div className="max-w-prose mx-auto w-full"> 41 - <div className={`flex flex-col gap-2`}> 40 + <div className="max-w-prose w-full"> 41 + <div className={`flex flex-col gap-3`}> 42 + <h1 className="sm:text-xl text-lg">Notifications</h1> 43 + 42 44 {notifications.map((n) => { 43 45 if (n.type === "comment") { 44 46 if (n.parentData) return <ReplyNotification key={n.id} {...n} />;
+2 -5
app/(home-pages)/notifications/QuoteNotification.tsx
··· 27 27 content={ 28 28 <ContentLayout postTitle={docRecord.title} pubRecord={pubRecord}> 29 29 <div className="flex gap-2 text-sm w-full"> 30 - <Avatar 31 - src={author.avatar} 32 - displayName={displayName} 33 - /> 30 + <Avatar src={author.avatar} displayName={displayName} /> 34 31 <pre 35 - style={{ wordBreak: "break-word" }} 32 + style={{ wordBreak: "break-word", fontFamily: "inherit" }} 36 33 className="whitespace-pre-wrap text-secondary line-clamp-3 sm:line-clamp-6" 37 34 > 38 35 {postText}
+2 -5
app/(home-pages)/reader/GlobalContent.tsx
··· 2 2 import { use } from "react"; 3 3 import useSWR from "swr"; 4 4 import { callRPC } from "app/api/rpc/client"; 5 + import { EmptyState } from "components/EmptyState"; 5 6 import { PostListing } from "components/PostListing"; 6 7 import type { Post } from "./getReaderFeed"; 7 8 import { ··· 33 34 let selectedPost = useSelectedPostListing((s) => s.selectedPostListing); 34 35 35 36 if (posts.length === 0) { 36 - return ( 37 - <div className="flex flex-col gap-2 frosted-container bg-[rgba(var(--bg-page),.7)] sm:p-4 p-3 justify-between text-center text-tertiary"> 38 - Nothing trending right now. Check back soon! 39 - </div> 40 - ); 37 + return <EmptyState title="Nothing trending right now. Check back soon!" />; 41 38 } 42 39 43 40 return (
+2 -5
app/(home-pages)/reader/NewContent.tsx
··· 1 1 "use client"; 2 2 3 3 import { use } from "react"; 4 + import { EmptyState } from "components/EmptyState"; 4 5 import type { Cursor, Post } from "./getReaderFeed"; 5 6 import useSWRInfinite from "swr/infinite"; 6 7 import { getNewFeed } from "./getNewFeed"; ··· 65 66 const allPosts = data ? data.flatMap((page) => page.posts) : []; 66 67 67 68 if (allPosts.length === 0) { 68 - return ( 69 - <div className="flex flex-col gap-2 frosted-container bg-[rgba(var(--bg-page),.7)] sm:p-4 p-3 justify-between text-center text-tertiary"> 70 - No posts yet. Check back soon! 71 - </div> 72 - ); 69 + return <EmptyState title="No posts yet. Check back soon!" />; 73 70 } 74 71 75 72 return (
+10 -6
app/(home-pages)/reader/ReaderMentionsContent.tsx
··· 2 2 import useSWR from "swr"; 3 3 import { PostView } from "@atproto/api/dist/client/types/app/bsky/feed/defs"; 4 4 import { BskyPostContent } from "app/lish/[did]/[publication]/[rkey]/BskyPostContent"; 5 + import { EmptyState } from "components/EmptyState"; 5 6 import { DotLoader } from "components/utils/DotLoader"; 6 7 7 8 async function fetchBskyPosts(uris: string[]): Promise<PostView[]> { ··· 17 18 quotesAndMentions: { uri: string; link?: string }[]; 18 19 }) { 19 20 const uris = props.quotesAndMentions.map((q) => q.uri); 20 - const key = uris.length > 0 21 - ? `/api/bsky/hydrate?${new URLSearchParams({ uris: JSON.stringify(uris) }).toString()}` 22 - : null; 21 + const key = 22 + uris.length > 0 23 + ? `/api/bsky/hydrate?${new URLSearchParams({ uris: JSON.stringify(uris) }).toString()}` 24 + : null; 23 25 24 26 const { data: bskyPosts, isLoading } = useSWR(key, () => 25 27 fetchBskyPosts(uris), ··· 27 29 28 30 if (props.quotesAndMentions.length === 0) { 29 31 return ( 30 - <div className="opaque-container flex flex-col gap-0.5 p-[6px] text-tertiary italic text-sm text-center"> 31 - <div className="font-bold">no mentions yet!</div> 32 - </div> 32 + <EmptyState 33 + title="No mentions yet!" 34 + container="opaque" 35 + className="gap-0.5 p-[6px]!" 36 + /> 33 37 ); 34 38 } 35 39
+1 -1
app/(home-pages)/reader/layout.tsx
··· 57 57 className={`pubTabs px-1 py-0 flex gap-1 items-center rounded-md hover:cursor-pointer ${ 58 58 isActive(tab.href) 59 59 ? "text-accent-2 bg-accent-1 font-bold -mb-px" 60 - : "text-tertiary" 60 + : "text-secondary" 61 61 }`} 62 62 > 63 63 {tab.name}
+7
app/api/inngest/client.ts
··· 82 82 subscriptionId: string; 83 83 }; 84 84 }; 85 + "stripe/invoice.payment.succeeded": { 86 + data: { 87 + invoiceId: string; 88 + subscriptionId: string; 89 + customerId: string; 90 + }; 91 + }; 85 92 "stripe/invoice.payment.failed": { 86 93 data: { 87 94 invoiceId: string;
+1 -8
app/lish/createPub/UpdatePubForm.tsx
··· 10 10 import { mutate } from "swr"; 11 11 import { AddTiny } from "components/Icons/AddTiny"; 12 12 import { useToaster } from "components/Toast"; 13 - import { PubSettingsHeader } from "../[did]/[publication]/dashboard/settings/PublicationSettings"; 14 13 import { Toggle } from "components/Toggle"; 15 14 16 15 export const EditPubForm = (props: { ··· 85 84 mutate("publication-data"); 86 85 }} 87 86 > 88 - <PubSettingsHeader 89 - loading={props.loading} 90 - setLoadingAction={props.setLoadingAction} 91 - backToMenuAction={props.backToMenuAction} 92 - > 93 - General Settings 94 - </PubSettingsHeader> 87 + 95 88 <div className="flex flex-col gap-3 w-[1000px] max-w-full pb-2 overflow-y-auto min-h-0"> 96 89 <div className="flex items-center justify-between gap-2 mt-2 "> 97 90 <p className="pl-0.5 pb-0.5 text-tertiary italic text-sm font-bold">
+33 -21
components/ThemeManager/Pickers/ColorPicker.tsx
··· 15 15 import { pickers } from "../ThemeSetter"; 16 16 import { Separator } from "components/Layout"; 17 17 import { onMouseDown } from "src/utils/iosInputMouseDown"; 18 + import { useIsMobile } from "src/hooks/isMobile"; 19 + import { HexKeyboard } from "./HexKeyboard"; 20 + import { NumPad } from "./NumPad"; 18 21 19 22 export let thumbStyle = 20 23 "w-4 h-4 rounded-full border-2 border-white shadow-[0_0_0_1px_#8C8C8C,inset_0_0_0_1px_#8C8C8C]"; ··· 33 36 disabled?: boolean; 34 37 children?: React.ReactNode; 35 38 }) => { 39 + let isMobile = useIsMobile(); 40 + 36 41 return ( 37 42 <SpectrumColorPicker value={props.value} onChange={props.setValue}> 38 43 <div className="flex flex-col w-full gap-2"> 39 44 <div className="colorPickerLabel flex gap-2 items-center "> 40 45 <button 46 + type="button" 41 47 disabled={props.disabled} 42 48 className="flex gap-2 items-center disabled:text-tertiary" 43 49 onClick={() => { ··· 63 69 <div>default</div> 64 70 ) : props.disabled ? ( 65 71 <div className="text-tertiary italic">hidden</div> 72 + ) : isMobile ? ( 73 + <HexKeyboard value={props.value} setValue={props.setValue} /> 66 74 ) : ( 67 75 <ColorField className="w-fit gap-1"> 68 76 <Input ··· 88 96 {props.alpha && !props.disabled && ( 89 97 <> 90 98 <Separator classname="my-1" /> 91 - <ColorField 92 - className={`w-[48px] pl-[6px] ${props.disabled ? "opacity-50" : ""}`} 93 - channel="alpha" 94 - > 95 - <Input 96 - disabled={props.disabled} 97 - onMouseDown={onMouseDown} 98 - onFocus={(e) => { 99 - e.currentTarget.setSelectionRange( 100 - 0, 101 - e.currentTarget.value.length - 1, 102 - ); 103 - }} 104 - onKeyDown={(e) => { 105 - if (e.key === "Enter") { 106 - e.currentTarget.blur(); 107 - } else return; 108 - }} 109 - className="w-[72px] bg-transparent outline-hidden " 110 - /> 111 - </ColorField> 99 + {isMobile ? ( 100 + <NumPad value={props.value} setValue={props.setValue} /> 101 + ) : ( 102 + <ColorField 103 + className={`w-[48px] pl-[6px] ${props.disabled ? "opacity-50" : ""}`} 104 + channel="alpha" 105 + > 106 + <Input 107 + disabled={props.disabled} 108 + onMouseDown={onMouseDown} 109 + onFocus={(e) => { 110 + e.currentTarget.setSelectionRange( 111 + 0, 112 + e.currentTarget.value.length - 1, 113 + ); 114 + }} 115 + onKeyDown={(e) => { 116 + if (e.key === "Enter") { 117 + e.currentTarget.blur(); 118 + } else return; 119 + }} 120 + className="w-[72px] bg-transparent outline-hidden " 121 + /> 122 + </ColorField> 123 + )} 112 124 </> 113 125 )} 114 126 </div>
+230
components/ThemeManager/Pickers/HexKeyboard.tsx
··· 1 + "use client"; 2 + 3 + import { useState, useCallback, useRef } from "react"; 4 + import { Color, parseColor } from "react-aria-components"; 5 + import { Popover } from "components/Popover"; 6 + import { GoToArrow } from "components/Icons/GoToArrow"; 7 + import { useSmoker } from "components/Toast"; 8 + import { CopyTiny } from "components/Icons/CopyTiny"; 9 + import { PasteTiny } from "components/Icons/PasteTiny"; 10 + 11 + const HEX_ROWS = [ 12 + ["1", "2", "3", "A", "B", "C"], 13 + ["4", "5", "6", "D", "E", "F"], 14 + ] as const; 15 + 16 + const BOTTOM_ROW = ["7", "8", "9", "0"] as const; 17 + 18 + const HexKey = (props: { 19 + onClick: () => void; 20 + children: React.ReactNode; 21 + className?: string; 22 + disabled?: boolean; 23 + ariaLabel?: string; 24 + }) => { 25 + return ( 26 + <button 27 + onPointerDown={(e) => e.preventDefault()} 28 + onClick={props.onClick} 29 + disabled={props.disabled} 30 + aria-label={props.ariaLabel} 31 + className={` 32 + h-10 w-10 rounded-md 33 + font-bold 34 + flex items-center justify-center 35 + border border-[#CCCCCC] text-[#969696] 36 + disabled:bg-[#DBDBDB] disabled:border-[#DBDBDB] disabled:text-[#CCCCCC] disabled:cursor-not-allowed 37 + ${props.className ?? ""} 38 + `} 39 + > 40 + {props.children} 41 + </button> 42 + ); 43 + }; 44 + 45 + export const HexKeyboard = (props: { 46 + value: Color | undefined; 47 + setValue: (c: Color) => void; 48 + }) => { 49 + let hexString = props.value 50 + ? props.value.toString("hex").toUpperCase() 51 + : "#000000"; 52 + 53 + let [draft, setDraft] = useState(hexString); 54 + let committed = useRef(false); 55 + let copyButtonRef = useRef<HTMLButtonElement>(null); 56 + let smoker = useSmoker(); 57 + 58 + let syncDraftToValue = useCallback(() => { 59 + let hex = props.value 60 + ? props.value.toString("hex").toUpperCase() 61 + : "#000000"; 62 + setDraft(hex); 63 + committed.current = true; 64 + }, [props.value]); 65 + 66 + let handleKey = (key: string) => { 67 + if (committed.current) { 68 + setDraft("#" + key); 69 + committed.current = false; 70 + return; 71 + } 72 + // Max hex length is 7 chars including # 73 + if (draft.length >= 7) return; 74 + setDraft((prev) => prev + key); 75 + }; 76 + 77 + let handleDelete = () => { 78 + committed.current = false; 79 + setDraft((prev) => { 80 + // Don't delete the # prefix 81 + if (prev.length <= 1) return prev; 82 + return prev.slice(0, -1); 83 + }); 84 + }; 85 + 86 + let handleSubmit = () => { 87 + try { 88 + // Pad short values: if user typed #ABC, expand to #AABBCC 89 + let raw = draft.replace("#", ""); 90 + let padded = raw; 91 + if (raw.length === 3) { 92 + padded = raw[0] + raw[0] + raw[1] + raw[1] + raw[2] + raw[2]; 93 + } else if (raw.length < 6) { 94 + padded = raw.padEnd(6, "0"); 95 + } 96 + let color = parseColor("#" + padded); 97 + props.setValue(color); 98 + setDraft("#" + padded.toUpperCase()); 99 + committed.current = true; 100 + } catch { 101 + // Invalid color, reset draft 102 + syncDraftToValue(); 103 + } 104 + }; 105 + 106 + let isValidPartialHex = /^#[0-9A-Fa-f]{0,6}$/.test(draft); 107 + let isSubmittable = 108 + /^#[0-9A-Fa-f]{3}$/.test(draft) || /^#[0-9A-Fa-f]{6}$/.test(draft); 109 + 110 + let handleCopy = () => { 111 + navigator.clipboard.writeText(draft); 112 + let rect = copyButtonRef.current?.getBoundingClientRect(); 113 + smoker({ 114 + position: { 115 + x: rect ? rect.left + rect.width / 2 : 0, 116 + y: rect ? rect.top - 8 : 0, 117 + }, 118 + text: "Copied!", 119 + }); 120 + }; 121 + 122 + let handlePaste = async () => { 123 + try { 124 + let text = await navigator.clipboard.readText(); 125 + let cleaned = text.trim(); 126 + if (!cleaned.startsWith("#")) cleaned = "#" + cleaned; 127 + cleaned = cleaned.toUpperCase(); 128 + if (/^#[0-9A-Fa-f]{0,6}$/.test(cleaned)) { 129 + setDraft(cleaned); 130 + committed.current = false; 131 + } 132 + } catch { 133 + // Clipboard read failed, do nothing 134 + } 135 + }; 136 + 137 + return ( 138 + <Popover 139 + align="center" 140 + className="p-2! w-fit bg-white! text-[#595959] border-[#CCCCCC]!" 141 + arrowFill="white" 142 + border="#CCCCCC" 143 + onOpenChange={(open) => { 144 + if (open) { 145 + syncDraftToValue(); 146 + } 147 + }} 148 + trigger={ 149 + <div className="w-[72px] text-left bg-transparent outline-hidden truncate"> 150 + {hexString} 151 + </div> 152 + } 153 + > 154 + <div className="flex flex-col gap-2"> 155 + {/* Display current draft value */} 156 + <div 157 + className={` 158 + flex items-center 159 + rounded-md border px-2 py-1 text-sm font-mono 160 + text-[#595959]! 161 + ${isValidPartialHex ? "border-[#CCCCCC]" : "border-red-400"} 162 + `} 163 + > 164 + <span className="select-all">{draft}</span> 165 + </div> 166 + 167 + {/* Copy / Paste buttons */} 168 + <div className="grid grid-cols-2 gap-1"> 169 + <button 170 + ref={copyButtonRef} 171 + onPointerDown={(e) => e.preventDefault()} 172 + onClick={handleCopy} 173 + className="h-8 gap-2 rounded-md border border-[#CCCCCC] text-sm text-[#969696] font-bold flex items-center justify-center" 174 + > 175 + <CopyTiny /> Copy 176 + </button> 177 + <button 178 + onPointerDown={(e) => e.preventDefault()} 179 + onClick={handlePaste} 180 + className="h-8 gap-2 rounded-md border border-[#CCCCCC] text-sm text-[#969696] font-bold flex items-center justify-center" 181 + > 182 + <PasteTiny /> 183 + Paste 184 + </button> 185 + </div> 186 + 187 + <hr className="border-[#CCCCCC]" /> 188 + 189 + {/* Hex keyboard grid — 6 columns */} 190 + <div className="grid grid-cols-6 gap-1"> 191 + {/* Row 1: 1 2 3 A B C */} 192 + {HEX_ROWS[0].map((key) => ( 193 + <HexKey key={key} onClick={() => handleKey(key)}> 194 + {key} 195 + </HexKey> 196 + ))} 197 + 198 + {/* Row 2: 4 5 6 D E F */} 199 + {HEX_ROWS[1].map((key) => ( 200 + <HexKey key={key} onClick={() => handleKey(key)}> 201 + {key} 202 + </HexKey> 203 + ))} 204 + 205 + {/* Row 3: 7 8 9 0 [delete] [submit] */} 206 + {BOTTOM_ROW.map((key) => ( 207 + <HexKey key={key} onClick={() => handleKey(key)}> 208 + {key} 209 + </HexKey> 210 + ))} 211 + <HexKey 212 + onClick={handleDelete} 213 + ariaLabel="Delete" 214 + className="bg-accent-1! border-accent-1! text-accent-2!" 215 + > 216 + &#x232B; 217 + </HexKey> 218 + <HexKey 219 + onClick={handleSubmit} 220 + disabled={!isSubmittable && !isValidPartialHex} 221 + ariaLabel="Apply" 222 + className="bg-accent-1! border-accent-1! text-accent-2!" 223 + > 224 + <GoToArrow /> 225 + </HexKey> 226 + </div> 227 + </div> 228 + </Popover> 229 + ); 230 + };
+162
components/ThemeManager/Pickers/NumPad.tsx
··· 1 + "use client"; 2 + 3 + import { useState, useCallback, useRef } from "react"; 4 + import { Color } from "react-aria-components"; 5 + import { Popover } from "components/Popover"; 6 + import { GoToArrow } from "components/Icons/GoToArrow"; 7 + 8 + const NUM_ROWS = [ 9 + ["1", "2", "3"], 10 + ["4", "5", "6"], 11 + ["7", "8", "9"], 12 + ] as const; 13 + 14 + const NumKey = (props: { 15 + onClick: () => void; 16 + children: React.ReactNode; 17 + className?: string; 18 + disabled?: boolean; 19 + ariaLabel?: string; 20 + }) => { 21 + return ( 22 + <button 23 + onPointerDown={(e) => e.preventDefault()} 24 + onClick={props.onClick} 25 + disabled={props.disabled} 26 + aria-label={props.ariaLabel} 27 + className={` 28 + h-10 w-10 rounded-md 29 + font-bold 30 + flex items-center justify-center 31 + border border-[#CCCCCC] text-[#969696] 32 + disabled:bg-[#DBDBDB] disabled:border-[#DBDBDB] disabled:text-[#CCCCCC] disabled:cursor-not-allowed 33 + ${props.className ?? ""} 34 + `} 35 + > 36 + {props.children} 37 + </button> 38 + ); 39 + }; 40 + 41 + export const NumPad = (props: { 42 + value: Color | undefined; 43 + setValue: (c: Color) => void; 44 + }) => { 45 + let alphaPercent = props.value 46 + ? Math.round(props.value.getChannelValue("alpha") * 100) 47 + : 100; 48 + 49 + let displayValue = `${alphaPercent}%`; 50 + 51 + let [draft, setDraft] = useState(String(alphaPercent)); 52 + let committed = useRef(false); 53 + 54 + let syncDraftToValue = useCallback(() => { 55 + let percent = props.value 56 + ? Math.round(props.value.getChannelValue("alpha") * 100) 57 + : 100; 58 + setDraft(String(percent)); 59 + committed.current = true; 60 + }, [props.value]); 61 + 62 + let handleKey = (key: string) => { 63 + if (committed.current) { 64 + committed.current = false; 65 + setDraft(key); 66 + return; 67 + } 68 + setDraft((prev) => { 69 + let next = prev === "0" ? key : prev + key; 70 + // Don't allow more than 3 digits (max 100) 71 + if (next.length > 3) return prev; 72 + let num = parseInt(next, 10); 73 + if (num > 100) return prev; 74 + return next; 75 + }); 76 + }; 77 + 78 + let handleDelete = () => { 79 + committed.current = false; 80 + setDraft((prev) => { 81 + if (prev.length <= 1) return "0"; 82 + return prev.slice(0, -1); 83 + }); 84 + }; 85 + 86 + let handleSubmit = () => { 87 + let num = parseInt(draft, 10); 88 + if (isNaN(num) || num < 0 || num > 100) { 89 + syncDraftToValue(); 90 + return; 91 + } 92 + if (props.value) { 93 + let updated = props.value.withChannelValue("alpha", num / 100); 94 + props.setValue(updated); 95 + } 96 + committed.current = true; 97 + }; 98 + 99 + let isValid = /^\d{1,3}$/.test(draft) && parseInt(draft, 10) <= 100; 100 + 101 + return ( 102 + <Popover 103 + align="center" 104 + className="p-2! w-fit bg-white! text-[#595959] border-[#CCCCCC]!" 105 + arrowFill="white" 106 + border="#CCCCCC" 107 + onOpenChange={(open) => { 108 + if (open) { 109 + syncDraftToValue(); 110 + } 111 + }} 112 + trigger={ 113 + <button className="w-[64px] pl-[6px] text-left bg-transparent outline-hidden truncate"> 114 + {displayValue} 115 + </button> 116 + } 117 + > 118 + <div className="flex flex-col gap-2"> 119 + {/* Display current draft value */} 120 + <div 121 + className={` 122 + flex items-center 123 + rounded-md border px-2 py-1 text-sm font-mono 124 + text-[#595959]! 125 + ${isValid ? "border-[#CCCCCC]" : "border-red-400"} 126 + `} 127 + > 128 + <span className="select-all">{draft}%</span> 129 + </div> 130 + 131 + {/* Numpad grid — 3 columns */} 132 + <div className="grid grid-cols-3 gap-1"> 133 + {NUM_ROWS.map((row) => 134 + row.map((key) => ( 135 + <NumKey key={key} onClick={() => handleKey(key)}> 136 + {key} 137 + </NumKey> 138 + )), 139 + )} 140 + 141 + {/* Bottom row: 0, delete, submit */} 142 + <NumKey onClick={() => handleKey("0")}>0</NumKey> 143 + <NumKey 144 + onClick={handleDelete} 145 + ariaLabel="Delete" 146 + className="bg-accent-1! border-accent-1! text-accent-2!" 147 + > 148 + &#x232B; 149 + </NumKey> 150 + <NumKey 151 + onClick={handleSubmit} 152 + disabled={!isValid} 153 + ariaLabel="Apply" 154 + className="bg-accent-1! border-accent-1! text-accent-2!" 155 + > 156 + <GoToArrow /> 157 + </NumKey> 158 + </div> 159 + </div> 160 + </Popover> 161 + ); 162 + };
+3 -2
components/ThemeManager/PubPickers/PubBackgroundPickers.tsx
··· 103 103 alpha={props.hasPageBackground ? true : false} 104 104 /> 105 105 )} 106 - <hr className="border-border-light" /> 106 + <hr className="border-[#CCCCCC] my-0.5" /> 107 107 <div className="flex gap-2 items-center"> 108 108 <Toggle 109 109 toggle={props.hasPageBackground} ··· 145 145 <> 146 146 <div className="bgPickerColorLabel flex gap-2 items-center"> 147 147 <button 148 + type="button" 148 149 disabled={props.disabled} 149 150 onClick={() => { 150 151 if (props.openPicker === props.thisPicker) { ··· 170 171 <div className="italic text-[#8C8C8C]">image</div> 171 172 </button> 172 173 <div className="flex gap-1 text-[#8C8C8C]"> 173 - <button onClick={() => props.setBgImage(null)}> 174 + <button type="button" onClick={() => props.setBgImage(null)}> 174 175 <DeleteSmall /> 175 176 </button> 176 177 <label className="hover:cursor-pointer ">
+2 -2
components/ThemeManager/PubPickers/PubPageWidthSetter.tsx
··· 40 40 let open = props.openPicker == props.thisPicker; 41 41 42 42 return ( 43 - <div className="pageWidthSetter flex flex-col gap-2 px-2 py-[6px] border border-[#CCCCCC] rounded-md bg-white"> 43 + <div className="pageWidthSetter flex flex-col gap-2"> 44 44 <button 45 45 type="button" 46 - className="font-bold text-[#000000] shrink-0 grow-0 w-full flex gap-2 text-left items-center" 46 + className="font-bold text-[#595959] shrink-0 grow-0 w-full flex gap-2 text-left items-center" 47 47 onClick={() => { 48 48 if (!open) { 49 49 props.setOpenPicker(props.thisPicker);
+149
components/ThemeManager/PubPickers/PubPresetPicker.tsx
··· 1 + import { useCallback } from "react"; 2 + import { parseColor } from "react-aria-components"; 3 + import { PubThemeEditorState } from "../PubThemeSetter"; 4 + 5 + type PresetTheme = { 6 + name: string; 7 + bgLeaflet: string; 8 + bgPage: string; 9 + showPageBackground: boolean; 10 + primary: string; 11 + accent1: string; 12 + accent2: string; 13 + headingFont: string | undefined; 14 + bodyFont: string | undefined; 15 + pageWidth: number; 16 + }; 17 + 18 + const presetThemes: PresetTheme[] = [ 19 + { 20 + name: "Default", 21 + bgLeaflet: "#FDFCFA", 22 + bgPage: "#FDFCFA", 23 + showPageBackground: false, 24 + primary: "#272727", 25 + accent1: "#57822B", 26 + accent2: "#FFFFFF", 27 + headingFont: undefined, 28 + bodyFont: undefined, 29 + pageWidth: 624, 30 + }, 31 + { 32 + name: "Minimal", 33 + bgLeaflet: "#F0F2F2", 34 + bgPage: "#F0F2F2", 35 + showPageBackground: false, 36 + primary: "#292929", 37 + accent1: "#292929", 38 + accent2: "#F0F2F2", 39 + headingFont: undefined, 40 + bodyFont: "source-sans", 41 + pageWidth: 768, 42 + }, 43 + { 44 + name: "Bookish", 45 + bgLeaflet: "#FFFBEB", 46 + bgPage: "#FFFBEB", 47 + showPageBackground: false, 48 + primary: "#3B3131", 49 + accent1: "#B50000", 50 + accent2: "#F0F2F2", 51 + headingFont: "lora", 52 + bodyFont: "lora", 53 + pageWidth: 768, 54 + }, 55 + { 56 + name: "Dark", 57 + bgLeaflet: "#1A1917", 58 + bgPage: "#1A1917", 59 + showPageBackground: false, 60 + primary: "#FFFFFF", 61 + accent1: "#234D26", 62 + accent2: "#C3D6BC", 63 + headingFont: undefined, 64 + bodyFont: undefined, 65 + pageWidth: 768, 66 + }, 67 + { 68 + name: "Colorful", 69 + bgLeaflet: "#2665C9", 70 + bgPage: "rgba(247, 223, 124, 1)", 71 + showPageBackground: true, 72 + primary: "#363131", 73 + accent1: "#ED3700", 74 + accent2: "#FFFFFF", 75 + headingFont: undefined, 76 + bodyFont: undefined, 77 + pageWidth: 768, 78 + }, 79 + { 80 + name: "Rose", 81 + bgLeaflet: "#FAD2D7", 82 + bgPage: "rgba(252, 246, 245, 0.68)", 83 + showPageBackground: true, 84 + primary: "#613E3E", 85 + accent1: "#74C200", 86 + accent2: "#FFFFFF", 87 + headingFont: "montserrat", 88 + bodyFont: undefined, 89 + pageWidth: 768, 90 + }, 91 + ]; 92 + 93 + export function PresetThemePicker(props: { state: PubThemeEditorState }) { 94 + let { 95 + setTheme, 96 + setImage, 97 + setPageWidth, 98 + setHeadingFont, 99 + setBodyFont, 100 + setShowPageBackground, 101 + } = props.state; 102 + 103 + let applyPreset = useCallback( 104 + (preset: PresetTheme) => { 105 + setTheme(() => ({ 106 + bgLeaflet: parseColor(preset.bgLeaflet), 107 + bgPage: parseColor(preset.bgPage), 108 + primary: parseColor(preset.primary), 109 + accent1: parseColor(preset.accent1), 110 + accent2: parseColor(preset.accent2), 111 + })); 112 + setShowPageBackground(preset.showPageBackground); 113 + setPageWidth(preset.pageWidth); 114 + setHeadingFont(preset.headingFont); 115 + setBodyFont(preset.bodyFont); 116 + setImage(null); 117 + }, 118 + [ 119 + setTheme, 120 + setShowPageBackground, 121 + setPageWidth, 122 + setHeadingFont, 123 + setBodyFont, 124 + setImage, 125 + ], 126 + ); 127 + 128 + return ( 129 + <div className="pubPresetPicker flex flex-col pb-2"> 130 + <div className="text-sm text-[#969696] -mb-0.5">PRESETS</div> 131 + <div className="flex gap-2 items-center px-2 py-2 border border-[#CCCCCC] rounded-md bg-white mb-1"> 132 + {presetThemes.map((preset) => ( 133 + <button 134 + key={preset.name} 135 + type="button" 136 + title={preset.name} 137 + onClick={() => applyPreset(preset)} 138 + className="w-8 h-8 rounded-full border border-[#CCCCCC] shrink-0 overflow-hidden outline-offset-1 outline-transparent hover:outline-[#CCCCCC] outline-2" 139 + style={{ 140 + background: `linear-gradient(135deg, ${preset.bgLeaflet} 50%, ${preset.accent1} 50%)`, 141 + }} 142 + > 143 + <span className="sr-only">{preset.name}</span> 144 + </button> 145 + ))} 146 + </div> 147 + </div> 148 + ); 149 + }
+2 -11
app/(home-pages)/home/LeafletList/LeafletListItem.tsx
··· 48 48 ref={previewRef} 49 49 className={`relative flex gap-3 w-full 50 50 ${props.isHidden ? "hidden" : "flex"} 51 - ${cardBorderHidden ? "" : "px-2 py-1 block-border hover:outline-border relative"}`} 51 + ${cardBorderHidden ? "" : "px-2 py-1 block-border hover:outline-border! relative"}`} 52 52 style={{ 53 53 backgroundColor: cardBorderHidden 54 54 ? "transparent" ··· 84 84 className={` 85 85 relative 86 86 flex flex-col gap-1 p-1 h-52 w-full 87 - block-border border-border! hover:outline-border 87 + block-border border-border! hover:outline-border! 88 88 ${props.isHidden ? "hidden" : "flex"} 89 89 `} 90 90 style={{ ··· 109 109 loggedIn={props.loggedIn} 110 110 /> 111 111 </div> 112 - ); 113 - }; 114 - 115 - const LeafletLink = (props: { id: string; className: string }) => { 116 - return ( 117 - <SpeedyLink 118 - href={`/${props.id}`} 119 - className={`no-underline hover:no-underline! text-primary ${props.className}`} 120 - /> 121 112 ); 122 113 };
+2 -1
app/(home-pages)/p/[didOrHandle]/PostsContent.tsx
··· 1 1 "use client"; 2 2 3 + import { EmptyState } from "components/EmptyState"; 3 4 import { PostListing } from "components/PostListing"; 4 5 import type { Post } from "app/(home-pages)/reader/getReaderFeed"; 5 6 import type { Cursor } from "./getProfilePosts"; ··· 64 65 const allPosts = data ? data.flatMap((page) => page.posts) : []; 65 66 66 67 if (allPosts.length === 0 && !isValidating) { 67 - return <div className="text-tertiary text-center py-4">No posts yet</div>; 68 + return <EmptyState title="No posts yet" />; 68 69 } 69 70 70 71 return (
+1 -1
app/(home-pages)/p/[didOrHandle]/ProfileHeader.tsx
··· 45 45 46 46 return ( 47 47 <div 48 - className={`profileHeader flex flex-col relative `} 48 + className={`profileHeader flex flex-col relative pb-6`} 49 49 id="profile-header" 50 50 > 51 51 {!props.popover && <ProfileLinks handle={props.profile.handle || ""} />}
+3 -3
app/(home-pages)/p/[didOrHandle]/ProfileLayout.tsx
··· 10 10 className={` 11 11 ${ 12 12 cardBorderHidden 13 - ? "" 14 - : "overflow-y-scroll h-full border border-border-light rounded-lg bg-bg-page px-3 sm:px-4" 13 + ? "bg-transparent" 14 + : "overflow-y-scroll h-full border border-border-light rounded-lg! container px-3 sm:px-4 sm:-mt-2" 15 15 } 16 - max-w-prose mx-auto w-full 16 + max-w-prose w-full 17 17 flex flex-col pb-3 18 18 text-center 19 19 `}
+5 -3
app/(home-pages)/p/[didOrHandle]/ProfileTabs.tsx
··· 41 41 const bgColor = cardBorderHidden ? "var(--bg-leaflet)" : "var(--bg-page)"; 42 42 43 43 return ( 44 - <div className="flex flex-col w-full sticky top-3 sm:top-4 z-20 pt-6"> 44 + <div 45 + className={`flex flex-col w-full sticky z-20 ${cardBorderHidden ? "top-3 sm:top-0" : "top-3"} `} 46 + > 45 47 <div 46 48 style={ 47 49 scrollPosWithinTabContent < 20 ··· 62 64 scrollPosWithinTabContent < 20 63 65 ? { 64 66 backgroundColor: !cardBorderHidden 65 - ? `rgba(${bgColor}, ${scrollPosWithinTabContent / 60 + 0.75})` 67 + ? `rgba(${bgColor}, ${scrollPosWithinTabContent / 60})` 66 68 : `rgba(${bgColor}, ${scrollPosWithinTabContent / 20})`, 67 69 paddingLeft: !cardBorderHidden 68 70 ? "4px" ··· 110 112 className={`pubTabs px-1 py-0 flex gap-1 items-center rounded-md hover:cursor-pointer hover:no-underline! ${ 111 113 props.selected 112 114 ? "text-accent-2 bg-accent-1 font-bold -mb-px" 113 - : "text-tertiary" 115 + : "text-secondary" 114 116 }`} 115 117 > 116 118 {props.name}
+14 -26
app/(home-pages)/tag/[tag]/page.tsx
··· 1 1 import { DashboardLayout } from "components/PageLayouts/DashboardLayout"; 2 - import { Tag } from "components/Tags"; 3 2 import { PostListing } from "components/PostListing"; 4 3 import { getDocumentsByTag } from "./getDocumentsByTag"; 5 - import { TagTiny } from "components/Icons/TagTiny"; 6 4 import { Metadata } from "next"; 7 5 8 6 export async function generateMetadata(props: { ··· 41 39 posts: Awaited<ReturnType<typeof getDocumentsByTag>>["posts"]; 42 40 }) => { 43 41 return ( 44 - <div className="max-w-prose mx-auto w-full grow shrink-0"> 45 - <div className="discoverHeader flex flex-col gap-3 items-center text-center pt-2 px-4"> 46 - <TagHeader tag={props.tag} postCount={props.posts.length} /> 47 - </div> 48 - <div className="pt-6 flex flex-col gap-3"> 42 + <div className="max-w-prose w-full grow shrink-0"> 43 + <h1 className="sm:text-xl text-lg">Tag: {props.tag}</h1> 44 + 45 + <div className="pt-4 flex flex-col gap-4"> 49 46 {props.posts.length === 0 ? ( 50 - <EmptyState tag={props.tag} /> 47 + <NoPostsForTag tag={props.tag} /> 51 48 ) : ( 52 - props.posts.map((post) => ( 53 - <PostListing key={post.documents.uri} {...post} /> 54 - )) 49 + <> 50 + <div className="text-tertiary text-sm px-3"> 51 + {props.posts.length} {props.posts.length === 1 ? "post" : "posts"} 52 + </div> 53 + {props.posts.map((post) => ( 54 + <PostListing key={post.documents.uri} {...post} /> 55 + ))} 56 + </> 55 57 )} 56 58 </div> 57 59 </div> 58 60 ); 59 61 }; 60 62 61 - const TagHeader = (props: { tag: string; postCount: number }) => { 62 - return ( 63 - <div className="flex flex-col leading-tight items-center"> 64 - <div className="flex items-center gap-3 text-xl font-bold text-primary"> 65 - <TagTiny className="scale-150" /> 66 - <h1>{props.tag}</h1> 67 - </div> 68 - <div className="text-tertiary text-sm"> 69 - {props.postCount} {props.postCount === 1 ? "post" : "posts"} 70 - </div> 71 - </div> 72 - ); 73 - }; 74 - 75 - const EmptyState = (props: { tag: string }) => { 63 + const NoPostsForTag = (props: { tag: string }) => { 76 64 return ( 77 65 <div className="flex flex-col gap-2 items-center justify-center p-8 text-center"> 78 66 <div className="text-tertiary">
+23
app/api/webhooks/stripe/route.ts
··· 57 57 break; 58 58 } 59 59 60 + case "invoice.payment_succeeded": { 61 + const invoice = event.data.object; 62 + const sub = invoice.parent?.subscription_details?.subscription; 63 + const subId = 64 + typeof sub === "string" 65 + ? sub 66 + : typeof sub === "object" && sub 67 + ? sub.id 68 + : ""; 69 + if (subId) { 70 + await inngest.send({ 71 + name: "stripe/invoice.payment.succeeded", 72 + data: { 73 + invoiceId: invoice.id, 74 + subscriptionId: subId, 75 + customerId: invoice.customer as string, 76 + }, 77 + }); 78 + await handleSubscriptionUpdated(subId); 79 + } 80 + break; 81 + } 82 + 60 83 case "invoice.payment_failed": { 61 84 const invoice = event.data.object; 62 85 const sub = invoice.parent?.subscription_details?.subscription;
+253
app/lish/[did]/[publication]/PublicationContent.tsx
··· 1 + import React from "react"; 2 + import { AtUri } from "@atproto/syntax"; 3 + import { 4 + getPublicationURL, 5 + getDocumentURL, 6 + } from "app/lish/createPub/getPublicationURL"; 7 + import { SubscribeWithBluesky } from "app/lish/Subscribe"; 8 + import { InteractionPreview } from "components/InteractionsPreview"; 9 + import { LocalizedDate } from "./LocalizedDate"; 10 + import { PublicationHomeLayout } from "./PublicationHomeLayout"; 11 + import { PublicationAuthor } from "./PublicationAuthor"; 12 + import { 13 + normalizePublicationRecord, 14 + normalizeDocumentRecord, 15 + } from "src/utils/normalizeRecords"; 16 + import { getFirstParagraph } from "src/utils/getFirstParagraph"; 17 + import { FontLoader } from "components/FontLoader"; 18 + import { SpeedyLink } from "components/SpeedyLink"; 19 + 20 + type FakePost = { 21 + title: string; 22 + description: string; 23 + date: React.ReactNode; 24 + }; 25 + 26 + export const PublicationContent = ({ 27 + record, 28 + publication, 29 + did, 30 + profile, 31 + showPageBackground, 32 + fakePosts, 33 + }: { 34 + record: ReturnType<typeof normalizePublicationRecord>; 35 + publication: { 36 + uri: string; 37 + name: string; 38 + identity_did: string; 39 + record: unknown; 40 + publication_subscriptions: { identity: string }[]; 41 + documents_in_publications: { 42 + documents: { 43 + uri: string; 44 + data: unknown; 45 + comments_on_documents: { count: number }[]; 46 + document_mentions_in_bsky: { count: number }[]; 47 + recommends_on_documents: { count: number }[]; 48 + } | null; 49 + }[]; 50 + }; 51 + did: string; 52 + profile: { did: string; displayName?: string; handle: string } | undefined; 53 + showPageBackground: boolean | undefined; 54 + fakePosts?: FakePost[]; 55 + }) => { 56 + return ( 57 + <> 58 + <FontLoader 59 + headingFontId={record?.theme?.headingFont} 60 + bodyFontId={record?.theme?.bodyFont} 61 + /> 62 + <PublicationHomeLayout 63 + uri={publication.uri} 64 + showPageBackground={!!showPageBackground} 65 + > 66 + <PublicationHeader 67 + iconUrl={ 68 + record?.icon 69 + ? `/api/atproto_images?did=${did}&cid=${(record.icon.ref as unknown as { $link: string })["$link"]}` 70 + : undefined 71 + } 72 + publicationName={publication.name} 73 + description={record?.description} 74 + author={ 75 + profile ? ( 76 + <PublicationAuthor 77 + did={profile.did} 78 + displayName={profile.displayName} 79 + handle={profile.handle} 80 + /> 81 + ) : undefined 82 + } 83 + subscribeButton={ 84 + <SubscribeWithBluesky 85 + base_url={getPublicationURL(publication)} 86 + pubName={publication.name} 87 + pub_uri={publication.uri} 88 + subscribers={publication.publication_subscriptions} 89 + /> 90 + } 91 + /> 92 + <div className="publicationPostList w-full flex flex-col gap-4"> 93 + {fakePosts && 94 + fakePosts.map((post, i) => ( 95 + <PublicationPostItem 96 + key={i} 97 + title={post.title} 98 + description={post.description} 99 + date={post.date} 100 + /> 101 + ))} 102 + {!fakePosts && 103 + publication.documents_in_publications 104 + .filter((d) => !!d?.documents) 105 + .sort((a, b) => { 106 + const aRecord = normalizeDocumentRecord(a.documents?.data); 107 + const bRecord = normalizeDocumentRecord(b.documents?.data); 108 + const aDate = aRecord?.publishedAt 109 + ? new Date(aRecord.publishedAt) 110 + : new Date(0); 111 + const bDate = bRecord?.publishedAt 112 + ? new Date(bRecord.publishedAt) 113 + : new Date(0); 114 + return bDate.getTime() - aDate.getTime(); // Sort by most recent first 115 + }) 116 + .map((doc) => { 117 + if (!doc.documents) return null; 118 + const doc_record = normalizeDocumentRecord(doc.documents.data); 119 + if (!doc_record) return null; 120 + let uri = new AtUri(doc.documents.uri); 121 + let quotes = 122 + doc.documents.document_mentions_in_bsky[0].count || 0; 123 + let comments = 124 + record?.preferences?.showComments === false 125 + ? 0 126 + : doc.documents.comments_on_documents[0].count || 0; 127 + let recommends = 128 + doc.documents.recommends_on_documents?.[0]?.count || 0; 129 + let tags = doc_record.tags || []; 130 + 131 + const docUrl = getDocumentURL( 132 + doc_record, 133 + doc.documents.uri, 134 + publication, 135 + ); 136 + return ( 137 + <React.Fragment key={doc.documents?.uri}> 138 + <PublicationPostItem 139 + href={docUrl} 140 + title={doc_record.title} 141 + description={ 142 + doc_record.description || getFirstParagraph(doc_record) 143 + } 144 + date={ 145 + doc_record.publishedAt ? ( 146 + <LocalizedDate 147 + dateString={doc_record.publishedAt} 148 + options={{ 149 + year: "numeric", 150 + month: "long", 151 + day: "2-digit", 152 + }} 153 + /> 154 + ) : undefined 155 + } 156 + interactions={ 157 + <InteractionPreview 158 + quotesCount={quotes} 159 + commentsCount={comments} 160 + recommendsCount={recommends} 161 + documentUri={doc.documents.uri} 162 + tags={tags} 163 + postUrl={docUrl} 164 + showComments={ 165 + record?.preferences?.showComments !== false 166 + } 167 + showMentions={ 168 + record?.preferences?.showMentions !== false 169 + } 170 + showRecommends={ 171 + record?.preferences?.showRecommends !== false 172 + } 173 + /> 174 + } 175 + /> 176 + </React.Fragment> 177 + ); 178 + })} 179 + </div> 180 + </PublicationHomeLayout> 181 + </> 182 + ); 183 + }; 184 + 185 + export function PublicationHeader(props: { 186 + iconUrl?: string; 187 + publicationName: string; 188 + description?: string; 189 + author?: React.ReactNode; 190 + subscribeButton?: React.ReactNode; 191 + }) { 192 + return ( 193 + <div className="pubHeader flex flex-col pb-8 w-full text-center justify-center "> 194 + {props.iconUrl && ( 195 + <div 196 + className="shrink-0 w-10 h-10 rounded-full mx-auto" 197 + style={{ 198 + backgroundImage: `url(${props.iconUrl})`, 199 + backgroundRepeat: "no-repeat", 200 + backgroundPosition: "center", 201 + backgroundSize: "cover", 202 + }} 203 + /> 204 + )} 205 + <h2 className="text-accent-contrast sm:text-xl text-[22px] pt-1 "> 206 + {props.publicationName} 207 + </h2> 208 + <p className="sm:text-lg text-secondary">{props.description} </p> 209 + {props.author} 210 + <div className="sm:pt-4 pt-4">{props.subscribeButton}</div> 211 + </div> 212 + ); 213 + } 214 + 215 + export function PublicationPostItem(props: { 216 + href?: string; 217 + title?: string; 218 + description?: string; 219 + date?: React.ReactNode; 220 + interactions?: React.ReactNode; 221 + }) { 222 + const content = ( 223 + <> 224 + {props.title && <h3 className="text-primary">{props.title}</h3>} 225 + <p className="italic text-secondary line-clamp-3">{props.description}</p> 226 + </> 227 + ); 228 + 229 + return ( 230 + <> 231 + <div className="flex w-full grow flex-col "> 232 + {props.href ? ( 233 + <SpeedyLink 234 + href={props.href} 235 + className="publishedPost no-underline! flex flex-col" 236 + > 237 + {content} 238 + </SpeedyLink> 239 + ) : ( 240 + <div className="publishedPost no-underline! flex flex-col"> 241 + {content} 242 + </div> 243 + )} 244 + 245 + <div className="justify-between w-full text-sm text-tertiary flex gap-1 flex-wrap pt-2 items-center"> 246 + <p className="text-sm text-tertiary ">{props.date} </p> 247 + {props.interactions} 248 + </div> 249 + </div> 250 + <hr className="last:hidden border-border-light" /> 251 + </> 252 + ); 253 + }
+2 -2
app/lish/[did]/[publication]/PublicationHomeLayout.tsx
··· 11 11 return ( 12 12 <div 13 13 ref={props.showPageBackground ? null : ref} 14 - className={`pubWrapper flex flex-col sm:py-6 h-full ${props.showPageBackground ? "max-w-(--page-width-units) mx-auto sm:px-0 px-[6px] py-2" : "w-full overflow-y-scroll"}`} 14 + className={`pubWrapper flex flex-col sm:py-6 h-full ${props.showPageBackground ? "max-w-(--page-width-units) mx-auto px-0 py-2" : "w-full overflow-y-scroll"}`} 15 15 > 16 16 <div 17 17 ref={!props.showPageBackground ? null : ref} 18 - className={`pub max-w-(--page-width-units) w-[1000px] mx-auto px-3 sm:px-4 py-5 ${props.showPageBackground ? "overflow-auto h-full bg-[rgba(var(--bg-page),var(--bg-page-alpha))] border border-border rounded-lg" : "h-fit"}`} 18 + className={`pubContent sm:max-w-(--page-width-units) w-full mx-auto px-3 sm:px-4 py-5 ${props.showPageBackground ? "overflow-auto h-full bg-[rgba(var(--bg-page),var(--bg-page-alpha))] border border-border rounded-lg" : "h-fit"}`} 19 19 > 20 20 {props.children} 21 21 </div>
+37 -25
app/lish/[did]/[publication]/UpgradeModal.tsx
··· 3 3 import { useState } from "react"; 4 4 import { createCheckoutSession } from "actions/createCheckoutSession"; 5 5 import { DotLoader } from "components/utils/DotLoader"; 6 + import { ToggleGroup } from "components/ToggleGroup"; 6 7 7 8 export const UpgradeContent = () => { 8 9 let [cadence, setCadence] = useState<"year" | "month">("year"); ··· 37 38 </div> 38 39 <div className="sm:w-64 w-full accent-container flex justify-center items-center"> 39 40 <div className="flex flex-col justify-center text-center py-6"> 40 - <div className="flex gap-2 mb-3 p-1 bg-accent-contrast rounded-lg text-sm"> 41 - <button 42 - className={`px-1 rounded-md ${cadence === "year" ? "bg-bg-page font-bold text-accent-contrast" : "bg-transparent text-bg-page"}`} 43 - onClick={() => setCadence("year")} 44 - > 45 - Yearly 46 - </button> 47 - <button 48 - className={`px-1 rounded-md ${cadence === "month" ? "bg-bg-page font-bold text-accent-contrast" : "bg-transparent text-bg-page"}`} 49 - onClick={() => setCadence("month")} 50 - > 51 - Monthly 52 - </button> 53 - </div> 41 + <ToggleGroup 42 + className="mb-3" 43 + value={cadence} 44 + onChange={setCadence} 45 + options={[ 46 + { value: "year", label: "Yearly" }, 47 + { value: "month", label: "Monthly" }, 48 + ]} 49 + /> 54 50 <div className="flex gap-1 items-baseline justify-center"> 55 51 <div className="text-2xl font-bold leading-tight"> 56 52 {cadence === "year" ? "$120" : "$12"} ··· 90 86 ); 91 87 }; 92 88 93 - export const InlineUpgrade = () => { 89 + export const InlineUpgradeToPro = (props: { compact?: boolean }) => { 94 90 return ( 95 - <div className="text-center p-2 accent-container text-secondary"> 96 - <UpgradeModal 97 - asChild 98 - trigger={ 99 - <ButtonPrimary compact fullWidth className="text-sm"> 100 - Upgrade to Leaflet Pro! 101 - </ButtonPrimary> 102 - } 103 - /> 104 - <div className="text-sm leading-snug text-tertiary pt-1"> 91 + <div className="text-center text-secondary"> 92 + <UpgradeToProButton fullWidth compact={props.compact} /> 93 + <div 94 + className={`${props.compact ? `text-sm` : "text-base"} leading-snug text-tertiary pt-2`} 95 + > 105 96 Analytics for all your pubs! <br /> Emails and membership coming soon. 106 97 </div> 107 98 </div> 99 + ); 100 + }; 101 + 102 + export const UpgradeToProButton = (props: { 103 + fullWidth?: boolean; 104 + compact?: boolean; 105 + }) => { 106 + return ( 107 + <UpgradeModal 108 + asChild 109 + trigger={ 110 + <ButtonPrimary 111 + type="button" 112 + compact={props.compact} 113 + fullWidth={props.fullWidth} 114 + className={props.compact ? `text-sm` : "text-base"} 115 + > 116 + Upgrade to Leaflet Pro! 117 + </ButtonPrimary> 118 + } 119 + /> 108 120 ); 109 121 };
+25 -173
app/lish/[did]/[publication]/page.tsx
··· 1 1 import { supabaseServerClient } from "supabase/serverClient"; 2 - import { AtUri } from "@atproto/syntax"; 3 2 import { 4 3 getPublicationURL, 5 4 getDocumentURL, ··· 7 6 import { BskyAgent } from "@atproto/api"; 8 7 import { publicationNameOrUriFilter } from "src/utils/uriHelpers"; 9 8 import React from "react"; 10 - import { 11 - PublicationBackgroundProvider, 12 - PublicationThemeProvider, 13 - } from "components/ThemeManager/PublicationThemeProvider"; 14 9 import { NotFoundLayout } from "components/PageLayouts/NotFoundLayout"; 15 - import { SpeedyLink } from "components/SpeedyLink"; 16 - import { InteractionPreview } from "components/InteractionsPreview"; 17 - import { LocalizedDate } from "./LocalizedDate"; 18 - import { PublicationHomeLayout } from "./PublicationHomeLayout"; 19 - import { PublicationAuthor } from "./PublicationAuthor"; 10 + import { normalizePublicationRecord } from "src/utils/normalizeRecords"; 11 + import { PublicationContent } from "./PublicationContent"; 20 12 import { 21 - normalizePublicationRecord, 22 - normalizeDocumentRecord, 23 - } from "src/utils/normalizeRecords"; 24 - import { getFirstParagraph } from "src/utils/getFirstParagraph"; 25 - import { FontLoader } from "components/FontLoader"; 26 - import { 27 - SubscribeButton, 28 - SubscribeInput, 29 - } from "components/Subscribe/SubscribeButton"; 30 - import { dummy } from "./[rkey]/PostPubInfo"; 31 - import { SubscribeWithBluesky } from "app/lish/Subscribe"; 13 + PublicationThemeProvider, 14 + PublicationBackgroundProvider, 15 + } from "components/ThemeManager/PublicationThemeProvider"; 32 16 33 17 export default async function Publication(props: { 34 18 params: Promise<{ publication: string; did: string }>; 35 19 }) { 36 20 let params = await props.params; 37 - let did = decodeURIComponent(params.did); 21 + const did = decodeURIComponent(params.did); 38 22 if (!did) return <PubNotFound />; 39 - let agent = new BskyAgent({ service: "https://public.api.bsky.app" }); 40 - let publication_name = decodeURIComponent(params.publication); 41 - let [{ data: publications }, { data: profile }] = await Promise.all([ 23 + const agent = new BskyAgent({ service: "https://public.api.bsky.app" }); 24 + const publication_name = decodeURIComponent(params.publication); 25 + const [{ data: publications }, { data: profile }] = await Promise.all([ 42 26 supabaseServerClient 43 27 .from("publications") 44 28 .select( ··· 58 42 .limit(1), 59 43 agent.getProfile({ actor: did }), 60 44 ]); 61 - let publication = publications?.[0]; 45 + const publication = publications?.[0]; 62 46 63 47 const record = normalizePublicationRecord(publication?.record); 64 48 65 - let showPageBackground = record?.theme?.showPageBackground; 49 + const showPageBackground = record?.theme?.showPageBackground; 66 50 67 51 if (!publication) return <PubNotFound />; 68 52 try { 69 53 return ( 70 - <> 71 - <FontLoader 72 - headingFontId={record?.theme?.headingFont} 73 - bodyFontId={record?.theme?.bodyFont} 74 - /> 75 - <PublicationThemeProvider 54 + <PublicationThemeProvider 55 + theme={record?.theme} 56 + pub_creator={publication.identity_did} 57 + > 58 + <PublicationBackgroundProvider 76 59 theme={record?.theme} 77 60 pub_creator={publication.identity_did} 78 61 > 79 - <PublicationBackgroundProvider 80 - theme={record?.theme} 81 - pub_creator={publication.identity_did} 82 - > 83 - <PublicationHomeLayout 84 - uri={publication.uri} 85 - showPageBackground={!!showPageBackground} 86 - > 87 - <div className="pubHeader flex flex-col pb-8 w-full text-center justify-center "> 88 - {record?.icon && ( 89 - <div 90 - className="shrink-0 w-10 h-10 rounded-full mx-auto" 91 - style={{ 92 - backgroundImage: `url(/api/atproto_images?did=${did}&cid=${(record.icon.ref as unknown as { $link: string })["$link"]})`, 93 - backgroundRepeat: "no-repeat", 94 - backgroundPosition: "center", 95 - backgroundSize: "cover", 96 - }} 97 - /> 98 - )} 99 - <h2 className="text-accent-contrast sm:text-xl text-[22px] pt-1 "> 100 - {publication.name} 101 - </h2> 102 - <p className="sm:text-lg text-secondary"> 103 - {record?.description}{" "} 104 - </p> 105 - {profile && ( 106 - <PublicationAuthor 107 - did={profile.did} 108 - displayName={profile.displayName} 109 - handle={profile.handle} 110 - /> 111 - )} 112 - <div className="sm:pt-4 pt-4 max-w-sm mx-auto w-full"> 113 - {/*<SubscribeInput {...dummy} />*/} 114 - 115 - <SubscribeWithBluesky 116 - base_url={getPublicationURL(publication)} 117 - pubName={publication.name} 118 - pub_uri={publication.uri} 119 - subscribers={publication.publication_subscriptions} 120 - /> 121 - </div> 122 - </div> 123 - <div className="publicationPostList w-full flex flex-col gap-4"> 124 - {publication.documents_in_publications 125 - .filter((d) => !!d?.documents) 126 - .sort((a, b) => { 127 - const aRecord = normalizeDocumentRecord(a.documents?.data); 128 - const bRecord = normalizeDocumentRecord(b.documents?.data); 129 - const aDate = aRecord?.publishedAt 130 - ? new Date(aRecord.publishedAt) 131 - : new Date(0); 132 - const bDate = bRecord?.publishedAt 133 - ? new Date(bRecord.publishedAt) 134 - : new Date(0); 135 - return bDate.getTime() - aDate.getTime(); // Sort by most recent first 136 - }) 137 - .map((doc) => { 138 - if (!doc.documents) return null; 139 - const doc_record = normalizeDocumentRecord( 140 - doc.documents.data, 141 - ); 142 - if (!doc_record) return null; 143 - let uri = new AtUri(doc.documents.uri); 144 - let quotes = 145 - doc.documents.document_mentions_in_bsky[0].count || 0; 146 - let comments = 147 - record?.preferences?.showComments === false 148 - ? 0 149 - : doc.documents.comments_on_documents[0].count || 0; 150 - let recommends = 151 - doc.documents.recommends_on_documents?.[0]?.count || 0; 152 - let tags = doc_record.tags || []; 153 - 154 - const docUrl = getDocumentURL( 155 - doc_record, 156 - doc.documents.uri, 157 - publication, 158 - ); 159 - return ( 160 - <React.Fragment key={doc.documents?.uri}> 161 - <div className="flex w-full grow flex-col "> 162 - <SpeedyLink 163 - href={docUrl} 164 - className="publishedPost no-underline! flex flex-col" 165 - > 166 - {doc_record.title && ( 167 - <h3 className="text-primary"> 168 - {doc_record.title} 169 - </h3> 170 - )} 171 - <p className="italic text-secondary line-clamp-3"> 172 - {doc_record.description || 173 - getFirstParagraph(doc_record)} 174 - </p> 175 - </SpeedyLink> 176 - 177 - <div className="justify-between w-full text-sm text-tertiary flex gap-1 flex-wrap pt-2 items-center"> 178 - <p className="text-sm text-tertiary "> 179 - {doc_record.publishedAt && ( 180 - <LocalizedDate 181 - dateString={doc_record.publishedAt} 182 - options={{ 183 - year: "numeric", 184 - month: "long", 185 - day: "2-digit", 186 - }} 187 - /> 188 - )}{" "} 189 - </p> 190 - 191 - <InteractionPreview 192 - quotesCount={quotes} 193 - commentsCount={comments} 194 - recommendsCount={recommends} 195 - documentUri={doc.documents.uri} 196 - tags={tags} 197 - postUrl={docUrl} 198 - showComments={ 199 - record?.preferences?.showComments !== false 200 - } 201 - showMentions={ 202 - record?.preferences?.showMentions !== false 203 - } 204 - showRecommends={ 205 - record?.preferences?.showRecommends !== false 206 - } 207 - /> 208 - </div> 209 - </div> 210 - <hr className="last:hidden border-border-light" /> 211 - </React.Fragment> 212 - ); 213 - })} 214 - </div> 215 - </PublicationHomeLayout> 216 - </PublicationBackgroundProvider> 217 - </PublicationThemeProvider> 218 - </> 62 + <PublicationContent 63 + record={record} 64 + publication={publication} 65 + did={did} 66 + profile={profile} 67 + showPageBackground={showPageBackground} 68 + /> 69 + </PublicationBackgroundProvider> 70 + </PublicationThemeProvider> 219 71 ); 220 72 } catch (e) { 221 73 console.log(e);
+3 -4
app/(home-pages)/p/[didOrHandle]/comments/CommentsContent.tsx
··· 1 1 "use client"; 2 2 3 + import { EmptyState } from "components/EmptyState"; 3 4 import { useEffect, useRef, useMemo } from "react"; 4 5 import useSWRInfinite from "swr/infinite"; 5 6 import { AppBskyActorProfile, AtUri } from "@atproto/api"; ··· 79 80 const allComments = data ? data.flatMap((page) => page.comments) : []; 80 81 81 82 if (allComments.length === 0 && !isValidating) { 82 - return ( 83 - <div className="text-tertiary text-center py-4">No comments yet</div> 84 - ); 83 + return <EmptyState title="No comments yet" className="py-4" />; 85 84 } 86 85 87 86 return ( ··· 206 205 </div> 207 206 )} 208 207 <pre 209 - style={{ wordBreak: "break-word" }} 208 + style={{ wordBreak: "break-word", fontFamily: "inherit" }} 210 209 className="whitespace-pre-wrap text-secondary" 211 210 > 212 211 <BaseTextBlock
+2 -3
app/(home-pages)/p/[didOrHandle]/subscriptions/SubscriptionsContent.tsx
··· 1 1 "use client"; 2 2 3 + import { EmptyState } from "components/EmptyState"; 3 4 import { useEffect, useRef } from "react"; 4 5 import useSWRInfinite from "swr/infinite"; 5 6 import { PubListing } from "app/(home-pages)/p/[didOrHandle]/PubListing"; ··· 75 76 : []; 76 77 77 78 if (allSubscriptions.length === 0 && !isValidating) { 78 - return ( 79 - <div className="text-tertiary text-center py-4">No subscriptions yet</div> 80 - ); 79 + return <EmptyState title="No subscriptions yet" />; 81 80 } 82 81 83 82 return (
-2
app/lish/[did]/[publication]/[rkey]/LinearDocumentPage.tsx
··· 38 38 did, 39 39 profile, 40 40 preferences, 41 - pubRecord, 42 - theme, 43 41 prerenderedCodeBlocks, 44 42 bskyPostData, 45 43 pollData,
+5 -3
app/lish/[did]/[publication]/[rkey]/PostContent.tsx
··· 69 69 return ( 70 70 <div 71 71 //The postContent class is important for QuoteHandler 72 - className={`postContent flex flex-col sm:px-4 px-3 sm:pt-3 pt-2 pb-1 sm:pb-4 ${className}`} 72 + className={`postContent flex flex-col sm:px-4 px-3 sm:pt-3 pt-2 pb-0 ${className}`} 73 73 > 74 74 {blocks.map((b, index) => { 75 75 return ( ··· 162 162 ) 163 163 alignment = "text-center justify-center"; 164 164 165 + let isHeading = PubLeafletBlocksHeader.isMain(b.block); 166 + 165 167 let className = ` 166 168 postBlockWrapper 167 169 min-h-7 168 - mt-1 mb-2 170 + ${isFirst ? "mt-0" : "mt-1"} ${isLast ? "mb-3 sm:mb-4" : isHeading ? "mb-0!" : "mb-2"} 169 171 ${isList && "isListItem mb-0! "} 170 172 ${alignment} 171 173 `; ··· 390 392 return ( 391 393 // all this margin stuff is a highly unfortunate hack so that the border-l on blockquote is the height of just the text rather than the height of the block, which includes padding. 392 394 <blockquote 393 - className={`blockquote py-0! mb-2! ${className} ${PubLeafletBlocksBlockquote.isMain(previousBlock?.block) ? "-mt-2! pt-3!" : "mt-1!"}`} 395 + className={`blockquote py-0! mb-2! ${className} ${PubLeafletBlocksBlockquote.isMain(previousBlock?.block) ? "-mt-3! pt-3!" : "mt-1!"}`} 394 396 {...blockProps} 395 397 > 396 398 <TextBlock
+5 -6
app/lish/[did]/[publication]/dashboard/PublishedPostsLists.tsx
··· 1 1 "use client"; 2 2 import { AtUri } from "@atproto/syntax"; 3 3 import { EditTiny } from "components/Icons/EditTiny"; 4 + import { EmptyState } from "components/EmptyState"; 4 5 5 6 import { 6 7 usePublicationData, ··· 28 29 const pubRecord = useNormalizedPublicationRecord(); 29 30 if (!publication) return null; 30 31 if (!documents || documents.length === 0) 31 - return ( 32 - <div className="italic text-tertiary w-full frosted-container text-center place-items-center flex flex-col gap-3 p-3"> 33 - Nothing's been published yet... 34 - </div> 35 - ); 32 + return <EmptyState title="Nothing's been published yet..." />; 36 33 37 34 // Sort by publishedAt (most recent first) 38 35 const sortedDocuments = [...documents].sort((a, b) => { ··· 89 86 <div className="flex justify-between gap-2"> 90 87 <a className="hover:no-underline!" target="_blank" href={docUrl}> 91 88 <h3 className="text-primary grow leading-snug"> 92 - {doc.record.title} 89 + {doc.record.title === "" || doc.record.title === undefined 90 + ? "Untitled" 91 + : doc.record.title} 93 92 </h3> 94 93 </a> 95 94 <div className="flex justify-start align-top flex-row gap-1">
+150
app/lish/[did]/[publication]/theme-settings/PostPreview.tsx
··· 1 + "use client"; 2 + 3 + import { LinearDocumentPage } from "app/lish/[did]/[publication]/[rkey]/LinearDocumentPage"; 4 + import { LeafletContentProvider } from "contexts/LeafletContentContext"; 5 + import { 6 + DocumentProvider, 7 + type PublicationContext, 8 + } from "contexts/DocumentContext"; 9 + import { useIdentityData } from "components/IdentityProvider"; 10 + import type { ProfileViewDetailed } from "@atproto/api/dist/client/types/app/bsky/actor/defs"; 11 + import type { PubLeafletPagesLinearDocument } from "lexicons/api"; 12 + import type { PostPageData } from "app/lish/[did]/[publication]/[rkey]/getPostPageData"; 13 + import type { DocumentContextValue } from "contexts/DocumentContext"; 14 + import { 15 + usePublicationData, 16 + useNormalizedPublicationRecord, 17 + } from "../dashboard/PublicationSWRProvider"; 18 + import { fakeBlocks, fakePage } from "./postPreviewFakeBlocks"; 19 + 20 + const FAKE_DID = "did:plc:fake-preview-user"; 21 + const FAKE_DOC_URI = 22 + "at://did:plc:fake-preview-user/site.standard.document/preview"; 23 + 24 + const fakeNormalizedDocument = { 25 + $type: "site.standard.document" as const, 26 + title: "Building your Dream Theme!", 27 + description: "A short description of this preview post.", 28 + publishedAt: new Date().toISOString(), 29 + site: "at://did:plc:fake-preview-user/site.standard.publication/preview", 30 + tags: ["preview", "theme"], 31 + }; 32 + 33 + function makeFakeDocument( 34 + publication?: { 35 + uri: string; 36 + name: string; 37 + identity_did: string; 38 + record: unknown; 39 + } | null, 40 + ): NonNullable<PostPageData> { 41 + return { 42 + data: {}, 43 + uri: FAKE_DOC_URI, 44 + normalizedDocument: fakeNormalizedDocument, 45 + normalizedPublication: null, 46 + quotesAndMentions: [], 47 + theme: null, 48 + prevNext: undefined, 49 + publication: publication || null, 50 + comments: [], 51 + comments_on_documents: [], 52 + mentions: [], 53 + document_mentions_in_bsky: [], 54 + leaflets_in_publications: [], 55 + leafletId: null, 56 + recommendsCount: 0, 57 + documents_in_publications: publication 58 + ? [{ publications: publication }] 59 + : [], 60 + recommends_on_documents: [], 61 + } as unknown as NonNullable<PostPageData>; 62 + } 63 + 64 + export function PostPreview(props: { 65 + showPageBackground: boolean; 66 + pageWidth: number; 67 + }) { 68 + let { identity } = useIdentityData(); 69 + let { data } = usePublicationData(); 70 + let { publication } = data || {}; 71 + let record = useNormalizedPublicationRecord(); 72 + let preferences = record?.preferences; 73 + let profileRecord = identity?.bsky_profiles 74 + ?.record as unknown as ProfileViewDetailed; 75 + 76 + let profile = profileRecord ?? { 77 + did: FAKE_DID, 78 + handle: "preview.bsky.social", 79 + displayName: "Preview Author", 80 + }; 81 + 82 + let pubInfo: PublicationContext = publication 83 + ? { 84 + uri: publication.uri, 85 + name: publication.name, 86 + identity_did: publication.identity_did, 87 + record: publication.record as NonNullable<PublicationContext>["record"], 88 + publication_subscriptions: ( 89 + publication.publication_subscriptions || [] 90 + ).map((s) => ({ 91 + created_at: s.created_at, 92 + identity: s.identity, 93 + publication: s.publication, 94 + record: s.record, 95 + uri: s.uri, 96 + })), 97 + } 98 + : null; 99 + 100 + let fakeDocument = makeFakeDocument(pubInfo); 101 + 102 + let fakeDocumentContextValue: DocumentContextValue = { 103 + uri: FAKE_DOC_URI, 104 + normalizedDocument: fakeNormalizedDocument, 105 + normalizedPublication: null, 106 + theme: undefined, 107 + prevNext: undefined, 108 + quotesAndMentions: [], 109 + publication: pubInfo, 110 + comments: [], 111 + mentions: [], 112 + leafletId: null, 113 + recommendsCount: 0, 114 + }; 115 + 116 + return ( 117 + <DocumentProvider value={fakeDocumentContextValue}> 118 + <LeafletContentProvider 119 + value={{ 120 + pages: [ 121 + fakePage as PubLeafletPagesLinearDocument.Main & { 122 + $type: string; 123 + }, 124 + ], 125 + }} 126 + > 127 + <div className="w-fit h-full py-2 sm:py-6"> 128 + <LinearDocumentPage 129 + document={fakeDocument} 130 + did={profile.did || FAKE_DID} 131 + profile={profile as ProfileViewDetailed} 132 + preferences={{ 133 + showComments: preferences?.showComments, 134 + showMentions: preferences?.showMentions, 135 + showRecommends: preferences?.showRecommends, 136 + showPrevNext: preferences?.showPrevNext, 137 + }} 138 + prerenderedCodeBlocks={new Map()} 139 + bskyPostData={[]} 140 + pollData={[]} 141 + document_uri={FAKE_DOC_URI} 142 + fullPageScroll={!props.showPageBackground} 143 + hasPageBackground={props.showPageBackground} 144 + blocks={fakeBlocks} 145 + /> 146 + </div> 147 + </LeafletContentProvider> 148 + </DocumentProvider> 149 + ); 150 + }
+99
app/lish/[did]/[publication]/theme-settings/PubPreview.tsx
··· 1 + "use client"; 2 + 3 + import { 4 + usePublicationData, 5 + useNormalizedPublicationRecord, 6 + } from "app/lish/[did]/[publication]/dashboard/PublicationSWRProvider"; 7 + import { useIdentityData } from "components/IdentityProvider"; 8 + import { ProfileViewDetailed } from "@atproto/api/dist/client/types/app/bsky/actor/defs"; 9 + import { PublicationContent } from "../PublicationContent"; 10 + import { LocalizedDate } from "../LocalizedDate"; 11 + 12 + export function PubPreview(props: { 13 + showPageBackground: boolean; 14 + pageWidth: number; 15 + }) { 16 + let { data } = usePublicationData(); 17 + let { publication } = data || {}; 18 + let { identity } = useIdentityData(); 19 + let record = useNormalizedPublicationRecord(); 20 + 21 + let profileRecord = identity?.bsky_profiles 22 + ?.record as unknown as ProfileViewDetailed; 23 + 24 + let did = publication?.identity_did || ""; 25 + 26 + let profile = identity?.bsky_profiles 27 + ? { 28 + did: identity.bsky_profiles.did, 29 + displayName: profileRecord?.displayName, 30 + handle: identity.bsky_profiles.handle || "", 31 + } 32 + : undefined; 33 + 34 + if (!publication) return null; 35 + 36 + const hasPosts = publication.documents_in_publications.some( 37 + (d) => !!d?.documents, 38 + ); 39 + const today = new Date(); 40 + const yesterday = new Date(today); 41 + yesterday.setDate(today.getDate() - 1); 42 + const dayBefore = new Date(today); 43 + dayBefore.setDate(today.getDate() - 2); 44 + 45 + const dateOptions: Intl.DateTimeFormatOptions = { 46 + year: "numeric", 47 + month: "long", 48 + day: "2-digit", 49 + }; 50 + 51 + const fakePosts = !hasPosts 52 + ? undefined 53 + : [ 54 + { 55 + title: "Your Personal Antheme", 56 + description: 57 + "Welcome to the Publication Theme Setter. This is how posts will appear in your publication", 58 + date: ( 59 + <LocalizedDate 60 + dateString={today.toISOString()} 61 + options={dateOptions} 62 + /> 63 + ), 64 + }, 65 + { 66 + title: "The Theme of the Crop", 67 + description: 68 + "This is the place to make your publication look and feel like home. It looks great!", 69 + date: ( 70 + <LocalizedDate 71 + dateString={yesterday.toISOString()} 72 + options={dateOptions} 73 + /> 74 + ), 75 + }, 76 + { 77 + title: "Reams and Reams of Colorful Themes!", 78 + description: 79 + "So happy to have you. There's so much cool stuff happening here, including this publication :)", 80 + date: ( 81 + <LocalizedDate 82 + dateString={dayBefore.toISOString()} 83 + options={dateOptions} 84 + /> 85 + ), 86 + }, 87 + ]; 88 + 89 + return ( 90 + <PublicationContent 91 + record={record} 92 + publication={publication} 93 + did={did} 94 + profile={profile} 95 + showPageBackground={props.showPageBackground} 96 + fakePosts={fakePosts} 97 + /> 98 + ); 99 + }
+277
app/lish/[did]/[publication]/theme-settings/ThemeSettingsContent.tsx
··· 1 + "use client"; 2 + 3 + import { useRef, useState } from "react"; 4 + import { useParams, useRouter } from "next/navigation"; 5 + import { 6 + BaseThemeProvider, 7 + CardBorderHiddenContext, 8 + } from "components/ThemeManager/ThemeProvider"; 9 + import { ButtonPrimary, ButtonSecondary } from "components/Buttons"; 10 + import { DotLoader } from "components/utils/DotLoader"; 11 + import { ToggleGroup } from "components/ToggleGroup"; 12 + import { PaintSmall } from "components/Icons/PaintSmall"; 13 + import { Popover } from "components/Popover"; 14 + import { 15 + usePubThemeEditorState, 16 + PubThemePickerPanel, 17 + } from "components/ThemeManager/PubThemeSetter"; 18 + import { PubPreview } from "./PubPreview"; 19 + import { PostPreview } from "./PostPreview"; 20 + import { PublicationBackgroundProvider } from "components/ThemeManager/PublicationThemeProvider"; 21 + import { 22 + usePublicationData, 23 + useNormalizedPublicationRecord, 24 + } from "../dashboard/PublicationSWRProvider"; 25 + import { Separator } from "components/Layout"; 26 + import { GoToArrow } from "components/Icons/GoToArrow"; 27 + import Link from "next/link"; 28 + 29 + export function ThemeSettingsContent() { 30 + let toolbarRef = useRef<HTMLDivElement>(null); 31 + let [previewMode, setPreviewMode] = useState<"post" | "pub">("post"); 32 + let params = useParams<{ did: string; publication: string }>(); 33 + let { data } = usePublicationData(); 34 + let { publication } = data || {}; 35 + let record = useNormalizedPublicationRecord(); 36 + let state = usePubThemeEditorState(); 37 + let { 38 + localPubTheme, 39 + headingFont, 40 + bodyFont, 41 + image, 42 + pageWidth, 43 + pubBGImage, 44 + leafletBGRepeat, 45 + showPageBackground, 46 + changes, 47 + } = state; 48 + 49 + let settingsHref = `/lish/${params.did}/${params.publication}/dashboard?tab=Settings`; 50 + 51 + let hasUnsavedChanges = 52 + changes || 53 + headingFont !== record?.theme?.headingFont || 54 + bodyFont !== record?.theme?.bodyFont || 55 + pageWidth !== (record?.theme?.pageWidth || 624) || 56 + showPageBackground !== !!record?.theme?.showPageBackground; 57 + 58 + return ( 59 + <CardBorderHiddenContext.Provider value={!showPageBackground}> 60 + <BaseThemeProvider 61 + local 62 + {...localPubTheme} 63 + headingFontId={headingFont} 64 + bodyFontId={bodyFont} 65 + hasBackgroundImage={!!image} 66 + pageWidth={pageWidth} 67 + > 68 + <div className="w-full h-screen flex flex-col overflow-hidden"> 69 + {/* Theme Setter Panel */} 70 + <div 71 + ref={toolbarRef} 72 + className="themeSetterControls bg-accent-1 items-center -mb-2 pb-[10px] pt-2 " 73 + > 74 + <div className=" sm:w-[var(--page-width-units)] mx-auto flex justify-between items-center px-3 sm:px-4 "> 75 + <BackToPubButton 76 + hasUnsavedChanges={hasUnsavedChanges} 77 + settingsHref={settingsHref} 78 + localPubTheme={localPubTheme} 79 + headingFont={headingFont} 80 + bodyFont={bodyFont} 81 + image={image} 82 + pageWidth={pageWidth} 83 + /> 84 + <div className="flex gap-1 items-center "> 85 + <ToggleGroup 86 + value={previewMode} 87 + optionClassName="text-base! py-1! px-2!" 88 + onChange={setPreviewMode} 89 + options={[ 90 + { value: "post", label: "Post" }, 91 + { value: "pub", label: "Pub" }, 92 + ]} 93 + /> 94 + <Separator classname="h-8! mr-1" /> 95 + <PubThemePopover state={state} toolbarRef={toolbarRef} /> 96 + </div> 97 + </div> 98 + </div> 99 + 100 + {/* Full-page Preview */} 101 + <PublicationBackgroundProvider 102 + className="rounded-t-lg grow! min-h-0 overflow-y-auto" 103 + theme={record?.theme} 104 + pub_creator={publication?.identity_did || ""} 105 + localBgImage={pubBGImage} 106 + localBgImageRepeat={leafletBGRepeat} 107 + > 108 + <div 109 + className="mx-auto h-full w-fit" 110 + onClickCapture={(e) => { 111 + e.preventDefault(); 112 + e.stopPropagation(); 113 + }} 114 + > 115 + {previewMode === "pub" ? ( 116 + <PubPreview 117 + showPageBackground={showPageBackground} 118 + pageWidth={pageWidth} 119 + /> 120 + ) : ( 121 + <PostPreview 122 + showPageBackground={showPageBackground} 123 + pageWidth={pageWidth} 124 + /> 125 + )} 126 + </div> 127 + </PublicationBackgroundProvider> 128 + </div> 129 + </BaseThemeProvider> 130 + </CardBorderHiddenContext.Provider> 131 + ); 132 + } 133 + 134 + const BackToPubButton = (props: { 135 + hasUnsavedChanges: boolean; 136 + settingsHref: string; 137 + localPubTheme: ReturnType<typeof usePubThemeEditorState>["localPubTheme"]; 138 + headingFont: ReturnType<typeof usePubThemeEditorState>["headingFont"]; 139 + bodyFont: ReturnType<typeof usePubThemeEditorState>["bodyFont"]; 140 + image: ReturnType<typeof usePubThemeEditorState>["image"]; 141 + pageWidth: ReturnType<typeof usePubThemeEditorState>["pageWidth"]; 142 + }) => { 143 + let router = useRouter(); 144 + let [open, setOpen] = useState(false); 145 + if (props.hasUnsavedChanges) 146 + return ( 147 + <Popover 148 + open={open} 149 + onOpenChange={setOpen} 150 + align="start" 151 + side="bottom" 152 + className="w-76 !p-0" 153 + trigger={ 154 + <div className="flex gap-2 items-center font-bold text-accent-2"> 155 + <GoToArrow className="rotate-180 text-accent-2" /> 156 + Back <span className="sm:block hidden">To Settings</span> 157 + </div> 158 + } 159 + > 160 + <BaseThemeProvider 161 + local 162 + {...props.localPubTheme} 163 + headingFontId={props.headingFont} 164 + bodyFontId={props.bodyFont} 165 + hasBackgroundImage={!!props.image} 166 + pageWidth={props.pageWidth} 167 + > 168 + <div className="flex flex-col p-3"> 169 + <h4 className="text-primary">Discard unsaved changes?</h4> 170 + <p className="text-sm text-tertiary"> 171 + You have unsaved changes to your theme. Leaving the page will lose 172 + your edits! 173 + </p> 174 + <div className="flex gap-4 w-full pt-3"> 175 + <ButtonPrimary 176 + className="shrink-0" 177 + onClick={() => router.push(props.settingsHref)} 178 + > 179 + Discard and Leave 180 + </ButtonPrimary> 181 + 182 + <button 183 + className="text-accent-contrast font-bold" 184 + onClick={() => setOpen(false)} 185 + > 186 + Nevermind 187 + </button> 188 + </div> 189 + </div> 190 + </BaseThemeProvider> 191 + </Popover> 192 + ); 193 + else 194 + return ( 195 + <Link className="no-underline!" href={props.settingsHref}> 196 + <div className="flex gap-2 text-accent-2 items-center font-bold"> 197 + <GoToArrow className="rotate-180 text-accent-2" /> 198 + Back <span className="sm:block hidden">To Settings</span> 199 + </div> 200 + </Link> 201 + ); 202 + }; 203 + 204 + const PubThemePopover = ({ 205 + state, 206 + toolbarRef, 207 + }: { 208 + state: ReturnType<typeof usePubThemeEditorState>; 209 + toolbarRef: React.RefObject<HTMLDivElement | null>; 210 + }) => { 211 + let { 212 + localPubTheme, 213 + headingFont, 214 + bodyFont, 215 + image, 216 + pageWidth, 217 + submitTheme, 218 + toaster, 219 + } = state; 220 + let [loading, setLoading] = useState(false); 221 + 222 + return ( 223 + <Popover 224 + defaultOpen 225 + align="start" 226 + side="bottom" 227 + arrowFill="white" 228 + border="#CCCCCC" 229 + className="sm:w-sm w-[1000px] rounded-lg! !p-0 bg-white! border-[#CCCCCC]!" 230 + trigger={ 231 + <button 232 + type="button" 233 + className="w-8 h-8 rounded-md bg-accent-1 text-accent-2 data-[state=open]:bg-accent-2 data-[state=open]:text-accent-1 flex items-center justify-center transition-colors cursor-pointer " 234 + > 235 + <PaintSmall /> 236 + </button> 237 + } 238 + asChild 239 + > 240 + <BaseThemeProvider 241 + local 242 + {...localPubTheme} 243 + headingFontId={headingFont} 244 + bodyFontId={bodyFont} 245 + hasBackgroundImage={!!image} 246 + pageWidth={pageWidth} 247 + > 248 + <div className="flex flex-col overflow-y-auto max-h-(--radix-popover-content-available-height) py-3"> 249 + {/* Toggle + Save Header */} 250 + 251 + <div className="p-3 pt-0 "> 252 + <ButtonPrimary 253 + fullWidth 254 + type="button" 255 + disabled={loading} 256 + onClick={async () => { 257 + let result = await submitTheme(setLoading); 258 + if (result?.success) { 259 + toaster({ 260 + content: "Theme saved!", 261 + type: "success", 262 + }); 263 + } 264 + }} 265 + > 266 + {loading ? <DotLoader /> : "Save Changes"} 267 + </ButtonPrimary> 268 + </div> 269 + 270 + <div className="px-3 gap-2 flex flex-col"> 271 + <PubThemePickerPanel state={state} /> 272 + </div> 273 + </div> 274 + </BaseThemeProvider> 275 + </Popover> 276 + ); 277 + };
+62
app/lish/[did]/[publication]/theme-settings/page.tsx
··· 1 + import { supabaseServerClient } from "supabase/serverClient"; 2 + import { getIdentityData } from "actions/getIdentityData"; 3 + import { get_publication_data } from "app/api/rpc/[command]/get_publication_data"; 4 + import { PublicationSWRDataProvider } from "../dashboard/PublicationSWRProvider"; 5 + import { AtUri } from "@atproto/syntax"; 6 + import { NotFoundLayout } from "components/PageLayouts/NotFoundLayout"; 7 + import { normalizePublicationRecord } from "src/utils/normalizeRecords"; 8 + import { ThemeSettingsContent } from "./ThemeSettingsContent"; 9 + 10 + export default async function ThemeSettingsPage(props: { 11 + params: Promise<{ publication: string; did: string }>; 12 + }) { 13 + let params = await props.params; 14 + let identity = await getIdentityData(); 15 + if (!identity || !identity.atp_did) 16 + return ( 17 + <NotFoundLayout> 18 + <p>Looks like you&apos;re not logged in.</p> 19 + <p> 20 + If the issue persists please{" "} 21 + <a href="mailto:contact@leaflet.pub">send us a note</a>. 22 + </p> 23 + </NotFoundLayout> 24 + ); 25 + let did = decodeURIComponent(params.did); 26 + if (!did) return <ThemeNotFound />; 27 + let { result: publication_data } = await get_publication_data.handler( 28 + { 29 + did, 30 + publication_name: decodeURIComponent(params.publication), 31 + }, 32 + { supabase: supabaseServerClient }, 33 + ); 34 + let { publication } = publication_data; 35 + const record = normalizePublicationRecord(publication?.record); 36 + 37 + if (!publication || identity.atp_did !== publication.identity_did || !record) 38 + return <ThemeNotFound />; 39 + let uri = new AtUri(publication.uri); 40 + 41 + return ( 42 + <PublicationSWRDataProvider 43 + publication_did={did} 44 + publication_rkey={uri.rkey} 45 + publication_data={publication_data} 46 + > 47 + <ThemeSettingsContent /> 48 + </PublicationSWRDataProvider> 49 + ); 50 + } 51 + 52 + const ThemeNotFound = () => { 53 + return ( 54 + <NotFoundLayout> 55 + <p className="font-bold">Sorry, we can&apos;t find this publication!</p> 56 + <p> 57 + This may be a glitch on our end. If the issue persists please{" "} 58 + <a href="mailto:contact@leaflet.pub">send us a note</a>. 59 + </p> 60 + </NotFoundLayout> 61 + ); 62 + };
+353
app/lish/[did]/[publication]/theme-settings/postPreviewFakeBlocks.ts
··· 1 + import type { PubLeafletPagesLinearDocument } from "lexicons/api"; 2 + 3 + export const fakeBlocks: PubLeafletPagesLinearDocument.Block[] = [ 4 + { 5 + $type: "pub.leaflet.pages.linearDocument#block", 6 + block: { 7 + $type: "pub.leaflet.blocks.text", 8 + plaintext: "Welcome to Leaflet, intrepid writer!", 9 + }, 10 + }, 11 + { 12 + $type: "pub.leaflet.pages.linearDocument#block", 13 + block: { 14 + $type: "pub.leaflet.blocks.text", 15 + plaintext: 16 + "This is a place to write, blog, journal, and above all, express oneself. As such we take theming very seriously. Read on to discover how to make your wildest themes come true!", 17 + }, 18 + }, 19 + { 20 + $type: "pub.leaflet.pages.linearDocument#block", 21 + block: { 22 + $type: "pub.leaflet.blocks.text", 23 + plaintext: 'And now, for a horizontal rule (also known as a "divider")!', 24 + }, 25 + }, 26 + { 27 + $type: "pub.leaflet.pages.linearDocument#block", 28 + block: { 29 + $type: "pub.leaflet.blocks.horizontalRule", 30 + }, 31 + }, 32 + { 33 + $type: "pub.leaflet.pages.linearDocument#block", 34 + block: { 35 + $type: "pub.leaflet.blocks.header", 36 + level: 2, 37 + plaintext: "TLDR", 38 + }, 39 + }, 40 + { 41 + $type: "pub.leaflet.pages.linearDocument#block", 42 + block: { 43 + $type: "pub.leaflet.blocks.text", 44 + plaintext: 45 + "We have some great presets available! Go ahead and apply one of those, and mess with the accent color to make it yours. Just keep in mind", 46 + }, 47 + }, 48 + { 49 + $type: "pub.leaflet.pages.linearDocument#block", 50 + block: { 51 + $type: "pub.leaflet.blocks.unorderedList", 52 + children: [ 53 + { 54 + $type: "pub.leaflet.blocks.unorderedList#listItem", 55 + content: { 56 + $type: "pub.leaflet.blocks.text", 57 + plaintext: "Go for a nice bright accent ", 58 + }, 59 + }, 60 + { 61 + $type: "pub.leaflet.blocks.unorderedList#listItem", 62 + content: { 63 + $type: "pub.leaflet.blocks.text", 64 + plaintext: 65 + "Make sure the text on accent is still legible. White or black if you're not sure!", 66 + }, 67 + }, 68 + ], 69 + }, 70 + }, 71 + { 72 + $type: "pub.leaflet.pages.linearDocument#block", 73 + block: { 74 + $type: "pub.leaflet.blocks.horizontalRule", 75 + }, 76 + }, 77 + { 78 + $type: "pub.leaflet.pages.linearDocument#block", 79 + block: { 80 + $type: "pub.leaflet.blocks.header", 81 + level: 2, 82 + plaintext: "Your Text", 83 + }, 84 + }, 85 + { 86 + $type: "pub.leaflet.pages.linearDocument#block", 87 + block: { 88 + $type: "pub.leaflet.blocks.header", 89 + level: 3, 90 + plaintext: "Text", 91 + }, 92 + }, 93 + { 94 + $type: "pub.leaflet.pages.linearDocument#block", 95 + block: { 96 + $type: "pub.leaflet.blocks.text", 97 + plaintext: "This is your default text color. ", 98 + }, 99 + }, 100 + { 101 + $type: "pub.leaflet.pages.linearDocument#block", 102 + block: { 103 + $type: "pub.leaflet.blocks.text", 104 + plaintext: 105 + "We also mix it with your background color to make lighter text or border colors!", 106 + }, 107 + }, 108 + { 109 + $type: "pub.leaflet.pages.linearDocument#block", 110 + block: { 111 + $type: "pub.leaflet.blocks.text", 112 + plaintext: 113 + "No need to think too hard, black or white is good! Just make sure it shows up strong against your background color. ", 114 + }, 115 + }, 116 + { 117 + $type: "pub.leaflet.pages.linearDocument#block", 118 + block: { 119 + $type: "pub.leaflet.blocks.header", 120 + level: 3, 121 + plaintext: "Accent Colors ", 122 + }, 123 + }, 124 + { 125 + $type: "pub.leaflet.pages.linearDocument#block", 126 + block: { 127 + $type: "pub.leaflet.blocks.text", 128 + facets: [ 129 + { 130 + index: { 131 + byteEnd: 32, 132 + byteStart: 20, 133 + }, 134 + features: [ 135 + { 136 + uri: "https://leaflet.pub/about", 137 + $type: "pub.leaflet.richtext.facet#link", 138 + }, 139 + ], 140 + }, 141 + ], 142 + plaintext: 143 + "We use this in your inline links, and in certain block types like...", 144 + }, 145 + }, 146 + { 147 + $type: "pub.leaflet.pages.linearDocument#block", 148 + block: { 149 + url: "https://www.leaflet.pub/about", 150 + text: "Buttons!", 151 + $type: "pub.leaflet.blocks.button", 152 + }, 153 + }, 154 + { 155 + $type: "pub.leaflet.pages.linearDocument#block", 156 + block: { 157 + $type: "pub.leaflet.blocks.text", 158 + plaintext: "Pick something... ", 159 + }, 160 + }, 161 + { 162 + $type: "pub.leaflet.pages.linearDocument#block", 163 + block: { 164 + $type: "pub.leaflet.blocks.unorderedList", 165 + children: [ 166 + { 167 + $type: "pub.leaflet.blocks.unorderedList#listItem", 168 + content: { 169 + $type: "pub.leaflet.blocks.text", 170 + plaintext: "nice and bright", 171 + }, 172 + }, 173 + { 174 + $type: "pub.leaflet.blocks.unorderedList#listItem", 175 + content: { 176 + $type: "pub.leaflet.blocks.text", 177 + plaintext: 178 + "with legible text on accent (white usually works, but sometimes black shows up better) ", 179 + }, 180 + }, 181 + { 182 + $type: "pub.leaflet.blocks.unorderedList#listItem", 183 + content: { 184 + $type: "pub.leaflet.blocks.text", 185 + plaintext: "that shows off your personality!", 186 + }, 187 + }, 188 + ], 189 + }, 190 + }, 191 + { 192 + $type: "pub.leaflet.pages.linearDocument#block", 193 + block: { 194 + $type: "pub.leaflet.blocks.horizontalRule", 195 + }, 196 + }, 197 + { 198 + $type: "pub.leaflet.pages.linearDocument#block", 199 + block: { 200 + $type: "pub.leaflet.blocks.header", 201 + level: 2, 202 + plaintext: "Your Background", 203 + }, 204 + }, 205 + { 206 + $type: "pub.leaflet.pages.linearDocument#block", 207 + block: { 208 + $type: "pub.leaflet.blocks.header", 209 + level: 3, 210 + plaintext: "Background", 211 + }, 212 + }, 213 + { 214 + $type: "pub.leaflet.pages.linearDocument#block", 215 + block: { 216 + $type: "pub.leaflet.blocks.text", 217 + plaintext: "This is your background color. It can also be an image!", 218 + }, 219 + }, 220 + { 221 + $type: "pub.leaflet.pages.linearDocument#block", 222 + block: { 223 + $type: "pub.leaflet.blocks.text", 224 + plaintext: "If you decide to go for a solid background color...", 225 + }, 226 + }, 227 + { 228 + $type: "pub.leaflet.pages.linearDocument#block", 229 + block: { 230 + $type: "pub.leaflet.blocks.unorderedList", 231 + children: [ 232 + { 233 + $type: "pub.leaflet.blocks.unorderedList#listItem", 234 + content: { 235 + $type: "pub.leaflet.blocks.text", 236 + plaintext: "pick one that isn't too vibrant", 237 + }, 238 + }, 239 + { 240 + $type: "pub.leaflet.blocks.unorderedList#listItem", 241 + content: { 242 + $type: "pub.leaflet.blocks.text", 243 + plaintext: "go either dark or light, not in the middle", 244 + }, 245 + }, 246 + { 247 + $type: "pub.leaflet.blocks.unorderedList#listItem", 248 + content: { 249 + $type: "pub.leaflet.blocks.text", 250 + plaintext: 251 + "try a very dark or very light version of your accent color", 252 + }, 253 + }, 254 + ], 255 + }, 256 + }, 257 + { 258 + $type: "pub.leaflet.pages.linearDocument#block", 259 + block: { 260 + $type: "pub.leaflet.blocks.text", 261 + plaintext: 262 + "If you go for an image, it's easy to overwhelm a reader with too much, so pick one that's... ", 263 + }, 264 + }, 265 + { 266 + $type: "pub.leaflet.pages.linearDocument#block", 267 + block: { 268 + $type: "pub.leaflet.blocks.unorderedList", 269 + children: [ 270 + { 271 + $type: "pub.leaflet.blocks.unorderedList#listItem", 272 + content: { 273 + $type: "pub.leaflet.blocks.text", 274 + plaintext: 275 + 'not distracting. ie, not colorful or busy. You want something that could be described as "kinda boring". It\'ll still give a lot of personality once you apply it!', 276 + }, 277 + }, 278 + { 279 + $type: "pub.leaflet.blocks.unorderedList#listItem", 280 + content: { 281 + $type: "pub.leaflet.blocks.text", 282 + plaintext: "Gradients are a classy classic", 283 + }, 284 + }, 285 + ], 286 + }, 287 + }, 288 + { 289 + $type: "pub.leaflet.pages.linearDocument#block", 290 + block: { 291 + $type: "pub.leaflet.blocks.header", 292 + level: 3, 293 + plaintext: "Page or Container", 294 + }, 295 + }, 296 + { 297 + $type: "pub.leaflet.pages.linearDocument#block", 298 + block: { 299 + $type: "pub.leaflet.blocks.text", 300 + plaintext: "You can choose to have a page background or not. ", 301 + }, 302 + }, 303 + { 304 + $type: "pub.leaflet.pages.linearDocument#block", 305 + block: { 306 + $type: "pub.leaflet.blocks.text", 307 + plaintext: 308 + "Page background puts a box around your writing. It's especially useful if you have a background image making your text harder to read. It's also another place to inject a color to give your writing some zuzsh.", 309 + }, 310 + }, 311 + { 312 + $type: "pub.leaflet.pages.linearDocument#block", 313 + block: { 314 + $type: "pub.leaflet.blocks.text", 315 + plaintext: "No page background looks clean and minimal. It's sup to you!", 316 + }, 317 + }, 318 + { 319 + $type: "pub.leaflet.pages.linearDocument#block", 320 + block: { 321 + $type: "pub.leaflet.blocks.horizontalRule", 322 + }, 323 + }, 324 + { 325 + $type: "pub.leaflet.pages.linearDocument#block", 326 + block: { 327 + $type: "pub.leaflet.blocks.header", 328 + level: 2, 329 + plaintext: "Stuck?", 330 + }, 331 + }, 332 + { 333 + $type: "pub.leaflet.pages.linearDocument#block", 334 + block: { 335 + $type: "pub.leaflet.blocks.text", 336 + plaintext: 337 + "If you're really stuck, try one of our preset themes! Change just the accent color to give it a more personal flair. ", 338 + }, 339 + }, 340 + { 341 + $type: "pub.leaflet.pages.linearDocument#block", 342 + block: { 343 + $type: "pub.leaflet.blocks.text", 344 + plaintext: "Good luck!", 345 + }, 346 + }, 347 + ]; 348 + 349 + export const fakePage: PubLeafletPagesLinearDocument.Main = { 350 + $type: "pub.leaflet.pages.linearDocument", 351 + id: "preview-page", 352 + blocks: fakeBlocks, 353 + };
+3 -1
app/lish/[did]/[publication]/[rkey]/Interactions/Interactions.tsx
··· 134 134 const tagCount = tags?.length || 0; 135 135 136 136 let interactionsAvailable = 137 - props.showComments || props.showMentions || props.showRecommends; 137 + props.showComments || 138 + (props.showMentions && props.quotesCount > 0) || 139 + props.showRecommends; 138 140 139 141 return ( 140 142 <div
+2 -2
app/lish/[did]/[publication]/[rkey]/PostHeader/PostHeader.tsx
··· 124 124 {props.pubLink} 125 125 </div> 126 126 {props.postTitle && ( 127 - <h2 className="postTitle text-xl leading-tight pt-0.5 font-bold outline-hidden bg-transparent"> 127 + <h1 className="postTitle text-2xl leading-tight pt-0.5 font-bold outline-hidden bg-transparent"> 128 128 {props.postTitle} 129 - </h2> 129 + </h1> 130 130 )} 131 131 {props.postDescription ? ( 132 132 <div className="postDescription italic text-secondary outline-hidden bg-transparent pt-2">
+32 -23
app/lish/[did]/[publication]/dashboard/settings/GeneralSettings.tsx
··· 1 1 import { useRef } from "react"; 2 2 import { Input } from "components/Input"; 3 3 import { AddTiny } from "components/Icons/AddTiny"; 4 + import { DashboardContainer } from "./SettingsContent"; 5 + import { EditTiny } from "components/Icons/EditTiny"; 4 6 5 7 export function GeneralSettings(props: { 6 8 nameValue: string; ··· 14 16 let fileInputRef = useRef<HTMLInputElement>(null); 15 17 16 18 return ( 17 - <section className="flex flex-col gap-3"> 18 - <h3 className="font-bold text-primary">General Settings</h3> 19 + <DashboardContainer section="General Settings"> 19 20 20 - <div className="flex items-center justify-between gap-2"> 21 - <p className="pl-0.5 pb-0.5 text-tertiary italic text-sm font-bold"> 21 + <div className="flex flex-col gap-2 pb-2"> 22 + <p className=" text-secondary font-bold"> 22 23 Logo <span className="font-normal">(optional)</span> 23 24 </p> 24 - <div 25 - className={`w-8 h-8 rounded-full flex items-center justify-center cursor-pointer ${props.iconPreview ? "border border-border-light hover:outline-border" : "border border-dotted border-accent-contrast hover:outline-accent-contrast"} selected-outline`} 26 - onClick={() => fileInputRef.current?.click()} 27 - > 28 - {props.iconPreview ? ( 29 - <img 30 - src={props.iconPreview} 31 - alt="Logo preview" 32 - className="w-full h-full rounded-full object-cover" 33 - /> 34 - ) : ( 35 - <AddTiny className="text-accent-1" /> 25 + <div className="relative w-fit"> 26 + {props.iconPreview && ( 27 + <div 28 + className="absolute top-0 -right-1 rounded-full bg-accent-1 text-accent-2 p-1 cursor-pointer" 29 + onClick={() => fileInputRef.current?.click()} 30 + > 31 + <EditTiny /> 32 + </div> 36 33 )} 34 + <div 35 + className={`w-16 h-16 rounded-full flex items-center justify-center cursor-pointer ${props.iconPreview ? "border border-border-light hover:outline-border" : "border border-dotted border-accent-contrast hover:outline-accent-contrast"} selected-outline`} 36 + onClick={() => fileInputRef.current?.click()} 37 + > 38 + {props.iconPreview ? ( 39 + <img 40 + src={props.iconPreview} 41 + alt="Logo preview" 42 + className="w-full h-full rounded-full object-cover" 43 + /> 44 + ) : ( 45 + <AddTiny className="text-accent-1" /> 46 + )} 47 + </div> 37 48 </div> 38 49 <input 39 50 type="file" ··· 55 66 </div> 56 67 57 68 <label> 58 - <p className="pl-0.5 pb-0.5 text-tertiary italic text-sm font-bold"> 59 - Publication Name 60 - </p> 69 + <p className="text-secondary pt-3 font-bold">Publication Name</p> 61 70 <Input 62 - className="input-with-border w-full text-primary" 71 + className="input-with-border w-full text-primary max-w-prose" 63 72 type="text" 64 73 id="pubName" 65 74 value={props.nameValue} ··· 68 77 </label> 69 78 70 79 <label> 71 - <p className="text-tertiary italic text-sm font-bold pl-0.5 pb-0.5"> 80 + <p className="text-secondary pt-3 font-bold"> 72 81 Description <span className="font-normal">(optional)</span> 73 82 </p> 74 83 <Input 75 84 textarea 76 - className="input-with-border w-full text-primary" 85 + className="input-with-border w-full text-primary max-w-prose" 77 86 rows={3} 78 87 id="pubDescription" 79 88 value={props.descriptionValue} 80 89 onChange={(e) => props.setDescriptionValue(e.currentTarget.value)} 81 90 /> 82 91 </label> 83 - </section> 92 + </DashboardContainer> 84 93 ); 85 94 }
+37 -15
app/lish/[did]/[publication]/dashboard/settings/ManageProSubscription.tsx
··· 7 7 import { GoBackSmall } from "components/Icons/GoBackSmall"; 8 8 import { PRODUCT_DEFINITION } from "stripe/products"; 9 9 10 - export const ManageProSubscription = (props: {}) => { 10 + export const ManageProSubscription = (props: { compact?: boolean }) => { 11 11 const [loading, setLoading] = useState(false); 12 12 const [error, setError] = useState<string | null>(null); 13 13 const { identity } = useIdentityData(); ··· 29 29 setLoading(false); 30 30 } 31 31 } 32 - 33 - return ( 34 - <div> 35 - <div className="flex justify-between font-bold text-secondary bg-border-light -mx-3 -mt-2 px-3 py-2 mb-1 flex-shrink-0"> 36 - Manage Subscription 37 - </div> 38 - <div className="text-secondary text-center flex flex-col justify-center gap-2 py-2"> 39 - <div> 40 - You have a <br /> 41 - {PRODUCT_DEFINITION.name} subscription 42 - <div className="text-lg font-bold text-primary"> 43 - {PRODUCT_DEFINITION.name} 44 - </div> 32 + if (props.compact) { 33 + return ( 34 + <div className="text-secondary font-bold flex flex-col gap-1 justify-end"> 35 + <ButtonPrimary 36 + className="" 37 + compact 38 + onClick={handleManageBilling} 39 + disabled={loading} 40 + > 41 + {loading ? <DotLoader /> : "Manage Billing"} 42 + </ButtonPrimary> 43 + <div className="text-tertiary text-sm font-normal"> 45 44 {subscription?.status === "canceled" 46 45 ? "Your subscription has ended" 47 46 : subscription?.status === "canceling" 48 47 ? `Access until ${renewalDate}` 49 - : `Renews on ${renewalDate}`} 48 + : `Renews ${renewalDate}`} 49 + </div> 50 + 51 + {error && <div className="text-sm text-red-500 mt-2">{error}</div>} 52 + </div> 53 + ); 54 + } 55 + return ( 56 + <div> 57 + <div className="text-secondary text-center flex flex-col leading-snug justify-center gap-2 py-2"> 58 + <div> 59 + You are subscribed to 60 + <br /> 61 + <div className="text-xl font-bold text-primary pb-1"> 62 + {PRODUCT_DEFINITION.name} 63 + </div> 64 + <div className="text-tertiary italic text-sm"> 65 + {subscription?.status === "canceled" 66 + ? "Your subscription has ended" 67 + : subscription?.status === "canceling" 68 + ? `Access until ${renewalDate}` 69 + : `Renews ${renewalDate}`} 70 + </div> 50 71 </div> 51 72 <ButtonPrimary 52 73 className="mx-auto" 74 + type="button" 53 75 compact 54 76 onClick={handleManageBilling} 55 77 disabled={loading}
-128
app/lish/[did]/[publication]/dashboard/settings/PostOptions.tsx
··· 1 - import { 2 - usePublicationData, 3 - useNormalizedPublicationRecord, 4 - } from "../PublicationSWRProvider"; 5 - import { PubSettingsHeader } from "./PublicationSettings"; 6 - import { useState } from "react"; 7 - import { Toggle } from "components/Toggle"; 8 - import { updatePublication } from "app/lish/createPub/updatePublication"; 9 - import { useToaster } from "components/Toast"; 10 - import { mutate } from "swr"; 11 - 12 - export const PostOptions = (props: { 13 - backToMenu: () => void; 14 - loading: boolean; 15 - setLoading: (l: boolean) => void; 16 - }) => { 17 - let { data } = usePublicationData(); 18 - 19 - let { publication: pubData } = data || {}; 20 - const record = useNormalizedPublicationRecord(); 21 - 22 - let [showComments, setShowComments] = useState( 23 - record?.preferences?.showComments === undefined 24 - ? true 25 - : record.preferences.showComments, 26 - ); 27 - let [showMentions, setShowMentions] = useState( 28 - record?.preferences?.showMentions === undefined 29 - ? true 30 - : record.preferences.showMentions, 31 - ); 32 - let [showRecommends, setShowRecommends] = useState( 33 - record?.preferences?.showRecommends === undefined 34 - ? true 35 - : record.preferences.showRecommends, 36 - ); 37 - let [showPrevNext, setShowPrevNext] = useState( 38 - record?.preferences?.showPrevNext === undefined 39 - ? true 40 - : record.preferences.showPrevNext, 41 - ); 42 - 43 - let toast = useToaster(); 44 - return ( 45 - <form 46 - onSubmit={async (e) => { 47 - if (!pubData || !record) return; 48 - e.preventDefault(); 49 - props.setLoading(true); 50 - let data = await updatePublication({ 51 - name: record.name, 52 - uri: pubData.uri, 53 - preferences: { 54 - showInDiscover: 55 - record?.preferences?.showInDiscover === undefined 56 - ? true 57 - : record.preferences.showInDiscover, 58 - showComments: showComments, 59 - showMentions: showMentions, 60 - showPrevNext: showPrevNext, 61 - showRecommends: showRecommends, 62 - }, 63 - }); 64 - toast({ type: "success", content: <strong>Posts Updated!</strong> }); 65 - props.setLoading(false); 66 - mutate("publication-data"); 67 - }} 68 - className="text-primary flex flex-col" 69 - > 70 - <PubSettingsHeader 71 - loading={props.loading} 72 - setLoadingAction={props.setLoading} 73 - backToMenuAction={props.backToMenu} 74 - > 75 - Post Options 76 - </PubSettingsHeader> 77 - <h4 className="mb-1">Layout</h4> 78 - <Toggle 79 - toggle={showPrevNext} 80 - onToggle={() => { 81 - setShowPrevNext(!showPrevNext); 82 - }} 83 - > 84 - <div className="font-bold">Show Prev/Next Buttons</div> 85 - </Toggle> 86 - <hr className="my-2 border-border-light" /> 87 - <h4 className="mb-1">Interactions</h4> 88 - <div className="flex flex-col gap-2"> 89 - <Toggle 90 - toggle={showComments} 91 - onToggle={() => { 92 - setShowComments(!showComments); 93 - }} 94 - > 95 - <div className="font-bold">Show Comments</div> 96 - </Toggle> 97 - 98 - <Toggle 99 - toggle={showMentions} 100 - onToggle={() => { 101 - setShowMentions(!showMentions); 102 - }} 103 - > 104 - <div className="flex flex-col justify-start"> 105 - <div className="font-bold">Show Mentions</div> 106 - <div className="text-tertiary text-sm leading-tight"> 107 - Display a list of Bluesky mentions about your post 108 - </div> 109 - </div> 110 - </Toggle> 111 - 112 - <Toggle 113 - toggle={showRecommends} 114 - onToggle={() => { 115 - setShowRecommends(!showRecommends); 116 - }} 117 - > 118 - <div className="flex flex-col justify-start"> 119 - <div className="font-bold">Show Recommends</div> 120 - <div className="text-tertiary text-sm leading-tight"> 121 - Allow readers to recommend/like your post 122 - </div> 123 - </div> 124 - </Toggle> 125 - </div> 126 - </form> 127 - ); 128 - };
+43 -42
app/lish/[did]/[publication]/dashboard/settings/PostSettings.tsx
··· 1 1 import { Toggle } from "components/Toggle"; 2 + import { DashboardContainer } from "./SettingsContent"; 2 3 3 4 export function PostSettings(props: { 4 5 showComments: boolean; ··· 14 15 }) { 15 16 return ( 16 17 <> 17 - <div className="flex flex-col gap-3"> 18 - <h3 className="mb-0">Discovery</h3> 18 + <DashboardContainer section="Discovery"> 19 19 <Toggle 20 20 toggle={props.showInDiscover} 21 21 onToggle={() => props.setShowInDiscover(!props.showInDiscover)} 22 22 > 23 23 <div className="flex flex-col justify-start"> 24 - <div className="font-bold">Make Public</div> 24 + <div className="font-bold text-secondary">Make Public</div> 25 25 <div className="text-tertiary text-sm leading-tight"> 26 26 Your posts will appear in{" "} 27 27 <a href="/reader" target="_blank"> ··· 31 31 </div> 32 32 </div> 33 33 </Toggle> 34 - </div> 35 - <hr className="border-border-light" /> 34 + </DashboardContainer> 36 35 37 - <h3 className="mb-0">Post Layout</h3> 38 - <Toggle 39 - toggle={props.showPrevNext} 40 - onToggle={() => props.setShowPrevNext(!props.showPrevNext)} 41 - > 42 - <div className="font-bold">Show Prev/Next Buttons on Post</div> 43 - </Toggle> 44 - 45 - <hr className="border-border-light" /> 46 - 47 - <h3 className="mb-0">Post Interactions</h3> 48 - <div className="flex flex-col gap-2"> 36 + <DashboardContainer section="Post Layout"> 49 37 <Toggle 50 - toggle={props.showComments} 51 - onToggle={() => props.setShowComments(!props.showComments)} 38 + toggle={props.showPrevNext} 39 + onToggle={() => props.setShowPrevNext(!props.showPrevNext)} 52 40 > 53 - <div className="font-bold">Show Comments</div> 54 - </Toggle> 55 - 56 - <Toggle 57 - toggle={props.showMentions} 58 - onToggle={() => props.setShowMentions(!props.showMentions)} 59 - > 60 - <div className="flex flex-col justify-start"> 61 - <div className="font-bold">Show Mentions</div> 62 - <div className="text-tertiary text-sm leading-tight"> 63 - Display a list of Bluesky mentions about your post 64 - </div> 41 + <div className="font-bold text-secondary"> 42 + Show Prev/Next Buttons on Post 65 43 </div> 66 44 </Toggle> 45 + </DashboardContainer> 67 46 68 - <Toggle 69 - toggle={props.showRecommends} 70 - onToggle={() => props.setShowRecommends(!props.showRecommends)} 71 - > 72 - <div className="flex flex-col justify-start"> 73 - <div className="font-bold">Show Recommends</div> 74 - <div className="text-tertiary text-sm leading-tight"> 75 - Allow readers to recommend/like your post 47 + <DashboardContainer section="Post Interactions"> 48 + <div className="flex flex-col gap-2"> 49 + <Toggle 50 + toggle={props.showComments} 51 + onToggle={() => props.setShowComments(!props.showComments)} 52 + > 53 + <div className="font-bold text-secondary">Show Comments</div> 54 + </Toggle> 55 + 56 + <Toggle 57 + toggle={props.showMentions} 58 + onToggle={() => props.setShowMentions(!props.showMentions)} 59 + > 60 + <div className="flex flex-col justify-start"> 61 + <div className="font-bold text-secondary">Show Mentions</div> 62 + <div className="text-tertiary text-sm leading-tight"> 63 + Display a list of Bluesky mentions about your post 64 + </div> 76 65 </div> 77 - </div> 78 - </Toggle> 79 - </div> 66 + </Toggle> 67 + 68 + <Toggle 69 + toggle={props.showRecommends} 70 + onToggle={() => props.setShowRecommends(!props.showRecommends)} 71 + > 72 + <div className="flex flex-col justify-start"> 73 + <div className="font-bold text-secondary">Show Recommends</div> 74 + <div className="text-tertiary text-sm leading-tight"> 75 + Allow readers to recommend/like your post 76 + </div> 77 + </div> 78 + </Toggle> 79 + </div> 80 + </DashboardContainer> 80 81 </> 81 82 ); 82 83 }
+374
app/lish/[did]/[publication]/dashboard/settings/PubDomainSettings.tsx
··· 1 + "use client"; 2 + 3 + import { useState } from "react"; 4 + import { mutate } from "swr"; 5 + import { useDomainStatus } from "components/Domains/useDomainStatus"; 6 + import { 7 + getDomainAssignment, 8 + describeAssignment, 9 + } from "components/Domains/domainAssignment"; 10 + import { 11 + useIdentityData, 12 + mutateIdentityData, 13 + } from "components/IdentityProvider"; 14 + import { ButtonPrimary } from "components/Buttons"; 15 + import { 16 + usePublicationData, 17 + useNormalizedPublicationRecord, 18 + } from "../PublicationSWRProvider"; 19 + import { updatePublicationBasePath } from "app/lish/createPub/updatePublication"; 20 + import { 21 + assignDomainToPublication, 22 + removeDomainAssignment, 23 + } from "actions/domains"; 24 + import { PinTiny } from "components/Icons/PinTiny"; 25 + import { LoadingTiny } from "components/Icons/LoadingTiny"; 26 + import { UnlinkTiny } from "components/Icons/UnlinkTiny"; 27 + import { DotLoader } from "components/utils/DotLoader"; 28 + import { useToaster } from "components/Toast"; 29 + import type { CustomDomain } from "components/Domains/DomainList"; 30 + 31 + export const PubDomainSettings = () => { 32 + let { data, mutate: mutatePubData } = usePublicationData(); 33 + let { publication: pubData } = data || {}; 34 + let record = useNormalizedPublicationRecord(); 35 + let { identity, mutate: mutateIdentity } = useIdentityData(); 36 + let toaster = useToaster(); 37 + let basePath = record?.url?.replace(/^https?:\/\//, "") || ""; 38 + 39 + let pubDomains = pubData?.publication_domains || []; 40 + let pubDomainNames = new Set(pubDomains.map((d) => d.domain)); 41 + 42 + if (!pubData) return null; 43 + 44 + return ( 45 + <> 46 + <div className="flex flex-col gap-1"> 47 + <h4>This Publication&apos;s Domains</h4> 48 + <div className="text-xs text-tertiary -mb-1">DEFAULT</div> 49 + {pubDomains 50 + .filter((d) => d.domain === basePath) 51 + .map((d) => ( 52 + <PubDomainRow 53 + key={d.domain} 54 + domain={d.domain} 55 + publication_uri={pubData.uri} 56 + basePath={basePath} 57 + mutatePubData={mutatePubData} 58 + mutateIdentity={mutateIdentity} 59 + toaster={toaster} 60 + /> 61 + ))} 62 + {pubDomains.filter((d) => d.domain !== basePath).length !== 0 && ( 63 + <> 64 + <div className="text-xs text-tertiary pt-1">ALTERNATES</div> 65 + {pubDomains 66 + .filter((d) => d.domain !== basePath) 67 + .map((d) => ( 68 + <PubDomainRow 69 + key={d.domain} 70 + domain={d.domain} 71 + publication_uri={pubData.uri} 72 + basePath={basePath} 73 + mutatePubData={mutatePubData} 74 + mutateIdentity={mutateIdentity} 75 + toaster={toaster} 76 + /> 77 + ))} 78 + </> 79 + )} 80 + 81 + <hr className="my-2 border-border-light" /> 82 + <h4>Available Domains</h4> 83 + {(() => { 84 + let availableDomains = (identity?.custom_domains || []) 85 + .filter((d) => !pubDomainNames.has(d.domain)) 86 + .filter( 87 + (d) => 88 + d.publication_domains.length === 0 && 89 + d.custom_domain_routes.length === 0, 90 + ); 91 + return availableDomains.length > 0 ? ( 92 + <> 93 + {availableDomains.map((d) => ( 94 + <UnassignedDomainRow 95 + key={d.domain} 96 + domainData={d} 97 + publication_uri={pubData.uri} 98 + mutatePubData={mutatePubData} 99 + onAssigned={() => { 100 + mutateIdentity(); 101 + mutate("publication-data"); 102 + }} 103 + /> 104 + ))} 105 + <div className="text-sm text-tertiary pt-0.5"> 106 + Add new domains from your profile settings! 107 + </div> 108 + </> 109 + ) : ( 110 + <div className="text-sm text-tertiary"> 111 + <strong>No available domains!</strong> 112 + <br /> 113 + Add new domains from your profile settings! 114 + </div> 115 + ); 116 + })()} 117 + </div> 118 + </> 119 + ); 120 + }; 121 + 122 + function PubDomainRow(props: { 123 + domain: string; 124 + publication_uri: string; 125 + basePath: string; 126 + mutatePubData: ReturnType<typeof usePublicationData>["mutate"]; 127 + mutateIdentity: ReturnType<typeof useIdentityData>["mutate"]; 128 + toaster: ReturnType<typeof useToaster>; 129 + }) { 130 + let { pending } = useDomainStatus(props.domain); 131 + let [loading, setLoading] = useState(false); 132 + let [unlinking, setUnlinking] = useState(false); 133 + let toaster = props.toaster; 134 + 135 + return ( 136 + <div className=" opaque-container text-secondary relative w-full flex items-center justify-between px-[6px] py-1 border rounded-md border-border-light"> 137 + <span className="pr-8 truncate text-left">{props.domain}</span> 138 + <div className="flex justify-end items-center"> 139 + {pending ? ( 140 + <div className="px-1 py-0.5 flex gap-1 items-center"> 141 + <p className="w-max pl-1 font-bold text-sm text-tertiary"> 142 + pending 143 + </p> 144 + <LoadingTiny className="animate-spin text-accent-contrast" /> 145 + </div> 146 + ) : ( 147 + <> 148 + {props.basePath !== props.domain && ( 149 + <div className="flex gap-1"> 150 + {!props.domain.endsWith(".leaflet.pub") && ( 151 + <button 152 + type="button" 153 + disabled={unlinking} 154 + className="text-tertiary hover:text-accent-contrast shrink-0" 155 + onClick={async () => { 156 + setUnlinking(true); 157 + props.mutatePubData( 158 + (current) => { 159 + if (!current) return current; 160 + let pub = current.publication; 161 + if (!pub) return current; 162 + return { 163 + ...current, 164 + publication: { 165 + ...pub, 166 + publication_domains: ( 167 + pub.publication_domains || [] 168 + ).filter((d) => d.domain !== props.domain), 169 + }, 170 + }; 171 + }, 172 + { revalidate: false }, 173 + ); 174 + mutateIdentityData(props.mutateIdentity, (draft) => { 175 + let domain = draft.custom_domains.find( 176 + (d) => d.domain === props.domain, 177 + ); 178 + if (domain) { 179 + domain.publication_domains = []; 180 + } 181 + }); 182 + toaster({ 183 + content: ( 184 + <div> 185 + Unlinked <strong>{props.domain}</strong> 186 + </div> 187 + ), 188 + type: "success", 189 + }); 190 + await removeDomainAssignment({ domain: props.domain }); 191 + props.mutatePubData(); 192 + props.mutateIdentity(); 193 + setUnlinking(false); 194 + }} 195 + > 196 + {unlinking ? ( 197 + <DotLoader className="h-[16px]! text-xs" /> 198 + ) : ( 199 + <UnlinkTiny /> 200 + )} 201 + </button> 202 + )} 203 + <button 204 + type="button" 205 + disabled={loading} 206 + onClick={async () => { 207 + setLoading(true); 208 + props.mutatePubData( 209 + (current) => { 210 + if (!current) return current; 211 + let pub = current.publication; 212 + if (!pub?.record) return current; 213 + let rec = pub.record as Record<string, unknown>; 214 + if (typeof rec.url !== "string") return current; 215 + let protocol = 216 + rec.url.match(/^https?:\/\//)?.[0] || "https://"; 217 + return { 218 + ...current, 219 + publication: { 220 + ...pub, 221 + record: { ...rec, url: protocol + props.domain }, 222 + }, 223 + }; 224 + }, 225 + { revalidate: false }, 226 + ); 227 + toaster({ 228 + content: ( 229 + <div> 230 + Default domain set to <strong>{props.domain}</strong> 231 + </div> 232 + ), 233 + type: "success", 234 + }); 235 + await updatePublicationBasePath({ 236 + uri: props.publication_uri, 237 + base_path: props.domain, 238 + }); 239 + props.mutatePubData(); 240 + setLoading(false); 241 + }} 242 + className="hover:text-accent-contrast" 243 + > 244 + {loading ? ( 245 + <DotLoader className="h-[18px]! text-xs" /> 246 + ) : ( 247 + <PinTiny className="text-tertiary hover:text-accent-contrast shrink-0" /> 248 + )} 249 + </button> 250 + </div> 251 + )} 252 + </> 253 + )} 254 + </div> 255 + </div> 256 + ); 257 + } 258 + 259 + function UnassignedDomainRow(props: { 260 + domainData: CustomDomain; 261 + publication_uri: string; 262 + mutatePubData: ReturnType<typeof usePublicationData>["mutate"]; 263 + onAssigned: () => void; 264 + }) { 265 + let { pending } = useDomainStatus(props.domainData.domain); 266 + let { mutate: mutateIdentity } = useIdentityData(); 267 + let assignment = getDomainAssignment(props.domainData); 268 + let [confirming, setConfirming] = useState(false); 269 + let [loading, setLoading] = useState(false); 270 + 271 + async function doAssign() { 272 + setLoading(true); 273 + try { 274 + mutateIdentityData(mutateIdentity, (draft) => { 275 + let domain = draft.custom_domains.find( 276 + (d) => d.domain === props.domainData.domain, 277 + ); 278 + if (domain) { 279 + domain.custom_domain_routes = []; 280 + let pub = draft.publications?.find( 281 + (p) => p.uri === props.publication_uri, 282 + ); 283 + domain.publication_domains = [ 284 + { 285 + publication: props.publication_uri, 286 + domain: props.domainData.domain, 287 + identity: "", 288 + created_at: new Date().toISOString(), 289 + publications: pub ? { name: pub.name } : null, 290 + }, 291 + ]; 292 + } 293 + }); 294 + props.mutatePubData( 295 + (current) => { 296 + if (!current) return current; 297 + let pub = current.publication; 298 + if (!pub) return current; 299 + return { 300 + ...current, 301 + publication: { 302 + ...pub, 303 + publication_domains: [ 304 + ...(pub.publication_domains || []), 305 + { 306 + publication: props.publication_uri, 307 + domain: props.domainData.domain, 308 + created_at: new Date().toISOString(), 309 + identity: "", 310 + }, 311 + ], 312 + }, 313 + }; 314 + }, 315 + { revalidate: false }, 316 + ); 317 + setConfirming(false); 318 + props.onAssigned(); 319 + await assignDomainToPublication({ 320 + domain: props.domainData.domain, 321 + publication_uri: props.publication_uri, 322 + }); 323 + } finally { 324 + setLoading(false); 325 + } 326 + } 327 + 328 + return ( 329 + <div className="opaque-container text-tertiary w-full flex flex-col gap-1 px-[6px] py-1 border rounded-md border-border-light border-dashed"> 330 + <div className="flex items-center justify-between"> 331 + <span className="truncate text-left">{props.domainData.domain}</span> 332 + {pending ? ( 333 + <div className="text-tertiary animate-pulse text-sm">unverified</div> 334 + ) : confirming ? null : ( 335 + <button 336 + className="text-accent-contrast text-xs font-bold" 337 + type="button" 338 + disabled={loading} 339 + onClick={() => { 340 + if (assignment.type === "document") { 341 + setConfirming(true); 342 + } else { 343 + doAssign(); 344 + } 345 + }} 346 + > 347 + {loading ? <DotLoader className="h-[18px]! text-xs" /> : "assign"} 348 + </button> 349 + )} 350 + </div> 351 + {confirming && ( 352 + <div className="text-xs border-t border-border-light pt-1 flex flex-col gap-1"> 353 + <p className="text-secondary"> 354 + This domain is currently assigned to{" "} 355 + {describeAssignment(assignment)}. Assigning it here will remove that 356 + assignment. 357 + </p> 358 + <div className="flex gap-2 justify-end"> 359 + <button 360 + className="text-accent-contrast" 361 + onMouseDown={() => setConfirming(false)} 362 + type="button" 363 + > 364 + Cancel 365 + </button> 366 + <ButtonPrimary compact disabled={loading} onMouseDown={doAssign}> 367 + {loading ? <DotLoader /> : "Reassign"} 368 + </ButtonPrimary> 369 + </div> 370 + </div> 371 + )} 372 + </div> 373 + ); 374 + }
-166
app/lish/[did]/[publication]/dashboard/settings/PublicationSettings.tsx
··· 1 - "use client"; 2 - 3 - import { ActionButton } from "components/ActionBar/ActionButton"; 4 - import { Popover } from "components/Popover"; 5 - import { SettingsSmall } from "components/Icons/SettingsSmall"; 6 - import { EditPubForm } from "app/lish/createPub/UpdatePubForm"; 7 - import { PubThemeSetter } from "components/ThemeManager/PubThemeSetter"; 8 - import { useIsMobile } from "src/hooks/isMobile"; 9 - import { useState } from "react"; 10 - import { GoBackSmall } from "components/Icons/GoBackSmall"; 11 - import { theme } from "tailwind.config"; 12 - import { ButtonPrimary } from "components/Buttons"; 13 - import { DotLoader } from "components/utils/DotLoader"; 14 - import { ArrowRightTiny } from "components/Icons/ArrowRightTiny"; 15 - import { PostOptions } from "./PostOptions"; 16 - import { PublicationDomains } from "components/Domains/PublicationDomains"; 17 - import { usePublicationData } from "../PublicationSWRProvider"; 18 - 19 - type menuState = "menu" | "pub-settings" | "theme" | "post-settings" | "domains"; 20 - 21 - export function PublicationSettingsButton(props: { publication: string }) { 22 - let isMobile = useIsMobile(); 23 - let [state, setState] = useState<menuState>("menu"); 24 - let [loading, setLoading] = useState(false); 25 - 26 - return ( 27 - <Popover 28 - asChild 29 - onOpenChange={() => setState("menu")} 30 - side={isMobile ? "top" : "right"} 31 - align={isMobile ? "center" : "start"} 32 - className={`flex flex-col max-w-xs w-[1000px] ${state === "theme" && "bg-white!"} pb-0!`} 33 - arrowFill={theme.colors["border-light"]} 34 - trigger={ 35 - <ActionButton 36 - id="pub-settings-button" 37 - icon=<SettingsSmall /> 38 - label="Settings" 39 - smallOnMobile 40 - /> 41 - } 42 - > 43 - {state === "pub-settings" ? ( 44 - <EditPubForm 45 - backToMenuAction={() => setState("menu")} 46 - loading={loading} 47 - setLoadingAction={setLoading} 48 - /> 49 - ) : state === "theme" ? ( 50 - <PubThemeSetter 51 - backToMenu={() => setState("menu")} 52 - loading={loading} 53 - setLoading={setLoading} 54 - /> 55 - ) : state === "post-settings" ? ( 56 - <PostOptions 57 - backToMenu={() => setState("menu")} 58 - loading={loading} 59 - setLoading={setLoading} 60 - /> 61 - ) : state === "domains" ? ( 62 - <PublicationDomainsView backToMenu={() => setState("menu")} /> 63 - ) : ( 64 - <PubSettingsMenu 65 - state={state} 66 - setState={setState} 67 - loading={loading} 68 - setLoading={setLoading} 69 - /> 70 - )} 71 - <div className="spacer h-2 w-full" aria-hidden /> 72 - </Popover> 73 - ); 74 - } 75 - 76 - const PubSettingsMenu = (props: { 77 - state: menuState; 78 - setState: (s: menuState) => void; 79 - loading: boolean; 80 - setLoading: (l: boolean) => void; 81 - }) => { 82 - let menuItemClassName = 83 - "menuItem -mx-[8px] text-left flex items-center justify-between hover:no-underline!"; 84 - 85 - return ( 86 - <div className="flex flex-col gap-0.5"> 87 - <PubSettingsHeader>Settings</PubSettingsHeader> 88 - <button 89 - className={menuItemClassName} 90 - type="button" 91 - onClick={() => { 92 - props.setState("pub-settings"); 93 - }} 94 - > 95 - Publication Settings 96 - <ArrowRightTiny /> 97 - </button> 98 - <button 99 - className={menuItemClassName} 100 - type="button" 101 - onClick={() => props.setState("post-settings")} 102 - > 103 - Post Settings 104 - <ArrowRightTiny /> 105 - </button> 106 - <button 107 - className={menuItemClassName} 108 - type="button" 109 - onClick={() => props.setState("theme")} 110 - > 111 - Theme and Layout 112 - <ArrowRightTiny /> 113 - </button> 114 - <button 115 - className={menuItemClassName} 116 - type="button" 117 - onClick={() => props.setState("domains")} 118 - > 119 - Domains 120 - <ArrowRightTiny /> 121 - </button> 122 - </div> 123 - ); 124 - }; 125 - 126 - function PublicationDomainsView(props: { backToMenu: () => void }) { 127 - let { data } = usePublicationData(); 128 - let { publication: pubData } = data || {}; 129 - if (!pubData) return null; 130 - return ( 131 - <PublicationDomains 132 - backToMenu={props.backToMenu} 133 - publication_uri={pubData.uri} 134 - /> 135 - ); 136 - } 137 - 138 - export const PubSettingsHeader = (props: { 139 - backToMenuAction?: () => void; 140 - loading?: boolean; 141 - setLoadingAction?: (l: boolean) => void; 142 - children: React.ReactNode; 143 - }) => { 144 - return ( 145 - <div className="flex justify-between font-bold text-secondary bg-border-light -mx-3 -mt-2 px-3 py-2 mb-1 flex-shrink-0"> 146 - {props.children} 147 - {props.backToMenuAction && ( 148 - <div className="flex gap-2"> 149 - <button 150 - type="button" 151 - onClick={() => { 152 - props.backToMenuAction && props.backToMenuAction(); 153 - }} 154 - > 155 - <GoBackSmall className="text-accent-contrast" /> 156 - </button> 157 - {props.setLoadingAction && ( 158 - <ButtonPrimary compact type="submit"> 159 - {props.loading ? <DotLoader /> : "Update"} 160 - </ButtonPrimary> 161 - )} 162 - </div> 163 - )} 164 - </div> 165 - ); 166 - };
+233 -41
app/lish/[did]/[publication]/dashboard/settings/SettingsContent.tsx
··· 1 1 "use client"; 2 2 3 - import { useState, useEffect } from "react"; 4 - import { ButtonPrimary } from "components/Buttons"; 3 + import { useState, useEffect, useMemo } from "react"; 4 + import { ButtonPrimary, ButtonSecondary } from "components/Buttons"; 5 5 import { DotLoader } from "components/utils/DotLoader"; 6 6 import { useToaster } from "components/Toast"; 7 7 import { mutate } from "swr"; ··· 10 10 useNormalizedPublicationRecord, 11 11 } from "../PublicationSWRProvider"; 12 12 import { updatePublication } from "app/lish/createPub/updatePublication"; 13 - import { DomainsInline } from "./DomainsInline"; 14 - import { PubThemeSetter } from "components/ThemeManager/PubThemeSetter"; 13 + import { PubDomainSettings } from "./PubDomainSettings"; 15 14 import { GeneralSettings } from "./GeneralSettings"; 16 15 import { PostSettings } from "./PostSettings"; 17 16 import { ThemeSettings } from "./ThemeSettings"; 18 17 import { useCardBorderHidden } from "components/Pages/useCardBorderHidden"; 18 + import { ManageProSubscription } from "./ManageProSubscription"; 19 + import { useIsPro, useCanSeePro } from "src/hooks/useEntitlement"; 20 + import { InlineUpgradeToPro, UpgradeToProButton } from "../../UpgradeModal"; 21 + import { Modal } from "components/Modal"; 22 + import { Input } from "components/Input"; 23 + import { deletePublication } from "./deletePublication"; 24 + import { useRouter } from "next/navigation"; 25 + import { 26 + isOAuthSessionError, 27 + OAuthErrorMessage, 28 + } from "components/OAuthError"; 19 29 20 30 type SettingsView = "all" | "theme"; 21 31 22 32 export function SettingsContent(props: { showPageBackground: boolean }) { 23 - let [view, setView] = useState<SettingsView>("all"); 24 - let [themeLoading, setThemeLoading] = useState(false); 25 - 26 - if (view === "theme") { 27 - return ( 28 - <div className="flex flex-col gap-0 w-full max-w-xl pb-8"> 29 - <PubThemeSetter 30 - backToMenu={() => setView("all")} 31 - loading={themeLoading} 32 - setLoading={setThemeLoading} 33 - /> 34 - </div> 35 - ); 36 - } 37 - 38 - return <SettingsForm onOpenTheme={() => setView("theme")} />; 39 - } 40 - 41 - function SettingsForm(props: { onOpenTheme: () => void }) { 42 33 let { data } = usePublicationData(); 43 34 let { publication: pubData } = data || {}; 35 + let isPro = useIsPro(); 36 + let canSeePro = useCanSeePro(); 44 37 let record = useNormalizedPublicationRecord(); 45 38 let [loading, setLoading] = useState(false); 46 39 let toast = useToaster(); 47 40 48 - // --- General Settings state --- 49 41 let [nameValue, setNameValue] = useState(record?.name || ""); 50 42 let [descriptionValue, setDescriptionValue] = useState( 51 43 record?.description || "", ··· 92 84 ); 93 85 }, [pubData, record]); 94 86 87 + let hasUnsavedChanges = useMemo(() => { 88 + if (!record) return false; 89 + if (nameValue !== (record.name || "")) return true; 90 + if (descriptionValue !== (record.description || "")) return true; 91 + if (iconFile !== null) return true; 92 + 93 + let savedShowInDiscover = 94 + record.preferences?.showInDiscover === undefined 95 + ? true 96 + : record.preferences.showInDiscover; 97 + if (showInDiscover !== savedShowInDiscover) return true; 98 + 99 + let savedShowComments = 100 + record.preferences?.showComments === undefined 101 + ? true 102 + : record.preferences.showComments; 103 + if (showComments !== savedShowComments) return true; 104 + 105 + let savedShowMentions = 106 + record.preferences?.showMentions === undefined 107 + ? true 108 + : record.preferences.showMentions; 109 + if (showMentions !== savedShowMentions) return true; 110 + 111 + let savedShowRecommends = 112 + record.preferences?.showRecommends === undefined 113 + ? true 114 + : record.preferences.showRecommends; 115 + if (showRecommends !== savedShowRecommends) return true; 116 + 117 + let savedShowPrevNext = 118 + record.preferences?.showPrevNext === undefined 119 + ? true 120 + : record.preferences.showPrevNext; 121 + if (showPrevNext !== savedShowPrevNext) return true; 122 + 123 + return false; 124 + }, [ 125 + record, 126 + nameValue, 127 + descriptionValue, 128 + iconFile, 129 + showInDiscover, 130 + showComments, 131 + showMentions, 132 + showRecommends, 133 + showPrevNext, 134 + ]); 135 + 95 136 return ( 96 137 <form 97 - className="flex flex-col w-full max-w-xl pb-20" 138 + className="flex flex-col w-full pb-8" 98 139 onSubmit={async (e) => { 99 140 e.preventDefault(); 100 141 if (!pubData) return; ··· 117 158 mutate("publication-data"); 118 159 }} 119 160 > 120 - <div className="flex flex-col gap-6"> 161 + <div className="flex flex-col gap-6 relative "> 121 162 {/* ── General Settings ── */} 122 163 <GeneralSettings 123 164 nameValue={nameValue} ··· 129 170 setIconFile={setIconFile} 130 171 /> 131 172 132 - <hr className="border-border-light" /> 173 + <DashboardContainer section="Theme and Layout"> 174 + <ThemeSettings /> 175 + </DashboardContainer> 133 176 134 177 {/* ── Post Settings ── */} 135 178 <PostSettings ··· 145 188 setShowInDiscover={setShowInDiscover} 146 189 /> 147 190 148 - <hr className="border-border-light" /> 191 + <DashboardContainer section="Domains"> 192 + <div className="text-secondary"> 193 + <PubDomainSettings /> 194 + </div> 195 + </DashboardContainer> 149 196 150 - {/* ── Theme ── */} 151 - <ThemeSettings onOpenTheme={props.onOpenTheme} /> 197 + <DashboardContainer section="Leaflet Pro" className="pb-4"> 198 + {canSeePro && !isPro ? ( 199 + <UpgradeToProButton /> 200 + ) : ( 201 + <ManageProSubscription compact /> 202 + )} 203 + </DashboardContainer> 204 + <div className="flex flex-col gap-1"> 205 + <hr className="border-border border-2" /> 206 + <hr className="border-border border-2" /> 207 + </div> 152 208 153 - <hr className="border-border-light" /> 209 + <DashboardContainer section="DANGER!" className="pb-4"> 210 + <DeletePublication /> 211 + </DashboardContainer> 154 212 155 - {/* ── Domains ── */} 156 - <DomainsInline /> 157 - </div> 158 - 159 - {/* ── Sticky Save Footer ── */} 160 - <div className="opaque-container sticky bottom-4 left-0 right-0 bg-bg-page border-t border-border-light p-2 px-2 flex justify-end"> 161 - <ButtonPrimary type="submit" disabled={loading}> 162 - {loading ? <DotLoader /> : "Save Changes"} 163 - </ButtonPrimary> 213 + {hasUnsavedChanges && <SettingsFooter loading={loading} />} 164 214 </div> 165 215 </form> 216 + ); 217 + } 218 + 219 + function SettingsFooter(props: { loading: boolean }) { 220 + let [distanceFromBottom, setDistanceFromBottom] = useState<number>(Infinity); 221 + 222 + useEffect(() => { 223 + const scrollContainer = document.getElementById("home-content"); 224 + if (!scrollContainer) return; 225 + 226 + const handleScroll = () => { 227 + const dist = 228 + scrollContainer.scrollHeight - 229 + scrollContainer.scrollTop - 230 + scrollContainer.clientHeight; 231 + setDistanceFromBottom(dist); 232 + }; 233 + 234 + handleScroll(); 235 + scrollContainer.addEventListener("scroll", handleScroll); 236 + return () => scrollContainer.removeEventListener("scroll", handleScroll); 237 + }, []); 238 + 239 + const threshold = 100; 240 + // ratio: 1 = far from bottom (full margin), 0 = at bottom (no margin) 241 + const ratio = Math.min(distanceFromBottom / threshold, 1); 242 + const mx = ratio * 8; // 8px = mx-2 243 + 244 + return ( 245 + <div 246 + className="settingsFooter sticky bottom-0 z-10" 247 + style={{ 248 + paddingLeft: `${mx}px`, 249 + paddingRight: `${mx}px`, 250 + paddingBottom: `${mx}px`, 251 + }} 252 + > 253 + <div className="bg-[rgb(var(--accent-1))] text-accent-2 text-sm rounded-md border-border-light pr-1 pl-2 flex justify-between items-center py-1"> 254 + You have unsaved updates! 255 + <ButtonSecondary type="submit" disabled={props.loading}> 256 + {props.loading ? <DotLoader /> : "Update Pub"} 257 + </ButtonSecondary> 258 + </div> 259 + </div> 166 260 ); 167 261 } 168 262 169 263 export const DashboardContainer = (props: { 170 264 children: React.ReactNode; 171 265 className?: string; 266 + section?: string; 172 267 }) => { 173 268 let cardBorderHidden = useCardBorderHidden(); 174 269 return ( 175 270 <div 176 - className={`flex flex-col ${cardBorderHidden ? "con" : "bg-transparent"}`} 271 + className={`container flex flex-col rounded-lg! gap-2 p-3 sm:px-4 ${!cardBorderHidden ? "" : "bg-transparent!"} ${props.className}`} 177 272 > 273 + {props.section && ( 274 + <> 275 + <h3 className="font-bold text-primary">{props.section}</h3> 276 + <hr className="-mt-1 mb-2 border-border-light" /> 277 + </> 278 + )} 178 279 {props.children} 179 280 </div> 281 + ); 282 + }; 283 + 284 + let pluralize = (n: number, word: string) => 285 + `${n} ${word}${n === 1 ? "" : "s"}`; 286 + 287 + const DeletePublication = () => { 288 + let [value, setValue] = useState(""); 289 + let [deleting, setDeleting] = useState(false); 290 + let record = useNormalizedPublicationRecord(); 291 + let { data: pub } = usePublicationData(); 292 + let postCount = pub?.documents?.length ?? 0; 293 + let draftCount = pub?.drafts?.length ?? 0; 294 + let subCount = pub?.publication?.publication_subscriptions?.length ?? 0; 295 + let toaster = useToaster(); 296 + let router = useRouter(); 297 + let pubUri = pub?.publication?.uri; 298 + 299 + let onDelete = async () => { 300 + if (!pubUri || record?.name !== value || deleting) return; 301 + setDeleting(true); 302 + let result = await deletePublication(pubUri); 303 + if (!result.success) { 304 + setDeleting(false); 305 + toaster({ 306 + type: "error", 307 + content: isOAuthSessionError(result.error) ? ( 308 + <OAuthErrorMessage error={result.error} /> 309 + ) : typeof result.error === "string" ? ( 310 + result.error 311 + ) : ( 312 + "Failed to delete publication" 313 + ), 314 + }); 315 + return; 316 + } 317 + toaster({ 318 + type: "success", 319 + content: `${record?.name ?? "Publication"} deleted`, 320 + }); 321 + router.push("/home"); 322 + }; 323 + 324 + return ( 325 + <Modal 326 + asChild 327 + className="text-center" 328 + trigger={<ButtonPrimary>Delete Publication</ButtonPrimary>} 329 + title="Are you sure?" 330 + > 331 + <div className="text-secondary flex flex-col max-w-prose"> 332 + <div className="pb-3 text-left"> 333 + This will permanently delete: 334 + <ul className="list-disc pl-5 pt-1"> 335 + <li>This publication and its settings</li> 336 + <li> 337 + {pluralize(postCount, "published post")} 338 + {postCount > 0 ? " (removed from your PDS)" : ""} 339 + </li> 340 + <li>{pluralize(draftCount, "draft")}</li> 341 + <li>All associated records on your PDS</li> 342 + </ul> 343 + {subCount > 0 && ( 344 + <div className="pt-2"> 345 + {pluralize(subCount, "subscriber")} will lose access. 346 + </div> 347 + )} 348 + <div className="pt-2 font-bold text-primary"> 349 + This cannot be undone. 350 + </div> 351 + </div> 352 + <div className="font-bold pb-1"> 353 + Enter the name of this publication to confirm 354 + </div> 355 + 356 + <Input 357 + className="input-with-border w-full mb-3 text-primary max-w-prose" 358 + placeholder={record?.name ? record.name : "Publication Name"} 359 + type="text" 360 + value={value} 361 + onChange={(e) => setValue(e.currentTarget.value)} 362 + /> 363 + <ButtonPrimary 364 + className="mx-auto mb-1" 365 + disabled={record?.name !== value || deleting || !pubUri} 366 + onClick={onDelete} 367 + > 368 + {deleting ? <DotLoader /> : "Delete Publication"} 369 + </ButtonPrimary> 370 + </div> 371 + </Modal> 180 372 ); 181 373 };
+17 -10
app/lish/[did]/[publication]/dashboard/settings/ThemeSettings.tsx
··· 1 - export function ThemeSettings(props: { onOpenTheme: () => void }) { 1 + "use client"; 2 + 3 + import { GoToArrow } from "components/Icons/GoToArrow"; 4 + import { SpeedyLink } from "components/SpeedyLink"; 5 + import { useParams } from "next/navigation"; 6 + 7 + export function ThemeSettings() { 8 + let params = useParams<{ did: string; publication: string }>(); 9 + let href = `/lish/${params.did}/${params.publication}/theme-settings`; 10 + 2 11 return ( 3 - <section className="flex flex-col gap-3"> 4 - <h3 className="font-bold text-primary">Theme and Layout</h3> 5 - <button 6 - type="button" 7 - className="text-left text-sm text-accent-contrast font-bold hover:underline w-fit" 8 - onClick={props.onOpenTheme} 12 + <> 13 + <SpeedyLink 14 + className="text-left flex gap-2 items-center text-accent-contrast font-bold no-underline! w-fit" 15 + href={href} 9 16 > 10 - Customize Theme &rarr; 11 - </button> 12 - </section> 17 + Customize Theme <GoToArrow /> 18 + </SpeedyLink> 19 + </> 13 20 ); 14 21 }
+124
app/lish/[did]/[publication]/dashboard/settings/deletePublication.ts
··· 1 + "use server"; 2 + 3 + import { AtpBaseClient } from "lexicons/api"; 4 + import { getIdentityData } from "actions/getIdentityData"; 5 + import { restoreOAuthSession, OAuthSessionError } from "src/atproto-oauth"; 6 + import { AtUri } from "@atproto/syntax"; 7 + import { supabaseServerClient } from "supabase/serverClient"; 8 + import { drizzle } from "drizzle-orm/node-postgres"; 9 + import { 10 + entities, 11 + permission_tokens, 12 + permission_token_rights, 13 + } from "drizzle/schema"; 14 + import { eq, inArray } from "drizzle-orm"; 15 + import { pool } from "supabase/pool"; 16 + import { revalidatePath } from "next/cache"; 17 + 18 + export async function deletePublication( 19 + publication_uri: string, 20 + ): Promise< 21 + { success: true } | { success: false; error: string | OAuthSessionError } 22 + > { 23 + let identity = await getIdentityData(); 24 + if (!identity || !identity.atp_did) { 25 + return { success: false, error: "Not authenticated" }; 26 + } 27 + 28 + let pubUri = new AtUri(publication_uri); 29 + if (pubUri.host !== identity.atp_did) { 30 + return { success: false, error: "Not authorized" }; 31 + } 32 + 33 + const sessionResult = await restoreOAuthSession(identity.atp_did); 34 + if (!sessionResult.ok) { 35 + return { success: false, error: sessionResult.error }; 36 + } 37 + let credentialSession = sessionResult.value; 38 + let agent = new AtpBaseClient( 39 + credentialSession.fetchHandler.bind(credentialSession), 40 + ); 41 + 42 + // Collect these BEFORE deleting the publication row — cascading deletes would remove the join rows. 43 + let [docs, drafts] = await Promise.all([ 44 + supabaseServerClient 45 + .from("documents_in_publications") 46 + .select("document") 47 + .eq("publication", publication_uri), 48 + supabaseServerClient 49 + .from("leaflets_in_publications") 50 + .select("leaflet") 51 + .eq("publication", publication_uri), 52 + ]); 53 + let documentUris = Array.from( 54 + new Set([...(docs.data ?? []).map((r) => r.document)]), 55 + ); 56 + let draftTokenIds = (drafts.data ?? []).map((r) => r.leaflet); 57 + 58 + let pdsDeletes = Promise.all([ 59 + ...documentUris.flatMap((docUri) => { 60 + let u = new AtUri(docUri); 61 + if (u.host !== credentialSession.did) return []; 62 + return [ 63 + agent.pub.leaflet.document 64 + .delete({ repo: credentialSession.did, rkey: u.rkey }) 65 + .catch(() => {}), 66 + agent.site.standard.document 67 + .delete({ repo: credentialSession.did, rkey: u.rkey }) 68 + .catch(() => {}), 69 + ]; 70 + }), 71 + agent.pub.leaflet.publication 72 + .delete({ repo: credentialSession.did, rkey: pubUri.rkey }) 73 + .catch(() => {}), 74 + agent.site.standard.publication 75 + .delete({ repo: credentialSession.did, rkey: pubUri.rkey }) 76 + .catch(() => {}), 77 + ]); 78 + 79 + let draftDeletes = async () => { 80 + if (draftTokenIds.length === 0) return; 81 + const client = await pool.connect(); 82 + try { 83 + const db = drizzle(client); 84 + await db.transaction(async (tx) => { 85 + let tokens = await tx 86 + .select() 87 + .from(permission_tokens) 88 + .leftJoin( 89 + permission_token_rights, 90 + eq(permission_tokens.id, permission_token_rights.token), 91 + ) 92 + .where(inArray(permission_tokens.id, draftTokenIds)); 93 + let entitySets = tokens 94 + .map((t) => t.permission_token_rights?.entity_set) 95 + .filter((s): s is string => !!s); 96 + if (entitySets.length > 0) { 97 + await tx.delete(entities).where(inArray(entities.set, entitySets)); 98 + } 99 + await tx 100 + .delete(permission_tokens) 101 + .where(inArray(permission_tokens.id, draftTokenIds)); 102 + }); 103 + } finally { 104 + client.release(); 105 + } 106 + }; 107 + 108 + await Promise.all([pdsDeletes, draftDeletes()]); 109 + 110 + // Delete document rows before publication rows — publication cascade would leave orphaned docs. 111 + if (documentUris.length > 0) { 112 + await supabaseServerClient 113 + .from("documents") 114 + .delete() 115 + .in("uri", documentUris); 116 + } 117 + await supabaseServerClient 118 + .from("publications") 119 + .delete() 120 + .eq("uri", publication_uri); 121 + 122 + revalidatePath("/lish/[did]/[publication]", "layout"); 123 + return { success: true }; 124 + }