a tool for shared writing and social publishing
0
fork

Configure Feed

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

Feature/pub theme (#142)

* reorganized a BUNCH of shit

* slight reorg of page pickers

* added the theme setter on the pub level but it needs wiring

* Add basic publication theme flow

* fixed some type errors

* rewires some stuff to get it all working for a solid bg color

* apply theme in draft editors, remove theme options from draft editor

* tweaked some words and colors

* change lexicon to seperate bgImage and bgColor

* don't overwrite themebg image

* fixed spacing issues in leaflet home, fixed legibility issues in pub dash

* fixed up the tab header a little in pub dash

* added a page bg to the published post

* pub page page background if there is a bg image

* handle opacity for bg in pub theme

* dashboard subs tab empty state update

* got opacity working

* checking borders, fixing max width stuff

* make card border work on leaflet previews

* add loader to update button

* set accentContrast in local pub theme

* started updating the theme modal

* make bg provider w-full and fix pub leaflet previews

* fix bgpage not being set right w/ local

* some updated to the theme modal in pub

* added pageBackground color and logic for toggle

* add loader and toast for adding feed

* check if they have feed before adding

* don't block when in inputs

* hide image toolbar buttons before image added

* collect textnodes properly on paste

* use data-entityid instead of data-entityID

* check empty children not textcontent

* don't paste empty text nodes

* shrink down url preview images before posting to bsky

* scale up preview res

* make rendered alt text in leaflet doc imageblock w-full instead of w-max

* add identity_id col to custom_domains

* check correct key in identity data custom domains

* get domains via identity_rel

* allow adding domains if no email

* filter out pub domains from custom domain list

* fix filter of pub domains

* mutate identity data after adding domain

* fix importing client component for rss feed

* use server safe popover for image alt text in blog

* fix double click issue w/ alt text

* update lexicons

* added a toggle and moved the page bg picker

* made the pub stuff use page bg color instead of leaflet bg color

* moved files around, redid the layout of the theme modal ... again

* add show page background ot lexicon

* updated checks to see if the page bg is showing

* updates to the sample pub, added a sample page

* a couple little tweaks

* updated the header for pubtheme modal

* simplified the bg image picker

* fix background wrapper styles

* enable update button on showPageBackground change

* handle missing record

* make leaflets respect cardborder hidden

* fix extra prop

* style publist on home page

---------

Co-authored-by: celine <celine@hyperlink.academy>
Co-authored-by: Brendan Schlagel <brendan.schlagel@gmail.com>

authored by

Jared Pereira
celine
Brendan Schlagel
and committed by
GitHub
f92c32e5 5f9430c0

+2477 -751
+1 -1
actions/getIdentityData.ts
··· 25 25 id, 26 26 root_entity, 27 27 permission_token_rights(*), 28 - leaflets_in_publications(*) 28 + leaflets_in_publications(*, publications(*)) 29 29 ) 30 30 ) 31 31 )`,
+2 -4
app/[leaflet_id]/Actions.tsx
··· 5 5 } from "app/lish/createPub/getPublicationURL"; 6 6 import { ActionButton } from "components/ActionBar/ActionButton"; 7 7 import { GoBackSmall } from "components/Icons/GoBackSmall"; 8 + import { PaintSmall } from "components/Icons/PaintSmall"; 8 9 import { PublishSmall } from "components/Icons/PublishSmall"; 9 - import { useIdentityData } from "components/IdentityProvider"; 10 10 import { useLeafletPublicationData } from "components/PageSWRDataProvider"; 11 11 import { useToaster } from "components/Toast"; 12 12 import { DotLoader } from "components/utils/DotLoader"; 13 13 import Link from "next/link"; 14 14 import { useParams, useRouter } from "next/navigation"; 15 - import router from "next/router"; 16 15 import { useState } from "react"; 17 - import { useBlocks } from "src/hooks/queries/useBlocks"; 18 - import { useReplicache, useEntity } from "src/replicache"; 16 + import { useReplicache } from "src/replicache"; 19 17 import { Json } from "supabase/database.types"; 20 18 21 19 export const BackToPubButton = (props: {
+1
app/[leaflet_id]/Footer.tsx
··· 44 44 <PublishButton /> 45 45 <ShareOptions /> 46 46 <HelpPopover /> 47 + <ThemePopover entityID={props.entityID} /> 47 48 </ActionFooter> 48 49 ) : ( 49 50 <ActionFooter>
+1
app/[leaflet_id]/Sidebar.tsx
··· 38 38 <> 39 39 <PublishButton /> 40 40 <ShareOptions /> 41 + <ThemePopover entityID={props.leaflet_id} /> 41 42 <HelpPopover /> 42 43 <hr className="text-border" /> 43 44 <BackToPubButton publication={pub.publications} />
+1 -1
app/[leaflet_id]/publish/PublishPost.tsx
··· 29 29 { state: "default" } | { state: "success"; post_url: string } 30 30 >({ state: "default" }); 31 31 return ( 32 - <div className="publishPage w-screen h-full bg-[#FDFCFA] flex sm:pt-0 pt-4 sm:place-items-center justify-center"> 32 + <div className="publishPage w-screen h-full bg-bg-page flex sm:pt-0 pt-4 sm:place-items-center justify-center"> 33 33 {publishState.state === "default" ? ( 34 34 <PublishPostForm setPublishState={setPublishState} {...props} /> 35 35 ) : (
+1 -1
app/home/LeafletList.tsx
··· 64 64 65 65 return ( 66 66 <div className="homeLeafletGrid grow w-full h-full"> 67 - <div className="grid auto-rows-max md:grid-cols-4 sm:grid-cols-3 grid-cols-2 gap-y-4 gap-x-4 sm:gap-6 grow pt-3 pb-28 px-2 sm:pt-6 sm:pb-12 sm:pl-6 sm:pr-1"> 67 + <div className="grid auto-rows-max md:grid-cols-4 sm:grid-cols-3 grid-cols-2 gap-y-8 gap-x-4 sm:gap-x-6 sm:gap-y-8 grow pt-3 pb-28 px-2 sm:pt-6 sm:pb-12 sm:pl-6 sm:pr-1"> 68 68 {leaflets.map((leaflet, index) => ( 69 69 <ReplicacheProvider 70 70 disablePull
+1 -1
app/home/LeafletPreview.tsx
··· 61 61 62 62 return ( 63 63 <div className="relative max-h-40 h-40"> 64 - <ThemeProvider local entityID={root}> 64 + <ThemeProvider local entityID={root} className="!w-full"> 65 65 <div className="rounded-lg sm:hover:shadow-sm overflow-clip border border-border outline outline-2 outline-transparent outline-offset-1 sm:hover:outline-border bg-bg-leaflet grow w-full h-full"> 66 66 {state === "normal" ? ( 67 67 <div className="relative w-full h-full">
+42 -19
app/home/Publications.tsx
··· 13 13 import { Json } from "supabase/database.types"; 14 14 import { PubLeafletPublication } from "lexicons/api"; 15 15 import { AtUri } from "@atproto/syntax"; 16 + import { blobRefToSrc } from "src/utils/blobRefToSrc"; 17 + import { PublicationThemeProvider } from "components/ThemeManager/PublicationThemeProvider"; 16 18 17 19 export const MyPublicationList = () => { 18 20 let { identity } = useIdentityData(); ··· 53 55 54 56 function Publication(props: { uri: string; name: string; record: Json }) { 55 57 let record = props.record as PubLeafletPublication.Record | null; 58 + let pub_creator = new AtUri(props.uri).host; 59 + let backgroundImage = record?.theme?.backgroundImage?.image?.ref 60 + ? blobRefToSrc(record?.theme?.backgroundImage?.image?.ref, pub_creator) 61 + : null; 62 + 63 + let backgroundImageRepeat = record?.theme?.backgroundImage?.repeat; 64 + let backgroundImageSize = record?.theme?.backgroundImage?.width || 500; 65 + let showPageBackground = !!record?.theme?.showPageBackground; 56 66 return ( 57 - <Link 58 - className="pubListItem sm:w-full sm:min-w-0 min-w-40 w-36 px-1 sm:px-2 py-1 sm:h-max hover:no-underline " 59 - href={`${getBasePublicationURL(props)}/dashboard`} 60 - > 61 - <div className="p-3 h-full w-full flex flex-col gap-1 place-items-center opaque-container rounded-lg! text-secondary text-center transparent-outline outline-2 outline-offset-1 hover:outline-border grow "> 62 - {record?.icon && ( 63 - <div 64 - style={{ 65 - backgroundRepeat: "no-repeat", 66 - backgroundPosition: "center", 67 - backgroundSize: "cover", 68 - backgroundImage: `url(/api/atproto_images?did=${new AtUri(props.uri).host}&cid=${(record.icon?.ref as unknown as { $link: string })["$link"]})`, 69 - }} 70 - className="w-6 h-6 rounded-full" 71 - /> 72 - )} 73 - <h4 className="font-bold w-full truncate my-auto">{props.name}</h4> 74 - </div> 75 - </Link> 67 + <PublicationThemeProvider record={record} local pub_creator={pub_creator}> 68 + <Link 69 + className="pubListItem sm:w-full sm:min-w-0 min-w-40 w-36 px-1 sm:px-2 py-1 sm:h-max hover:no-underline " 70 + href={`${getBasePublicationURL(props)}/dashboard`} 71 + > 72 + <div 73 + style={{ 74 + backgroundImage: `url(${backgroundImage})`, 75 + backgroundRepeat: backgroundImageRepeat ? "repeat" : "no-repeat", 76 + backgroundSize: `${backgroundImageRepeat ? `${backgroundImageSize}px` : "cover"}`, 77 + }} 78 + className="p-3 h-full w-full flex flex-col gap-1 place-items-center bg-bg-leaflet border border-border-light rounded-lg text-secondary text-center transparent-outline outline-2 outline-offset-1 hover:outline-border grow " 79 + > 80 + {record?.icon && ( 81 + <div 82 + style={{ 83 + backgroundRepeat: "no-repeat", 84 + backgroundPosition: "center", 85 + backgroundSize: "cover", 86 + backgroundImage: `url(/api/atproto_images?did=${new AtUri(props.uri).host}&cid=${(record.icon?.ref as unknown as { $link: string })["$link"]})`, 87 + }} 88 + className="w-6 h-6 rounded-full" 89 + /> 90 + )} 91 + <h4 92 + className={`font-bold w-full truncate my-auto ${showPageBackground ? "bg-[rgba(var(--bg-page),0.8)]" : ""}`} 93 + > 94 + {props.name} 95 + </h4> 96 + </div> 97 + </Link> 98 + </PublicationThemeProvider> 76 99 ); 77 100 } 78 101
-91
app/lish/PostList.tsx
··· 1 - "use client"; 2 - import Link from "next/link"; 3 - import { Json } from "supabase/database.types"; 4 - import { PubLeafletDocument } from "lexicons/api"; 5 - import { useIdentityData } from "components/IdentityProvider"; 6 - import { useParams } from "next/navigation"; 7 - import { AtUri } from "@atproto/syntax"; 8 - import { getPublicationURL } from "./createPub/getPublicationURL"; 9 - 10 - export const PostList = (props: { 11 - isFeed?: boolean; 12 - publication: { uri: string; record: Json; name: string }; 13 - posts: { 14 - documents: { 15 - data: Json; 16 - indexed_at: string; 17 - uri: string; 18 - } | null; 19 - }[]; 20 - }) => { 21 - if (props.posts.length === 0) { 22 - if (props.isFeed) { 23 - return ( 24 - <div className="italic text-tertiary w-full text-center pt-4"> 25 - Subscribe to publications to see posts in your feed 26 - </div> 27 - ); 28 - } 29 - return null; 30 - } 31 - 32 - return ( 33 - <div className="pubPostList flex flex-col gap-3"> 34 - {props.posts 35 - .sort((a, b) => { 36 - return a.documents?.indexed_at! > b.documents?.indexed_at! ? -1 : 1; 37 - }) 38 - .map((post, index) => { 39 - let p = post.documents?.data as PubLeafletDocument.Record; 40 - let uri = new AtUri(post.documents?.uri!); 41 - 42 - return ( 43 - <PostListItem 44 - {...p} 45 - publication_data={props.publication} 46 - key={index} 47 - isFeed={props.isFeed} 48 - uri={uri} 49 - /> 50 - ); 51 - })} 52 - </div> 53 - ); 54 - }; 55 - 56 - const PostListItem = ( 57 - props: { 58 - publication_data: { uri: string; record: Json; name: string }; 59 - isFeed?: boolean; 60 - uri: AtUri; 61 - } & PubLeafletDocument.Record, 62 - ) => { 63 - let { identity } = useIdentityData(); 64 - let params = useParams(); 65 - return ( 66 - <div className="pubPostListItem flex flex-col"> 67 - {props.isFeed && ( 68 - <Link 69 - href={getPublicationURL(props.publication_data)} 70 - className="font-bold text-tertiary hover:no-underline text-sm " 71 - > 72 - {props.publication} 73 - </Link> 74 - )} 75 - 76 - <Link 77 - href={`${getPublicationURL(props.publication_data)}/${props.uri.rkey}/`} 78 - className="pubPostListContent flex flex-col hover:no-underline hover:text-accent-contrast" 79 - > 80 - <h4>{props.title}</h4> 81 - {/* <div className="text-secondary text-sm pt-1">placeholder description</div> */} 82 - <div className="flex gap-2 text-sm text-tertiary"> 83 - {/* <div className="">{props.publishedAt}</div> */} 84 - {/* <Separator classname="h-4" /> */} 85 - <div>by {params.handle}</div> 86 - </div> 87 - <hr className="border-border-light mt-3" /> 88 - </Link> 89 - </div> 90 - ); 91 - };
+108 -77
app/lish/[did]/[publication]/[rkey]/page.tsx
··· 5 5 import { 6 6 PubLeafletDocument, 7 7 PubLeafletPagesLinearDocument, 8 + PubLeafletPublication, 9 + PubLeafletThemeColor, 8 10 } from "lexicons/api"; 9 11 import { Metadata } from "next"; 10 12 import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; 11 - import { ThemeProvider } from "components/ThemeManager/ThemeProvider"; 13 + import { TextBlock } from "./TextBlock"; 12 14 import { BskyAgent } from "@atproto/api"; 13 15 import { SubscribeWithBluesky } from "app/lish/Subscribe"; 16 + import { 17 + PublicationBackgroundProvider, 18 + PublicationThemeProvider, 19 + } from "components/ThemeManager/PublicationThemeProvider"; 14 20 import { PostContent } from "./PostContent"; 15 21 import { getIdentityData } from "actions/getIdentityData"; 22 + import { EditTiny } from "components/Icons/EditTiny"; 16 23 17 24 export async function generateMetadata(props: { 18 25 params: Promise<{ publication: string; did: string; rkey: string }>; ··· 86 93 if (PubLeafletPagesLinearDocument.isMain(firstPage)) { 87 94 blocks = firstPage.blocks || []; 88 95 } 89 - return ( 90 - <ThemeProvider entityID={null}> 91 - <div className="flex flex-col px-3 sm:px-4 py-3 sm:py-9 w-full bg-[#FDFCFA] h-full min-h-fit overflow-auto"> 92 - <div className="max-w-prose mx-auto w-full"> 93 - <div className="pubHeader flex flex-col pb-5"> 94 - <Link 95 - className="font-bold hover:no-underline text-accent-contrast" 96 - href={getPublicationURL( 97 - document.documents_in_publications[0].publications, 98 - )} 99 - > 100 - {decodeURIComponent((await props.params).publication)} 101 - </Link> 102 - <h2 className="">{record.title}</h2> 103 - {record.description ? ( 104 - <p className="italic text-secondary">{record.description}</p> 105 - ) : null} 106 96 107 - <div className="text-sm text-tertiary pt-3 flex gap-1"> 108 - {profile ? ( 109 - <> 110 - <a 111 - className="text-tertiary" 112 - href={`https://bsky.app/profile/${profile.handle}`} 113 - > 114 - by {profile.displayName || profile.handle} 115 - </a> 116 - </> 97 + let pubRecord = document.documents_in_publications[0]?.publications 98 + .record as PubLeafletPublication.Record; 99 + 100 + let hasPageBackground = !!pubRecord.theme?.showPageBackground; 101 + 102 + return ( 103 + <PublicationThemeProvider 104 + record={pubRecord} 105 + pub_creator={ 106 + document.documents_in_publications[0].publications.identity_did 107 + } 108 + > 109 + <PublicationBackgroundProvider 110 + record={pubRecord} 111 + pub_creator={ 112 + document.documents_in_publications[0].publications.identity_did 113 + } 114 + > 115 + <div 116 + className={`flex flex-col sm:py-6 h-full ${hasPageBackground ? "max-w-prose mx-auto sm:px-0 px-[6px] py-2" : "w-full overflow-y-scroll"}`} 117 + > 118 + <div 119 + className={`sm:max-w-prose max-w-[var(--page-width-units)] w-[1000px] mx-auto px-3 sm:px-4 py-3 ${hasPageBackground ? "overflow-auto h-full bg-[rgba(var(--bg-page),var(--bg-page-alpha))] rounded-lg border border-border" : "h-fit "}`} 120 + > 121 + <div className="postHeader flex flex-col pb-5"> 122 + <Link 123 + className="font-bold hover:no-underline text-accent-contrast" 124 + href={getPublicationURL( 125 + document.documents_in_publications[0].publications, 126 + )} 127 + > 128 + {decodeURIComponent((await props.params).publication)} 129 + </Link> 130 + <h2 className="leading-snug">{record.title}</h2> 131 + {record.description ? ( 132 + <p className="italic text-secondary pt-0.5"> 133 + {record.description} 134 + </p> 117 135 ) : null} 118 - {record.publishedAt ? ( 119 - <> 120 - {" "} 121 - | 122 - <p> 123 - Published{" "} 124 - {new Date(record.publishedAt).toLocaleDateString( 125 - undefined, 126 - { 127 - year: "numeric", 128 - month: "long", 129 - day: "2-digit", 130 - }, 131 - )} 132 - </p> 133 - </> 134 - ) : null} 135 - {identity && 136 - identity.atp_did === 137 - document.documents_in_publications[0]?.publications 138 - .identity_did && ( 136 + 137 + <div className="text-sm text-tertiary pt-3 flex gap-1"> 138 + {profile ? ( 139 139 <> 140 - {" "} 141 - | 142 140 <a 143 - href={`https://leaflet.pub/${document.leaflets_in_publications[0].leaflet}`} 141 + className="text-tertiary" 142 + href={`https://bsky.app/profile/${profile.handle}`} 144 143 > 145 - Edit Post 144 + by {profile.displayName || profile.handle} 146 145 </a> 147 146 </> 148 - )} 147 + ) : null} 148 + {record.publishedAt ? ( 149 + <> 150 + | 151 + <p> 152 + {new Date(record.publishedAt).toLocaleDateString( 153 + undefined, 154 + { 155 + year: "numeric", 156 + month: "long", 157 + day: "2-digit", 158 + }, 159 + )} 160 + </p> 161 + </> 162 + ) : null} 163 + {identity && 164 + identity.atp_did === 165 + document.documents_in_publications[0]?.publications 166 + .identity_did && ( 167 + <> 168 + {" "} 169 + | 170 + <a 171 + href={`https://leaflet.pub/${document.leaflets_in_publications[0].leaflet}`} 172 + > 173 + Edit Post 174 + </a> 175 + </> 176 + )} 177 + </div> 149 178 </div> 179 + <PostContent blocks={blocks} did={did} /> 180 + <hr className="border-border-light mb-4 mt-2" /> 181 + {identity && 182 + identity.atp_did === 183 + document.documents_in_publications[0]?.publications 184 + .identity_did ? ( 185 + <a 186 + href={`https://leaflet.pub/${document.leaflets_in_publications[0].leaflet}`} 187 + className="flex gap-2 items-center hover:!no-underline selected-outline px-2 py-0.5 bg-accent-1 text-accent-2 font-bold w-fit rounded-lg !border-accent-1 !outline-accent-1 mx-auto" 188 + > 189 + <EditTiny /> Edit Post 190 + </a> 191 + ) : ( 192 + <SubscribeWithBluesky 193 + isPost 194 + pub_uri={document.documents_in_publications[0].publications.uri} 195 + subscribers={ 196 + document.documents_in_publications[0].publications 197 + .publication_subscriptions 198 + } 199 + pubName={decodeURIComponent((await props.params).publication)} 200 + /> 201 + )} 150 202 </div> 151 - <PostContent blocks={blocks} did={did} /> 152 - <hr className="border-border-light mb-4 mt-2" /> 153 - {identity && 154 - identity.atp_did === 155 - document.documents_in_publications[0]?.publications.identity_did ? ( 156 - <a 157 - href={`https://leaflet.pub/${document.leaflets_in_publications[0].leaflet}`} 158 - > 159 - Edit Post 160 - </a> 161 - ) : ( 162 - <SubscribeWithBluesky 163 - isPost 164 - pub_uri={document.documents_in_publications[0].publications.uri} 165 - subscribers={ 166 - document.documents_in_publications[0].publications 167 - .publication_subscriptions 168 - } 169 - pubName={decodeURIComponent((await props.params).publication)} 170 - /> 171 - )} 172 203 </div> 173 - </div> 174 - </ThemeProvider> 204 + </PublicationBackgroundProvider> 205 + </PublicationThemeProvider> 175 206 ); 176 207 }
+18 -1
app/lish/[did]/[publication]/dashboard/Actions.tsx
··· 15 15 import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; 16 16 import { usePublicationData } from "./PublicationSWRProvider"; 17 17 import { useSmoker } from "components/Toast"; 18 + import { PaintSmall } from "components/Icons/PaintSmall"; 19 + import { PubThemeSetter } from "components/ThemeManager/PubThemeSetter"; 18 20 19 21 export const Actions = (props: { publication: string }) => { 20 22 return ( ··· 31 33 </Media> 32 34 <NewDraftActionButton publication={props.publication} /> 33 35 <PublicationShareButton /> 36 + <PublicationThemeButton /> 34 37 <PublicationSettingsButton publication={props.publication} /> 35 38 <hr className="border-border-light" /> 36 39 <Media mobile={false}> ··· 104 107 return ( 105 108 <Popover 106 109 asChild 107 - className="w-80" 110 + className="max-w-xs" 108 111 trigger={ 109 112 <ActionButton 110 113 id="pub-settings-button" ··· 117 120 </Popover> 118 121 ); 119 122 } 123 + 124 + function PublicationThemeButton() { 125 + return ( 126 + <Popover 127 + asChild 128 + className="max-w-xs pb-0 !bg-white" 129 + trigger={ 130 + <ActionButton id="pub-theme-button" icon=<PaintSmall /> label="Theme" /> 131 + } 132 + > 133 + <PubThemeSetter /> 134 + </Popover> 135 + ); 136 + }
+4 -4
app/lish/[did]/[publication]/dashboard/DraftList.tsx
··· 5 5 import React, { useState } from "react"; 6 6 import { usePublicationData } from "./PublicationSWRProvider"; 7 7 import { Menu, MenuItem } from "components/Layout"; 8 - import { MoreOptionsTiny } from "components/Icons/MoreOptionsTiny"; 9 8 import { deleteDraft } from "./deleteDraft"; 10 9 import { DeleteSmall } from "components/Icons/DeleteSmall"; 11 10 import { PrimaryKey } from "drizzle-orm/sqlite-core"; 12 11 import { ButtonPrimary } from "components/Buttons"; 12 + import { MoreOptionsVerticalTiny } from "components/Icons/MoreOptionsVerticalTiny"; 13 13 14 14 export function DraftList() { 15 15 let { data: pub_data } = usePublicationData(); 16 16 if (!pub_data) return null; 17 17 return ( 18 - <div className="flex flex-col gap-4 pb-8 sm:pb-12"> 18 + <div className="flex flex-col gap-4 pb-4"> 19 19 <NewDraftSecondaryButton fullWidth publication={pub_data?.uri} /> 20 20 {pub_data.leaflets_in_publications 21 21 .filter((d) => !d.doc) ··· 53 53 align="end" 54 54 asChild 55 55 trigger={ 56 - <button className="text-secondary hover:accent-primary border border-accent-2 rounded-md h-min w-min pt-2.5"> 57 - <MoreOptionsTiny className="rotate-90 h-min w-min " /> 56 + <button className="text-secondary rounded-md selected-outline !border-transparent hover:!border-border "> 57 + <MoreOptionsVerticalTiny /> 58 58 </button> 59 59 } 60 60 >
+42 -19
app/lish/[did]/[publication]/dashboard/PublicationDashboard.tsx
··· 1 1 "use client"; 2 2 import { BlobRef } from "@atproto/lexicon"; 3 3 import { useState } from "react"; 4 + import { useIsMobile } from "src/hooks/isMobile"; 5 + import { theme } from "tailwind.config"; 6 + import { usePublicationData } from "./PublicationSWRProvider"; 7 + import { PubLeafletPublication } from "lexicons/api"; 4 8 5 9 type Tabs = { [tabName: string]: React.ReactNode }; 6 10 export function PublicationDashboard<T extends Tabs>(props: { ··· 10 14 icon: BlobRef | null; 11 15 did: string; 12 16 }) { 17 + let { data: pub } = usePublicationData(); 18 + let showPageBackground = !!(pub?.record as PubLeafletPublication.Record) 19 + ?.theme?.showPageBackground; 13 20 let [tab, setTab] = useState(props.defaultTab); 14 21 let content = props.tabs[tab]; 15 22 16 23 return ( 17 - <div className="pubDashWrapper w-full max-w-[var(--page-width-units)] flex flex-col items-stretch px-4 sm:px-3"> 18 - <div className="pubDashTabWrapper flex flex-row gap-2 w-full justify-between border-b border-border text-secondary items-center"> 19 - {props.icon && ( 24 + <> 25 + <div className="pubDashHeader flex flex-row gap-2 w-full justify-between border-b border-border text-secondary items-center "> 26 + <div className="max-w-full w-[1000px] h-full "> 20 27 <div 21 - className="shrink-0 w-5 h-5 rounded-full" 22 - style={{ 23 - backgroundImage: `url(/api/atproto_images?did=${props.did}&cid=${(props.icon.ref as unknown as { $link: string })["$link"]})`, 24 - backgroundRepeat: "no-repeat", 25 - backgroundPosition: "center", 26 - backgroundSize: "cover", 27 - }} 28 - /> 29 - )}{" "} 30 - <div className="font-bold grow text-tertiary max-w-full truncate pr-2 w-[1000px]"> 31 - {props.name} 28 + className={`flex gap-2 h-fit py-0.5 pl-1 pr-2 w-fit rounded-md ${showPageBackground ? "bg-[rgba(var(--bg-page),0.8)]" : ""}`} 29 + > 30 + {props.icon && ( 31 + <div 32 + className="pubDashLogo shrink-0 w-6 h-6 rounded-full border-2 border-bg-page " 33 + style={{ 34 + backgroundImage: `url(/api/atproto_images?did=${props.did}&cid=${(props.icon.ref as unknown as { $link: string })["$link"]})`, 35 + backgroundRepeat: "no-repeat", 36 + backgroundPosition: "center", 37 + backgroundSize: "cover", 38 + }} 39 + /> 40 + )} 41 + <div className="pubDashName font-bold grow text-tertiary max-w-full truncate sm:block hidden"> 42 + {props.name} 43 + </div> 44 + </div> 32 45 </div> 33 - <div className="pubDashTabs flex flex-row gap-2"> 46 + <div className="pubDashTabs flex flex-row gap-1"> 34 47 {Object.keys(props.tabs).map((t) => ( 35 48 <Tab 36 49 key={t} 37 50 name={t} 38 51 selected={t === tab} 39 52 onSelect={() => setTab(t)} 53 + showPageBackground={showPageBackground} 40 54 /> 41 55 ))} 42 56 </div> 43 57 </div> 44 - <div className="pubDashContent pt-4">{content}</div> 45 - </div> 58 + <div 59 + className={`pubDashContent py-4 px-3 sm:px-4 h-full overflow-auto ${showPageBackground ? "rounded-b-md border border-border border-t-0 bg-[rgba(var(--bg-page),var(--bg-page-alpha))]" : ""}`} 60 + > 61 + {content} 62 + </div> 63 + </> 46 64 ); 47 65 } 48 66 49 - function Tab(props: { name: string; selected: boolean; onSelect: () => void }) { 67 + function Tab(props: { 68 + name: string; 69 + selected: boolean; 70 + showPageBackground: boolean; 71 + onSelect: () => void; 72 + }) { 50 73 return ( 51 74 <div 52 - className={`pubTabs border bg-[#FDFCFA] border-b-0 px-2 pt-1 pb-0.5 rounded-t-md border-border hover:cursor-pointer ${props.selected ? "text-accent-1 font-bold -mb-[1px]" : ""}`} 75 + className={`pubTabs border border-b-0 px-2 pt-1 pb-0.5 rounded-t-md border-border hover:cursor-pointer ${props.selected ? "text-accent-1 font-bold -mb-[1px]" : ""} ${props.showPageBackground ? "bg-[rgba(var(--bg-page),var(--bg-page-alpha))]" : ""}`} 53 76 onClick={() => props.onSelect()} 54 77 > 55 78 {props.name}
+29 -1
app/lish/[did]/[publication]/dashboard/PublicationSubscribers.tsx
··· 2 2 import { AppBskyActorProfile } from "lexicons/api"; 3 3 import { usePublicationData } from "./PublicationSWRProvider"; 4 4 import { blobRefToSrc } from "src/utils/blobRefToSrc"; 5 + import { ButtonPrimary } from "components/Buttons"; 6 + import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; 7 + import { useSmoker } from "components/Toast"; 5 8 6 9 export function PublicationSubscribers() { 7 10 let { data: publication } = usePublicationData(); 11 + let smoker = useSmoker(); 12 + 8 13 if (!publication) return <div>null</div>; 9 14 if (publication.publication_subscriptions.length === 0) 10 - return <div>No subscribers yet!</div>; 15 + return ( 16 + <div className="italic text-tertiary flex flex-col gap-0 text-center justify-center pt-4"> 17 + <p className="font-bold"> No subscribers yet </p> 18 + <p>Start sharing your publication!</p> 19 + <ButtonPrimary 20 + className="mx-auto mt-2" 21 + onClick={(e) => { 22 + e.preventDefault(); 23 + let rect = (e.currentTarget as Element)?.getBoundingClientRect(); 24 + navigator.clipboard.writeText(getPublicationURL(publication!)); 25 + smoker({ 26 + position: { 27 + x: rect ? rect.left + (rect.right - rect.left) / 2 : 0, 28 + y: rect ? rect.top + 26 : 0, 29 + }, 30 + text: "Copied Publication URL!", 31 + }); 32 + }} 33 + > 34 + Copy Share Link 35 + </ButtonPrimary> 36 + </div> 37 + ); 38 + 11 39 return ( 12 40 <div> 13 41 <h2>{publication.publication_subscriptions.length} Subscribers </h2>
+26 -19
app/lish/[did]/[publication]/dashboard/PublishedPostsLists.tsx
··· 9 9 import { useParams } from "next/navigation"; 10 10 import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; 11 11 import { Menu, MenuItem } from "components/Layout"; 12 - import { MoreOptionsTiny } from "components/Icons/MoreOptionsTiny"; 13 12 import { deletePost } from "./deletePost"; 14 13 import { mutate } from "swr"; 15 14 import { Button } from "react-aria-components"; 16 15 import { ButtonPrimary } from "components/Buttons"; 16 + import { MoreOptionsVerticalTiny } from "components/Icons/MoreOptionsVerticalTiny"; 17 17 18 18 export function PublishedPostsList() { 19 19 let { data: publication } = usePublicationData(); ··· 26 26 </div> 27 27 ); 28 28 return ( 29 - <div className="publishedList w-full flex flex-col gap-4 pb-8 sm:pb-12"> 29 + <div className="publishedList w-full flex flex-col gap-4 pb-4"> 30 30 {publication.documents_in_publications 31 31 .sort((a, b) => { 32 32 let aRecord = a.documents?.data! as PubLeafletDocument.Record; ··· 49 49 50 50 return ( 51 51 <Fragment key={doc.documents?.uri}> 52 - <div className="flex w-full "> 53 - <Link 54 - target="_blank" 55 - href={`${getPublicationURL(publication)}/${uri.rkey}`} 56 - className="publishedPost grow flex flex-col hover:!no-underline" 57 - > 58 - <h3 className="text-primary">{record.title}</h3> 52 + <div className="flex gap-2 w-full "> 53 + <div className="publishedPost grow flex flex-col hover:!no-underline"> 54 + <div className="flex justify-between gap-2"> 55 + <a 56 + className="hover:!no-underline" 57 + target="_blank" 58 + href={`${getPublicationURL(publication)}/${uri.rkey}`} 59 + > 60 + <h3 className="text-primary grow leading-snug"> 61 + {record.title} 62 + </h3> 63 + </a> 64 + <div className="flex justify-start align-top flex-row gap-1"> 65 + {leaflet && ( 66 + <Link className="pt-[6px]" href={`/${leaflet.leaflet}`}> 67 + <EditTiny /> 68 + </Link> 69 + )} 70 + <Options document_uri={doc.documents.uri} /> 71 + </div> 72 + </div> 73 + 59 74 {record.description ? ( 60 75 <p className="italic text-secondary"> 61 76 {record.description} ··· 74 89 )} 75 90 </p> 76 91 ) : null} 77 - </Link> 78 - <div className="flex justify-start align-top flex-row"> 79 - {leaflet && ( 80 - <Link className="pt-[6px]" href={`/${leaflet.leaflet}`}> 81 - <EditTiny /> 82 - </Link> 83 - )} 84 - <Options document_uri={doc.documents.uri} /> 85 92 </div> 86 93 </div> 87 94 <hr className="last:hidden border-border-light" /> ··· 98 105 align="end" 99 106 asChild 100 107 trigger={ 101 - <button className="text-secondary hover:accent-primary border border-accent-2 rounded-md h-min w-min pt-2.5"> 102 - <MoreOptionsTiny className="rotate-90 h-min w-min " /> 108 + <button className="text-secondary rounded-md selected-outline !border-transparent hover:!border-border h-min"> 109 + <MoreOptionsVerticalTiny /> 103 110 </button> 104 111 } 105 112 >
+16 -9
app/lish/[did]/[publication]/dashboard/page.tsx
··· 8 8 import { PublicationDashboard } from "./PublicationDashboard"; 9 9 import { DraftList } from "./DraftList"; 10 10 import { getIdentityData } from "actions/getIdentityData"; 11 - import { ThemeProvider } from "components/ThemeManager/ThemeProvider"; 12 11 import { Actions } from "./Actions"; 13 12 import React from "react"; 14 13 import { get_publication_data } from "app/api/rpc/[command]/get_publication_data"; 15 14 import { PublicationSWRDataProvider } from "./PublicationSWRProvider"; 16 15 import { PublishedPostsList } from "./PublishedPostsLists"; 17 - import { PubLeafletPublication } from "lexicons/api"; 16 + import { PubLeafletPublication, PubLeafletThemeColor } from "lexicons/api"; 18 17 import { PublicationSubscribers } from "./PublicationSubscribers"; 18 + import { 19 + PublicationThemeProvider, 20 + PublicationThemeProviderDashboard, 21 + } from "components/ThemeManager/PublicationThemeProvider"; 22 + import { blobRefToSrc } from "src/utils/blobRefToSrc"; 19 23 20 24 export async function generateMetadata(props: { 21 25 params: Promise<{ publication: string; did: string }>; ··· 61 65 ); 62 66 63 67 let record = publication?.record as PubLeafletPublication.Record | null; 68 + 69 + let showPageBackground = !!record?.theme?.showPageBackground; 70 + 64 71 if (!publication || identity.atp_did !== publication.identity_did) 65 72 return <PubNotFound />; 66 73 ··· 71 78 publication_name={publication.name} 72 79 publication_data={publication} 73 80 > 74 - <ThemeProvider entityID={null}> 75 - <div className="w-screen h-full flex place-items-center bg-[#FDFCFA]"> 76 - <div className="relative w-max h-full flex sm:flex-row flex-col sm:items-stretch pwa-padding"> 81 + <PublicationThemeProviderDashboard record={record}> 82 + <div className="pubDashWrapper relative w-max h-full flex items-stretch"> 83 + <div className="flex sm:flex-row flex-col max-h-full h-full"> 77 84 <div 78 - className="spacer flex justify-end items-start" 85 + className="pubDashSidebarWrapper flex justify-end items-start " 79 86 style={{ width: `calc(50vw - ((var(--page-width-units)/2))` }} 80 87 > 81 - <div className="relative w-16 justify-items-end"> 88 + <div className="pubDashSidebar relative w-16 justify-items-end"> 82 89 <Sidebar className="mt-6 p-2 "> 83 90 <Actions publication={publication.uri} /> 84 91 </Sidebar> 85 92 </div> 86 93 </div> 87 94 <div 88 - className={`h-full overflow-y-scroll pt-4 sm:pt-8 max-w-[var(--page-width-units)]`} 95 + className={`pubDash grow sm:h-full h-32 w-full flex flex-col items-stretch pt-2 sm:pt-6 ml-[6px] sm:ml-0 max-w-[var(--page-width-units)] ${showPageBackground ? "sm:pb-8 pb-1" : "pb-0"}`} 89 96 > 90 97 <PublicationDashboard 91 98 did={did} ··· 104 111 </Footer> 105 112 </div> 106 113 </div> 107 - </ThemeProvider> 114 + </PublicationThemeProviderDashboard> 108 115 </PublicationSWRDataProvider> 109 116 ); 110 117 } catch (e) {
+100 -77
app/lish/[did]/[publication]/page.tsx
··· 4 4 import { ThemeProvider } from "components/ThemeManager/ThemeProvider"; 5 5 import { get_publication_data } from "app/api/rpc/[command]/get_publication_data"; 6 6 import { AtUri } from "@atproto/syntax"; 7 - import { PubLeafletDocument, PubLeafletPublication } from "lexicons/api"; 7 + import { 8 + PubLeafletDocument, 9 + PubLeafletPublication, 10 + PubLeafletThemeColor, 11 + } from "lexicons/api"; 8 12 import Link from "next/link"; 9 13 import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; 10 14 import { BskyAgent } from "@atproto/api"; 11 15 import { SubscribeWithBluesky } from "app/lish/Subscribe"; 12 16 import React from "react"; 17 + import { 18 + PublicationBackgroundProvider, 19 + PublicationThemeProvider, 20 + } from "components/ThemeManager/PublicationThemeProvider"; 13 21 14 22 export async function generateMetadata(props: { 15 23 params: Promise<{ publication: string; did: string }>; ··· 58 66 59 67 let record = publication?.record as PubLeafletPublication.Record | null; 60 68 69 + let showPageBackground = record?.theme?.showPageBackground; 70 + 61 71 if (!publication) return <PubNotFound />; 62 72 try { 63 73 return ( 64 - <ThemeProvider entityID={null}> 65 - <div className="publicationWrapper pwa-padding w-screen h-full min-h-fit flex place-items-center bg-[#FDFCFA]"> 66 - <div className="publication max-w-prose w-full mx-auto h-full sm:pt-8 pt-4 px-3 pb-12 sm:pb-8 "> 67 - <div className="flex flex-col pb-8 w-full text-center justify-center "> 68 - <div className="flex flex-col gap-3 justify-center place-items-center"> 74 + <PublicationThemeProvider 75 + record={record} 76 + pub_creator={publication.identity_did} 77 + > 78 + <PublicationBackgroundProvider 79 + record={record} 80 + pub_creator={publication.identity_did} 81 + > 82 + <div 83 + className={`pubWrapper flex flex-col sm:py-6 h-full ${showPageBackground ? "max-w-prose mx-auto sm:px-0 px-[6px] py-2" : "w-full overflow-y-scroll"}`} 84 + > 85 + <div 86 + className={`pub sm:max-w-prose max-w-[var(--page-width-units)] w-[1000px] mx-auto px-3 sm:px-4 py-5 ${showPageBackground ? "overflow-auto h-full bg-[rgba(var(--bg-page),var(--bg-page-alpha))] border border-border rounded-lg" : "h-fit"}`} 87 + > 88 + <div className="pubHeader flex flex-col pb-8 w-full text-center justify-center "> 69 89 {record?.icon && ( 70 90 <div 71 - className="shrink-0 w-10 h-10 rounded-full" 91 + className="shrink-0 w-10 h-10 rounded-full mx-auto" 72 92 style={{ 73 93 backgroundImage: `url(/api/atproto_images?did=${did}&cid=${(record.icon.ref as unknown as { $link: string })["$link"]})`, 74 94 backgroundRepeat: "no-repeat", ··· 77 97 }} 78 98 /> 79 99 )} 80 - <h2 className="text-accent-contrast sm:text-xl text-[22px] pb-2 sm:pb-1"> 100 + <h2 className="text-accent-contrast sm:text-xl text-[22px] pt-1 "> 81 101 {publication.name} 82 102 </h2> 103 + <p className="sm:text-lg text-secondary"> 104 + {record?.description}{" "} 105 + </p> 106 + {profile && ( 107 + <p className="italic text-tertiary sm:text-base text-sm"> 108 + <strong className="">by {profile.displayName}</strong>{" "} 109 + <a 110 + className="text-tertiary" 111 + href={`https://bsky.app/profile/${profile.handle}`} 112 + > 113 + @{profile.handle} 114 + </a> 115 + </p> 116 + )} 117 + <div className="sm:pt-4 pt-4"> 118 + <SubscribeWithBluesky 119 + pubName={publication.name} 120 + pub_uri={publication.uri} 121 + subscribers={publication.publication_subscriptions} 122 + /> 123 + </div> 83 124 </div> 84 - <p className="sm:text-lg text-secondary pb-1"> 85 - {record?.description}{" "} 86 - </p> 87 - {profile && ( 88 - <p className="italic text-tertiary sm:text-base text-sm"> 89 - <strong className="">by {profile.displayName}</strong>{" "} 90 - <a 91 - className="text-tertiary" 92 - href={`https://bsky.app/profile/${profile.handle}`} 93 - > 94 - @{profile.handle} 95 - </a> 96 - </p> 97 - )} 98 - <div className="sm:pt-4 pt-4"> 99 - <SubscribeWithBluesky 100 - pubName={publication.name} 101 - pub_uri={publication.uri} 102 - subscribers={publication.publication_subscriptions} 103 - /> 125 + <div className="publicationPostList w-full flex flex-col gap-4"> 126 + {publication.documents_in_publications 127 + .filter((d) => !!d?.documents) 128 + .sort((a, b) => { 129 + let aRecord = a.documents 130 + ?.data! as PubLeafletDocument.Record; 131 + let bRecord = b.documents 132 + ?.data! as PubLeafletDocument.Record; 133 + const aDate = aRecord.publishedAt 134 + ? new Date(aRecord.publishedAt) 135 + : new Date(0); 136 + const bDate = bRecord.publishedAt 137 + ? new Date(bRecord.publishedAt) 138 + : new Date(0); 139 + return bDate.getTime() - aDate.getTime(); // Sort by most recent first 140 + }) 141 + .map((doc) => { 142 + if (!doc.documents) return null; 143 + let uri = new AtUri(doc.documents.uri); 144 + let record = doc.documents 145 + .data as PubLeafletDocument.Record; 146 + return ( 147 + <React.Fragment key={doc.documents?.uri}> 148 + <div className="flex w-full "> 149 + <Link 150 + href={`${getPublicationURL(publication)}/${uri.rkey}`} 151 + className="publishedPost grow flex flex-col hover:!no-underline" 152 + > 153 + <h3 className="text-primary">{record.title}</h3> 154 + <p className="italic text-secondary"> 155 + {record.description} 156 + </p> 157 + <p className="text-sm text-tertiary pt-2"> 158 + {record.publishedAt && 159 + new Date(record.publishedAt).toLocaleDateString( 160 + undefined, 161 + { 162 + year: "numeric", 163 + month: "long", 164 + day: "2-digit", 165 + }, 166 + )}{" "} 167 + </p> 168 + </Link> 169 + </div> 170 + <hr className="last:hidden border-border-light" /> 171 + </React.Fragment> 172 + ); 173 + })} 104 174 </div> 105 175 </div> 106 - <div className="publicationPostList w-full flex flex-col gap-4"> 107 - {publication.documents_in_publications 108 - .filter((d) => !!d?.documents) 109 - .sort((a, b) => { 110 - let aRecord = a.documents?.data! as PubLeafletDocument.Record; 111 - let bRecord = b.documents?.data! as PubLeafletDocument.Record; 112 - const aDate = aRecord.publishedAt 113 - ? new Date(aRecord.publishedAt) 114 - : new Date(0); 115 - const bDate = bRecord.publishedAt 116 - ? new Date(bRecord.publishedAt) 117 - : new Date(0); 118 - return bDate.getTime() - aDate.getTime(); // Sort by most recent first 119 - }) 120 - .map((doc) => { 121 - if (!doc.documents) return null; 122 - let uri = new AtUri(doc.documents.uri); 123 - let record = doc.documents.data as PubLeafletDocument.Record; 124 - return ( 125 - <React.Fragment key={doc.documents?.uri}> 126 - <div className="flex w-full "> 127 - <Link 128 - href={`${getPublicationURL(publication)}/${uri.rkey}`} 129 - className="publishedPost grow flex flex-col hover:!no-underline" 130 - > 131 - <h3 className="text-primary">{record.title}</h3> 132 - <p className="italic text-secondary"> 133 - {record.description} 134 - </p> 135 - <p className="text-sm text-tertiary pt-2"> 136 - {record.publishedAt && 137 - new Date(record.publishedAt).toLocaleDateString( 138 - undefined, 139 - { 140 - year: "numeric", 141 - month: "long", 142 - day: "2-digit", 143 - }, 144 - )}{" "} 145 - </p> 146 - </Link> 147 - </div> 148 - <hr className="last:hidden border-border-light" /> 149 - </React.Fragment> 150 - ); 151 - })} 152 - </div> 153 176 </div> 154 - </div> 155 - </ThemeProvider> 177 + </PublicationBackgroundProvider> 178 + </PublicationThemeProvider> 156 179 ); 157 180 } catch (e) { 158 181 console.log(e);
+1 -1
app/lish/[did]/[publication]/subscribeSuccess/page.tsx
··· 3 3 4 4 export default function SubscribeSuccess() { 5 5 return ( 6 - <div className="h-full w-screen bg-[#FDFCFA] flex place-items-center text-center "> 6 + <div className="h-full w-screen bg-bg-leaflet flex place-items-center text-center "> 7 7 <div className="container p-4 max-w-md mx-auto justify-center place-items-center flex flex-col gap-2"> 8 8 <h3 className="text-secondary">You've Subscribed!</h3> 9 9 <div className="text-tertiary">
+104 -1
app/lish/createPub/updatePublication.ts
··· 1 1 "use server"; 2 2 import { TID } from "@atproto/common"; 3 - import { AtpBaseClient, PubLeafletPublication } from "lexicons/api"; 3 + import { 4 + AtpBaseClient, 5 + PubLeafletPublication, 6 + PubLeafletThemeColor, 7 + } from "lexicons/api"; 4 8 import { createOauthClient } from "src/atproto-oauth"; 5 9 import { getIdentityData } from "actions/getIdentityData"; 6 10 import { supabaseServerClient } from "supabase/serverClient"; 7 11 import { Json } from "supabase/database.types"; 8 12 import { AtUri } from "@atproto/syntax"; 9 13 import { redirect } from "next/navigation"; 14 + import { $Typed } from "@atproto/api"; 15 + import { ids } from "lexicons/api/lexicons"; 10 16 11 17 export async function updatePublication({ 12 18 uri, ··· 130 136 .single(); 131 137 return { success: true, publication }; 132 138 } 139 + 140 + type Color = 141 + | $Typed<PubLeafletThemeColor.Rgb, "pub.leaflet.theme.color#rgb"> 142 + | $Typed<PubLeafletThemeColor.Rgba, "pub.leaflet.theme.color#rgba">; 143 + export async function updatePublicationTheme({ 144 + uri, 145 + theme, 146 + }: { 147 + uri: string; 148 + theme: { 149 + backgroundImage?: File | null; 150 + backgroundRepeat?: number | null; 151 + backgroundColor: Color; 152 + primary: Color; 153 + pageBackground: Color; 154 + showPageBackground: boolean; 155 + accentBackground: Color; 156 + accentText: Color; 157 + }; 158 + }) { 159 + const oauthClient = await createOauthClient(); 160 + let identity = await getIdentityData(); 161 + if (!identity || !identity.atp_did) return; 162 + 163 + let credentialSession = await oauthClient.restore(identity.atp_did); 164 + let agent = new AtpBaseClient( 165 + credentialSession.fetchHandler.bind(credentialSession), 166 + ); 167 + let { data: existingPub } = await supabaseServerClient 168 + .from("publications") 169 + .select("*") 170 + .eq("uri", uri) 171 + .single(); 172 + if (!existingPub || existingPub.identity_did !== identity.atp_did) return; 173 + let aturi = new AtUri(existingPub.uri); 174 + 175 + let oldRecord = existingPub.record as PubLeafletPublication.Record; 176 + let record: PubLeafletPublication.Record = { 177 + ...oldRecord, 178 + $type: "pub.leaflet.publication", 179 + theme: { 180 + backgroundImage: theme.backgroundImage 181 + ? { 182 + $type: "pub.leaflet.theme.backgroundImage", 183 + image: ( 184 + await agent.com.atproto.repo.uploadBlob( 185 + new Uint8Array(await theme.backgroundImage.arrayBuffer()), 186 + { encoding: theme.backgroundImage.type }, 187 + ) 188 + )?.data.blob, 189 + width: theme.backgroundRepeat || undefined, 190 + repeat: !!theme.backgroundRepeat, 191 + } 192 + : theme.backgroundImage === null 193 + ? undefined 194 + : oldRecord.theme?.backgroundImage, 195 + backgroundColor: theme.backgroundColor 196 + ? { 197 + ...theme.backgroundColor, 198 + } 199 + : undefined, 200 + primary: { 201 + ...theme.primary, 202 + }, 203 + pageBackground: { 204 + ...theme.pageBackground, 205 + }, 206 + showPageBackground: theme.showPageBackground, 207 + accentBackground: { 208 + ...theme.accentBackground, 209 + }, 210 + accentText: { 211 + ...theme.accentText, 212 + }, 213 + }, 214 + }; 215 + 216 + let result = await agent.com.atproto.repo.putRecord({ 217 + repo: credentialSession.did!, 218 + rkey: aturi.rkey, 219 + record, 220 + collection: record.$type, 221 + validate: false, 222 + }); 223 + 224 + //optimistically write to our db! 225 + let { data: publication, error } = await supabaseServerClient 226 + .from("publications") 227 + .update({ 228 + name: record.name, 229 + record: record as Json, 230 + }) 231 + .eq("uri", uri) 232 + .select() 233 + .single(); 234 + return { success: true, publication }; 235 + }
+1 -1
components/ActionBar/Footer.tsx
··· 5 5 <Media 6 6 mobile 7 7 className={` 8 - actionFooter touch-none 8 + actionFooter touch-none shrink-0 9 9 w-full z-10 10 10 px-2 pt-1 pwa-padding-bottom 11 11 flex justify-start gap-1
+11 -10
components/Checkbox.tsx
··· 1 1 import { CheckboxChecked } from "./Icons/CheckboxChecked"; 2 2 import { CheckboxEmpty } from "./Icons/CheckboxEmpty"; 3 3 import { Props } from "./Icons/Props"; 4 + import React, { forwardRef, type JSX } from "react"; 4 5 5 6 export function Checkbox(props: { 6 7 checked: boolean; ··· 33 34 ); 34 35 } 35 36 36 - export function Radio(props: { 37 - checked: boolean; 38 - onChange: (e: React.ChangeEvent<HTMLInputElement>) => void; 39 - id: string; 40 - name: string; 41 - value: string; 42 - children: React.ReactNode; 43 - radioEmptyClassName?: string; 44 - radioCheckedClassName?: string; 45 - }) { 37 + type RadioProps = Omit<JSX.IntrinsicElements["input"], "content">; 38 + 39 + export function Radio( 40 + props: { 41 + onChange: (e: React.ChangeEvent<HTMLInputElement>) => void; 42 + children: React.ReactNode; 43 + radioEmptyClassName?: string; 44 + radioCheckedClassName?: string; 45 + } & JSX.IntrinsicElements["input"], 46 + ) { 46 47 return ( 47 48 <label 48 49 htmlFor={props.id}
+5 -1
components/Pages/useCardBorderHidden.ts
··· 1 1 import { useLeafletPublicationData } from "components/PageSWRDataProvider"; 2 + import { PubLeafletPublication } from "lexicons/api"; 2 3 import { useEntity, useReplicache } from "src/replicache"; 3 4 4 5 export function useCardBorderHidden(entityID: string) { ··· 9 10 let cardBorderHidden = 10 11 useEntity(entityID, "theme/card-border-hidden") || rootCardBorderHidden; 11 12 if (!cardBorderHidden && !rootCardBorderHidden) { 12 - if (pub) return true; 13 + if (pub?.publications?.record) { 14 + let record = pub.publications.record as PubLeafletPublication.Record; 15 + return !record.theme?.showPageBackground; 16 + } 13 17 return false; 14 18 } 15 19 return (cardBorderHidden || rootCardBorderHidden)?.data.value;
+4 -4
components/ThemeManager/AccentThemePickers.tsx components/ThemeManager/Pickers/AccentPickers.tsx
··· 1 1 "use client"; 2 2 3 3 import { useMemo } from "react"; 4 - import { pickers, setColorAttribute } from "./ThemeSetter"; 4 + import { pickers, setColorAttribute } from "../ThemeSetter"; 5 5 import { ColorPicker } from "./ColorPicker"; 6 6 import { useReplicache } from "src/replicache"; 7 - import { useColorAttribute } from "./useColorAttribute"; 7 + import { useColorAttribute } from "../useColorAttribute"; 8 8 9 - export const AccentThemePickers = (props: { 9 + export const AccentPickers = (props: { 10 10 entityID: string; 11 11 openPicker: pickers; 12 12 setOpenPicker: (thisPicker: pickers) => void; ··· 27 27 <div 28 28 className="themeLeafletControls text-accent-2 flex flex-col gap-2 h-full bg-bg-leaflet p-2 rounded-md border border-accent-2 shadow-[0_0_0_1px_rgb(var(--accent-1))]" 29 29 style={{ 30 - backgroundColor: "rgba(var(--accent-contrast), 0.5)", 30 + backgroundColor: "rgba(var(--accent-1), 0.5)", 31 31 }} 32 32 > 33 33 <ColorPicker
+11 -7
components/ThemeManager/ColorPicker.tsx components/ThemeManager/Pickers/ColorPicker.tsx
··· 12 12 SliderTrack, 13 13 ColorSwatch, 14 14 } from "react-aria-components"; 15 - import { pickers } from "./ThemeSetter"; 15 + import { pickers } from "../ThemeSetter"; 16 16 import { Separator } from "components/Layout"; 17 17 import { onMouseDown } from "src/utils/iosInputMouseDown"; 18 18 ··· 54 54 backgroundSize: "cover", 55 55 }} 56 56 /> 57 - <strong className="">{props.label}</strong> 57 + <strong className="w-max">{props.label}</strong> 58 58 </button> 59 59 60 60 <div className="flex gap-1"> 61 61 {props.value === undefined ? ( 62 62 <div>default</div> 63 + ) : props.disabled ? ( 64 + <div className="text-tertiary italic">hidden</div> 63 65 ) : ( 64 66 <ColorField className="w-fit gap-1"> 65 67 <Input 66 - disabled={props.disabled} 67 68 onMouseDown={onMouseDown} 68 69 onFocus={(e) => { 69 70 e.currentTarget.setSelectionRange( ··· 83 84 /> 84 85 </ColorField> 85 86 )} 86 - {props.alpha && ( 87 + {props.alpha && !props.disabled && ( 87 88 <> 88 89 <Separator classname="my-1" /> 89 - <ColorField className="w-fit pl-[6px]" channel="alpha"> 90 + <ColorField 91 + className={`w-[48px] pl-[6px] ${props.disabled ? "opacity-50" : ""}`} 92 + channel="alpha" 93 + > 90 94 <Input 91 95 disabled={props.disabled} 92 96 onMouseDown={onMouseDown} ··· 101 105 e.currentTarget.blur(); 102 106 } else return; 103 107 }} 104 - className="w-[72px] bg-transparent outline-none text-primary disabled:text-tertiary" 108 + className="w-[72px] bg-transparent outline-none " 105 109 /> 106 110 </ColorField> 107 111 </> ··· 130 134 colorSpace="hsb" 131 135 className="w-full mt-1 rounded-full" 132 136 style={{ 133 - backgroundImage: `url(./transparent-bg.png)`, 137 + backgroundImage: `url(/transparent-bg.png)`, 134 138 backgroundRepeat: "repeat", 135 139 backgroundSize: "8px", 136 140 }}
+1 -1
components/ThemeManager/ImageSetters.tsx components/ThemeManager/Pickers/ImagePicker.tsx
··· 1 1 import * as Slider from "@radix-ui/react-slider"; 2 - import { theme } from "../../tailwind.config"; 2 + import { theme } from "../../../tailwind.config"; 3 3 4 4 import { Color } from "react-aria-components"; 5 5
-223
components/ThemeManager/LeafletBGPicker.tsx
··· 1 - "use client"; 2 - 3 - import { 4 - ColorPicker as SpectrumColorPicker, 5 - parseColor, 6 - Color, 7 - ColorArea, 8 - ColorThumb, 9 - ColorSlider, 10 - Input, 11 - ColorField, 12 - SliderTrack, 13 - ColorSwatch, 14 - } from "react-aria-components"; 15 - import { pickers, setColorAttribute } from "./ThemeSetter"; 16 - import { thumbStyle } from "./ColorPicker"; 17 - import { ImageInput, ImageSettings } from "./ImageSetters"; 18 - import { useEntity, useReplicache } from "src/replicache"; 19 - import { useColorAttribute } from "components/ThemeManager/useColorAttribute"; 20 - import { Separator } from "components/Layout"; 21 - import { onMouseDown } from "src/utils/iosInputMouseDown"; 22 - import { BlockImageSmall } from "components/Icons/BlockImageSmall"; 23 - 24 - export const LeafletBGPicker = (props: { 25 - entityID: string; 26 - openPicker: pickers; 27 - thisPicker: pickers; 28 - setOpenPicker: (thisPicker: pickers) => void; 29 - closePicker: () => void; 30 - setValue: (c: Color) => void; 31 - card?: boolean; 32 - }) => { 33 - let bgImage = useEntity( 34 - props.entityID, 35 - props.card ? "theme/card-background-image" : "theme/background-image", 36 - ); 37 - let bgColor = useColorAttribute( 38 - props.entityID, 39 - props.card ? "theme/card-background" : "theme/page-background", 40 - ); 41 - let open = props.openPicker == props.thisPicker; 42 - let { rep } = useReplicache(); 43 - 44 - return ( 45 - <> 46 - <div className="bgPickerLabel flex justify-between place-items-center "> 47 - <div className="bgPickerColorLabel flex gap-2 items-center"> 48 - <button 49 - onClick={() => { 50 - if (props.openPicker === props.thisPicker) { 51 - props.setOpenPicker("null"); 52 - } else { 53 - props.setOpenPicker(props.thisPicker); 54 - } 55 - }} 56 - className="flex gap-2 items-center" 57 - > 58 - <ColorSwatch 59 - color={bgColor} 60 - className={`w-6 h-6 rounded-full border-2 border-white shadow-[0_0_0_1px_#8C8C8C]`} 61 - style={{ 62 - backgroundImage: bgImage?.data.src 63 - ? `url(${bgImage.data.src})` 64 - : undefined, 65 - backgroundSize: "cover", 66 - }} 67 - /> 68 - <strong 69 - className={`${props.card ? "text-primary" : "text-[#595959]"}`} 70 - > 71 - {props.card ? "Page" : "Background"} 72 - </strong> 73 - </button> 74 - 75 - <div className="flex"> 76 - {bgImage ? ( 77 - <div 78 - className={`${props.card ? "text-secondary" : "text-[#969696]"}`} 79 - > 80 - Image 81 - </div> 82 - ) : ( 83 - <> 84 - <ColorField className="w-fit gap-1" value={bgColor}> 85 - <Input 86 - onMouseDown={onMouseDown} 87 - onFocus={(e) => { 88 - e.currentTarget.setSelectionRange( 89 - 1, 90 - e.currentTarget.value.length, 91 - ); 92 - }} 93 - onPaste={(e) => { 94 - console.log(e); 95 - }} 96 - onKeyDown={(e) => { 97 - if (e.key === "Enter") { 98 - e.currentTarget.blur(); 99 - } else return; 100 - }} 101 - onBlur={(e) => { 102 - props.setValue(parseColor(e.currentTarget.value)); 103 - }} 104 - className={`w-[72px] bg-transparent outline-none ${props.card ? "text-primary" : "text-[#595959]"}`} 105 - /> 106 - </ColorField> 107 - {props.card && ( 108 - <> 109 - <Separator classname="my-1" /> 110 - 111 - <SpectrumColorPicker 112 - value={bgColor} 113 - onChange={setColorAttribute( 114 - rep, 115 - props.entityID, 116 - )( 117 - props.card 118 - ? "theme/card-background" 119 - : "theme/page-background", 120 - )} 121 - > 122 - <ColorField className="w-fit pl-[6px]" channel="alpha"> 123 - <Input 124 - onMouseDown={onMouseDown} 125 - onFocus={(e) => { 126 - e.currentTarget.setSelectionRange( 127 - 0, 128 - e.currentTarget.value.length - 1, 129 - ); 130 - }} 131 - onKeyDown={(e) => { 132 - if (e.key === "Enter") { 133 - e.currentTarget.blur(); 134 - } else return; 135 - }} 136 - className="w-[48px] bg-transparent outline-none text-primary" 137 - /> 138 - </ColorField> 139 - </SpectrumColorPicker> 140 - </> 141 - )} 142 - </> 143 - )} 144 - </div> 145 - </div> 146 - <label className="hover:cursor-pointer h-fit"> 147 - <div 148 - className={ 149 - props.card 150 - ? "text-tertiary hover:text-accent-contrast" 151 - : "text-[#8C8C8C] hover:text-[#0000FF]" 152 - } 153 - > 154 - <BlockImageSmall /> 155 - </div> 156 - <div className="hidden"> 157 - <ImageInput 158 - {...props} 159 - onChange={() => { 160 - props.setOpenPicker(props.thisPicker); 161 - }} 162 - /> 163 - </div> 164 - </label> 165 - </div> 166 - {open && ( 167 - <div className="bgImageAndColorPicker w-full flex flex-col gap-2 "> 168 - <SpectrumColorPicker 169 - value={bgColor} 170 - onChange={setColorAttribute( 171 - rep, 172 - props.entityID, 173 - )(props.card ? "theme/card-background" : "theme/page-background")} 174 - > 175 - {bgImage ? ( 176 - <ImageSettings 177 - entityID={props.entityID} 178 - card={props.card} 179 - setValue={props.setValue} 180 - /> 181 - ) : ( 182 - <> 183 - <ColorArea 184 - className="w-full h-[128px] rounded-md" 185 - colorSpace="hsb" 186 - xChannel="saturation" 187 - yChannel="brightness" 188 - > 189 - <ColorThumb className={thumbStyle} /> 190 - </ColorArea> 191 - <ColorSlider 192 - colorSpace="hsb" 193 - className="w-full " 194 - channel="hue" 195 - > 196 - <SliderTrack className="h-2 w-full rounded-md"> 197 - <ColorThumb className={`${thumbStyle} mt-[4px]`} /> 198 - </SliderTrack> 199 - </ColorSlider> 200 - </> 201 - )} 202 - {props.card && ( 203 - <ColorSlider 204 - colorSpace="hsb" 205 - className="w-full mt-1 rounded-full" 206 - style={{ 207 - backgroundImage: `url(./transparent-bg.png)`, 208 - backgroundRepeat: "repeat", 209 - backgroundSize: "8px", 210 - }} 211 - channel="alpha" 212 - > 213 - <SliderTrack className="h-2 w-full rounded-md"> 214 - <ColorThumb className={`${thumbStyle} mt-[4px]`} /> 215 - </SliderTrack> 216 - </ColorSlider> 217 - )} 218 - </SpectrumColorPicker> 219 - </div> 220 - )} 221 - </> 222 - ); 223 - };
+134 -71
components/ThemeManager/PageThemePickers.tsx components/ThemeManager/Pickers/PageThemePickers.tsx
··· 17 17 import { useColorAttribute } from "components/ThemeManager/useColorAttribute"; 18 18 import { Separator } from "components/Layout"; 19 19 import { onMouseDown } from "src/utils/iosInputMouseDown"; 20 - import { pickers, setColorAttribute } from "./ThemeSetter"; 21 - import { ImageInput, ImageSettings } from "./ImageSetters"; 20 + import { pickers, setColorAttribute } from "../ThemeSetter"; 21 + import { ImageInput, ImageSettings } from "./ImagePicker"; 22 22 23 23 import { ColorPicker, thumbStyle } from "./ColorPicker"; 24 24 import { BlockImageSmall } from "components/Icons/BlockImageSmall"; ··· 27 27 28 28 export const PageThemePickers = (props: { 29 29 entityID: string; 30 - home?: boolean; 31 30 openPicker: pickers; 32 31 setOpenPicker: (thisPicker: pickers) => void; 33 32 }) => { ··· 37 36 }, [rep, props.entityID]); 38 37 39 38 let pageType = useEntity(props.entityID, "page/type")?.data.value || "doc"; 40 - let pageValue = useColorAttribute(props.entityID, "theme/card-background"); 41 39 let primaryValue = useColorAttribute(props.entityID, "theme/primary"); 40 + let pageBackgroundValue = useColorAttribute( 41 + props.entityID, 42 + "theme/card-background", 43 + ); 42 44 let pageBGImage = useEntity(props.entityID, "theme/card-background-image"); 43 45 let pageBorderHidden = useEntity(props.entityID, "theme/card-border-hidden"); 44 46 ··· 53 55 <hr className="border-border-light w-full" /> 54 56 </> 55 57 )} 56 - <ColorPicker 57 - disabled={pageBorderHidden?.data.value} 58 - label="Page" 59 - value={pageValue} 60 - setValue={set("theme/card-background")} 61 - thisPicker={"page"} 62 - openPicker={props.openPicker} 63 - setOpenPicker={props.setOpenPicker} 64 - closePicker={() => props.setOpenPicker("null")} 65 - alpha 66 - > 67 - {(pageBGImage === null || !pageBGImage) && ( 68 - <label 69 - className={`m-0 h-max w-full py-0.5 px-1 70 - bg-accent-1 outline-transparent 71 - rounded-md text-base font-bold text-accent-2 72 - hover:cursor-pointer 73 - flex gap-2 items-center justify-center shrink-0 74 - transparent-outline hover:outline-accent-1 outline-offset-1 75 - `} 76 - > 77 - <BlockImageSmall /> Add Background Image 78 - <div className="hidden"> 79 - <ImageInput 80 - entityID={props.entityID} 81 - onChange={() => props.setOpenPicker("page-background-image")} 82 - card 83 - /> 84 - </div> 85 - </label> 86 - )} 87 - </ColorPicker> 88 58 {pageBGImage && pageBGImage !== null && ( 89 - <PageBGPicker 90 - disabled={pageBorderHidden?.data.value} 59 + <PageBackgroundPicker 91 60 entityID={props.entityID} 92 - thisPicker={"page-background-image"} 61 + setValue={set("theme/card-background")} 93 62 openPicker={props.openPicker} 94 63 setOpenPicker={props.setOpenPicker} 95 - closePicker={() => props.setOpenPicker("null")} 96 - setValue={set("theme/card-background")} 97 64 /> 98 65 )} 99 - <ColorPicker 100 - label="Text" 66 + <PageTextPicker 101 67 value={primaryValue} 102 68 setValue={set("theme/primary")} 103 - thisPicker={"text"} 104 69 openPicker={props.openPicker} 105 70 setOpenPicker={props.setOpenPicker} 106 - closePicker={() => props.setOpenPicker("null")} 107 71 /> 108 - <hr /> 72 + <hr className="border-border-light" /> 109 73 <PageBorderHider entityID={props.entityID} /> 110 74 </div> 111 75 ); 112 76 }; 113 77 114 - export const PageBorderHider = (props: { entityID: string }) => { 115 - let { rep, rootEntity } = useReplicache(); 116 - let rootPageBorderHidden = useEntity(rootEntity, "theme/card-border-hidden"); 117 - let entityPageBorderHidden = useEntity( 118 - props.entityID, 119 - "theme/card-border-hidden", 120 - ); 121 - let pageBorderHidden = 122 - (entityPageBorderHidden || rootPageBorderHidden)?.data.value || false; 78 + export const PageBackgroundPicker = (props: { 79 + entityID: string; 80 + setValue: (c: Color) => void; 81 + openPicker: pickers; 82 + setOpenPicker: (p: pickers) => void; 83 + }) => { 84 + let pageValue = useColorAttribute(props.entityID, "theme/card-background"); 85 + let pageBGImage = useEntity(props.entityID, "theme/card-background-image"); 86 + let pageBorderHidden = useEntity(props.entityID, "theme/card-border-hidden"); 123 87 124 88 return ( 125 89 <> 126 - <Checkbox 127 - small 128 - className="pl-[6px] !gap-3" 129 - checked={pageBorderHidden} 130 - onChange={(e) => { 131 - rep?.mutate.assertFact({ 132 - entity: props.entityID, 133 - attribute: "theme/card-border-hidden", 134 - data: { type: "boolean", value: !pageBorderHidden }, 135 - }); 136 - }} 137 - > 138 - No Page Borders 139 - </Checkbox> 90 + {pageBGImage && pageBGImage !== null && ( 91 + <PageBackgroundImagePicker 92 + disabled={pageBorderHidden?.data.value} 93 + entityID={props.entityID} 94 + thisPicker={"page-background-image"} 95 + openPicker={props.openPicker} 96 + setOpenPicker={props.setOpenPicker} 97 + closePicker={() => props.setOpenPicker("null")} 98 + setValue={props.setValue} 99 + /> 100 + )} 101 + <div className="relative"> 102 + <PageBackgroundColorPicker 103 + disabled={pageBorderHidden?.data.value} 104 + label={pageBGImage && pageBGImage !== null ? "Menus" : "Page"} 105 + value={pageValue} 106 + setValue={props.setValue} 107 + thisPicker={"page"} 108 + openPicker={props.openPicker} 109 + setOpenPicker={props.setOpenPicker} 110 + /> 111 + {(pageBGImage === null || !pageBGImage) && ( 112 + <label 113 + className={` 114 + text-primary hover:cursor-pointer shrink-0 115 + absolute top-0 right-0 116 + `} 117 + > 118 + <BlockImageSmall /> 119 + <div className="hidden"> 120 + <ImageInput 121 + entityID={props.entityID} 122 + onChange={() => props.setOpenPicker("page-background-image")} 123 + card 124 + /> 125 + </div> 126 + </label> 127 + )} 128 + </div> 140 129 </> 141 130 ); 142 131 }; 143 132 144 - export const PageBGPicker = (props: { 133 + export const PageBackgroundColorPicker = (props: { 134 + disabled?: boolean; 135 + label: string; 136 + openPicker: pickers; 137 + thisPicker: pickers; 138 + setOpenPicker: (thisPicker: pickers) => void; 139 + setValue: (c: Color) => void; 140 + value: Color; 141 + alpha?: boolean; 142 + }) => { 143 + return ( 144 + <ColorPicker 145 + disabled={props.disabled} 146 + label={props.label} 147 + value={props.value} 148 + setValue={props.setValue} 149 + thisPicker={"page"} 150 + openPicker={props.openPicker} 151 + setOpenPicker={props.setOpenPicker} 152 + closePicker={() => props.setOpenPicker("null")} 153 + alpha={props.alpha} 154 + /> 155 + ); 156 + }; 157 + 158 + export const PageBackgroundImagePicker = (props: { 145 159 disabled?: boolean; 146 160 entityID: string; 147 161 openPicker: pickers; ··· 248 262 colorSpace="hsb" 249 263 className="w-full mt-1 rounded-full" 250 264 style={{ 251 - backgroundImage: `url(./transparent-bg.png)`, 265 + backgroundImage: `url(/transparent-bg.png)`, 252 266 backgroundRepeat: "repeat", 253 267 backgroundSize: "8px", 254 268 }} ··· 315 329 </div> 316 330 ); 317 331 }; 332 + 333 + export const PageTextPicker = (props: { 334 + openPicker: pickers; 335 + setOpenPicker: (thisPicker: pickers) => void; 336 + value: Color; 337 + setValue: (c: Color) => void; 338 + }) => { 339 + return ( 340 + <ColorPicker 341 + label="Text" 342 + value={props.value} 343 + setValue={props.setValue} 344 + thisPicker={"text"} 345 + openPicker={props.openPicker} 346 + setOpenPicker={props.setOpenPicker} 347 + closePicker={() => props.setOpenPicker("null")} 348 + /> 349 + ); 350 + }; 351 + 352 + export const PageBorderHider = (props: { entityID: string }) => { 353 + let { rep, rootEntity } = useReplicache(); 354 + let rootPageBorderHidden = useEntity(rootEntity, "theme/card-border-hidden"); 355 + let entityPageBorderHidden = useEntity( 356 + props.entityID, 357 + "theme/card-border-hidden", 358 + ); 359 + let pageBorderHidden = 360 + (entityPageBorderHidden || rootPageBorderHidden)?.data.value || false; 361 + 362 + return ( 363 + <> 364 + <Checkbox 365 + small 366 + className="pl-[6px] !gap-3" 367 + checked={pageBorderHidden} 368 + onChange={(e) => { 369 + rep?.mutate.assertFact({ 370 + entity: props.entityID, 371 + attribute: "theme/card-border-hidden", 372 + data: { type: "boolean", value: !pageBorderHidden }, 373 + }); 374 + }} 375 + > 376 + No Page Borders 377 + </Checkbox> 378 + </> 379 + ); 380 + };
+3 -3
components/ThemeManager/PageThemeSetter.tsx
··· 2 2 import { useEntitySetContext } from "components/EntitySetProvider"; 3 3 import { pickers, SectionArrow } from "./ThemeSetter"; 4 4 5 - import { PageThemePickers } from "./PageThemePickers"; 5 + import { PageThemePickers } from "./Pickers/PageThemePickers"; 6 6 import { useState } from "react"; 7 7 import { theme } from "tailwind.config"; 8 8 import { ButtonPrimary } from "components/Buttons"; 9 9 import { PaintSmall } from "components/Icons/PaintSmall"; 10 - import { AccentThemePickers } from "./AccentThemePickers"; 10 + import { AccentPickers } from "./Pickers/AccentPickers"; 11 11 12 12 export const PageThemeSetter = (props: { entityID: string }) => { 13 13 let { rootEntity } = useReplicache(); ··· 40 40 : `calc(${leafletBGRepeat.data.value}px / 2 )`, 41 41 }} 42 42 > 43 - <AccentThemePickers 43 + <AccentPickers 44 44 entityID={props.entityID} 45 45 openPicker={openPicker} 46 46 setOpenPicker={(pickers) => setOpenPicker(pickers)}
+150
components/ThemeManager/Pickers/LeafletBGPicker.tsx
··· 1 + "use client"; 2 + 3 + import { 4 + ColorPicker as SpectrumColorPicker, 5 + parseColor, 6 + Color, 7 + ColorArea, 8 + ColorThumb, 9 + ColorSlider, 10 + Input, 11 + ColorField, 12 + SliderTrack, 13 + ColorSwatch, 14 + } from "react-aria-components"; 15 + import { pickers, setColorAttribute } from "../ThemeSetter"; 16 + import { thumbStyle } from "./ColorPicker"; 17 + import { ImageInput, ImageSettings } from "./ImagePicker"; 18 + import { useEntity, useReplicache } from "src/replicache"; 19 + import { useColorAttribute } from "components/ThemeManager/useColorAttribute"; 20 + import { Separator } from "components/Layout"; 21 + import { onMouseDown } from "src/utils/iosInputMouseDown"; 22 + import { BlockImageSmall } from "components/Icons/BlockImageSmall"; 23 + 24 + export const LeafletBGPicker = (props: { 25 + entityID: string; 26 + openPicker: pickers; 27 + thisPicker: pickers; 28 + setOpenPicker: (thisPicker: pickers) => void; 29 + closePicker: () => void; 30 + setValue: (c: Color) => void; 31 + }) => { 32 + let bgImage = useEntity(props.entityID, "theme/background-image"); 33 + let bgColor = useColorAttribute(props.entityID, "theme/page-background"); 34 + let open = props.openPicker == props.thisPicker; 35 + let { rep } = useReplicache(); 36 + 37 + return ( 38 + <> 39 + <div className="bgPickerLabel flex justify-between place-items-center "> 40 + <div className="bgPickerColorLabel flex gap-2 items-center"> 41 + <button 42 + onClick={() => { 43 + if (props.openPicker === props.thisPicker) { 44 + props.setOpenPicker("null"); 45 + } else { 46 + props.setOpenPicker(props.thisPicker); 47 + } 48 + }} 49 + className="flex gap-2 items-center" 50 + > 51 + <ColorSwatch 52 + color={bgColor} 53 + className={`w-6 h-6 rounded-full border-2 border-white shadow-[0_0_0_1px_#8C8C8C]`} 54 + style={{ 55 + backgroundImage: bgImage?.data.src 56 + ? `url(${bgImage.data.src})` 57 + : undefined, 58 + backgroundSize: "cover", 59 + }} 60 + /> 61 + <strong className={` "text-[#595959]`}>{"Background"}</strong> 62 + </button> 63 + 64 + <div className="flex"> 65 + {bgImage ? ( 66 + <div className={`"text-[#969696]`}>Image</div> 67 + ) : ( 68 + <> 69 + <ColorField className="w-fit gap-1" value={bgColor}> 70 + <Input 71 + onMouseDown={onMouseDown} 72 + onFocus={(e) => { 73 + e.currentTarget.setSelectionRange( 74 + 1, 75 + e.currentTarget.value.length, 76 + ); 77 + }} 78 + onPaste={(e) => { 79 + console.log(e); 80 + }} 81 + onKeyDown={(e) => { 82 + if (e.key === "Enter") { 83 + e.currentTarget.blur(); 84 + } else return; 85 + }} 86 + onBlur={(e) => { 87 + props.setValue(parseColor(e.currentTarget.value)); 88 + }} 89 + className={`w-[72px] bg-transparent outline-nonetext-[#595959]`} 90 + /> 91 + </ColorField> 92 + </> 93 + )} 94 + </div> 95 + </div> 96 + <label className="hover:cursor-pointer h-fit"> 97 + <div className={"text-[#8C8C8C] hover:text-[#0000FF]"}> 98 + <BlockImageSmall /> 99 + </div> 100 + <div className="hidden"> 101 + <ImageInput 102 + {...props} 103 + onChange={() => { 104 + props.setOpenPicker(props.thisPicker); 105 + }} 106 + /> 107 + </div> 108 + </label> 109 + </div> 110 + {open && ( 111 + <div className="bgImageAndColorPicker w-full flex flex-col gap-2 "> 112 + <SpectrumColorPicker 113 + value={bgColor} 114 + onChange={setColorAttribute( 115 + rep, 116 + props.entityID, 117 + )("theme/page-background")} 118 + > 119 + {bgImage ? ( 120 + <ImageSettings 121 + entityID={props.entityID} 122 + setValue={props.setValue} 123 + /> 124 + ) : ( 125 + <> 126 + <ColorArea 127 + className="w-full h-[128px] rounded-md" 128 + colorSpace="hsb" 129 + xChannel="saturation" 130 + yChannel="brightness" 131 + > 132 + <ColorThumb className={thumbStyle} /> 133 + </ColorArea> 134 + <ColorSlider 135 + colorSpace="hsb" 136 + className="w-full " 137 + channel="hue" 138 + > 139 + <SliderTrack className="h-2 w-full rounded-md"> 140 + <ColorThumb className={`${thumbStyle} mt-[4px]`} /> 141 + </SliderTrack> 142 + </ColorSlider> 143 + </> 144 + )} 145 + </SpectrumColorPicker> 146 + </div> 147 + )} 148 + </> 149 + ); 150 + };
+42
components/ThemeManager/PubPickers/PubAcccentPickers.tsx
··· 1 + import { pickers } from "../ThemeSetter"; 2 + import { Color } from "react-aria-components"; 3 + import { ColorPicker } from "../Pickers/ColorPicker"; 4 + 5 + export const PubAccentPickers = (props: { 6 + accent1: Color; 7 + accent2: Color; 8 + setAccent1: (color: Color) => void; 9 + setAccent2: (color: Color) => void; 10 + openPicker: pickers; 11 + setOpenPicker: (thisPicker: pickers) => void; 12 + }) => { 13 + return ( 14 + <> 15 + <div 16 + className="themeLeafletControls text-accent-2 flex flex-col gap-2 h-full bg-bg-leaflet p-2 rounded-md border border-accent-2 shadow-[0_0_0_1px_rgb(var(--accent-1))]" 17 + style={{ 18 + backgroundColor: "rgba(var(--accent-1), 0.5)", 19 + }} 20 + > 21 + <ColorPicker 22 + label="Accent" 23 + value={props.accent1} 24 + setValue={props.setAccent1} 25 + thisPicker={"accent-1"} 26 + openPicker={props.openPicker} 27 + setOpenPicker={props.setOpenPicker} 28 + closePicker={() => props.setOpenPicker("null")} 29 + /> 30 + <ColorPicker 31 + label="Text on Accent" 32 + value={props.accent2} 33 + setValue={props.setAccent2} 34 + thisPicker={"accent-2"} 35 + openPicker={props.openPicker} 36 + setOpenPicker={props.setOpenPicker} 37 + closePicker={() => props.setOpenPicker("null")} 38 + /> 39 + </div> 40 + </> 41 + ); 42 + };
+363
components/ThemeManager/PubPickers/PubBackgroundPickers.tsx
··· 1 + import { pickers } from "../ThemeSetter"; 2 + import { theme } from "tailwind.config"; 3 + import { PageBackgroundColorPicker } from "../Pickers/PageThemePickers"; 4 + import { Color, ColorSwatch } from "react-aria-components"; 5 + import { BlockImageSmall } from "components/Icons/BlockImageSmall"; 6 + import { ColorPicker } from "../Pickers/ColorPicker"; 7 + import { CloseContrastSmall } from "components/Icons/CloseContrastSmall"; 8 + import * as Slider from "@radix-ui/react-slider"; 9 + import { Toggle } from "components/Toggle"; 10 + import { DeleteSmall } from "components/Icons/DeleteSmall"; 11 + import { ImageState } from "../PubThemeSetter"; 12 + import { Radio } from "components/Checkbox"; 13 + import { Input } from "components/Input"; 14 + 15 + export const BackgroundPicker = (props: { 16 + backgroundColor: Color; 17 + setBackgroundColor: (c: Color) => void; 18 + pageBackground: Color; 19 + setPageBackground: (c: Color) => void; 20 + openPicker: pickers; 21 + setOpenPicker: (p: pickers) => void; 22 + bgImage: ImageState | null; 23 + setBgImage: (i: ImageState | null) => void; 24 + hasPageBackground: boolean; 25 + setHasPageBackground: (s: boolean) => void; 26 + }) => { 27 + return ( 28 + <> 29 + {props.bgImage && props.bgImage !== null ? ( 30 + <BackgroundImagePicker 31 + bgColor={props.backgroundColor} 32 + bgImage={props.bgImage} 33 + setBgImage={props.setBgImage} 34 + thisPicker={"page-background-image"} 35 + openPicker={props.openPicker} 36 + setOpenPicker={props.setOpenPicker} 37 + closePicker={() => props.setOpenPicker("null")} 38 + setValue={props.setBackgroundColor} 39 + /> 40 + ) : ( 41 + <div className="relative"> 42 + <ColorPicker 43 + label={"Background"} 44 + value={props.backgroundColor} 45 + setValue={props.setBackgroundColor} 46 + thisPicker={"leaflet"} 47 + openPicker={props.openPicker} 48 + setOpenPicker={props.setOpenPicker} 49 + closePicker={() => props.setOpenPicker("null")} 50 + alpha={!!props.bgImage} 51 + /> 52 + {!props.bgImage && ( 53 + <label 54 + className={` 55 + text-[#969696] hover:cursor-pointer shrink-0 56 + absolute top-0 right-0 57 + `} 58 + > 59 + <BlockImageSmall /> 60 + <div className="hidden"> 61 + <input 62 + type="file" 63 + accept="image/*" 64 + hidden 65 + onChange={async (e) => { 66 + let file = e.currentTarget.files?.[0]; 67 + if (file) { 68 + const reader = new FileReader(); 69 + reader.onload = (e) => { 70 + console.log("loaded!", props.bgImage); 71 + props.setBgImage({ 72 + src: e.target?.result as string, 73 + file, 74 + repeat: null, 75 + }); 76 + props.setOpenPicker("page-background-image"); 77 + }; 78 + reader.readAsDataURL(file); 79 + } 80 + }} 81 + /> 82 + </div> 83 + </label> 84 + )} 85 + </div> 86 + )} 87 + <PageBackgroundColorPicker 88 + label={"Containers"} 89 + value={props.pageBackground} 90 + setValue={props.setPageBackground} 91 + thisPicker={"page"} 92 + openPicker={props.openPicker} 93 + setOpenPicker={props.setOpenPicker} 94 + alpha={props.hasPageBackground ? true : false} 95 + /> 96 + <hr className="border-border-light" /> 97 + <div className="flex gap-2 items-center"> 98 + <Toggle 99 + toggleOn={props.hasPageBackground} 100 + setToggleOn={() => { 101 + props.setHasPageBackground(!props.hasPageBackground); 102 + props.hasPageBackground && props.setOpenPicker("null"); 103 + }} 104 + disabledColor1="#8C8C8C" 105 + disabledColor2="#DBDBDB" 106 + /> 107 + <button 108 + className="flex gap-2 items-center" 109 + onClick={() => { 110 + props.setHasPageBackground(!props.hasPageBackground); 111 + props.hasPageBackground && props.setOpenPicker("null"); 112 + }} 113 + > 114 + <div className="font-bold">Page Background</div> 115 + <div className="italic text-[#8C8C8C]"> 116 + {props.hasPageBackground ? "" : "hidden"} 117 + </div> 118 + </button> 119 + </div> 120 + </> 121 + ); 122 + }; 123 + 124 + const BackgroundImagePicker = (props: { 125 + disabled?: boolean; 126 + bgImage: ImageState | null; 127 + setBgImage: (i: ImageState | null) => void; 128 + bgColor: Color; 129 + openPicker: pickers; 130 + thisPicker: pickers; 131 + setOpenPicker: (thisPicker: pickers) => void; 132 + closePicker: () => void; 133 + setValue: (c: Color) => void; 134 + }) => { 135 + let open = props.openPicker == props.thisPicker; 136 + 137 + return ( 138 + <> 139 + <div className="bgPickerColorLabel flex gap-2 items-center"> 140 + <button 141 + disabled={props.disabled} 142 + onClick={() => { 143 + if (props.openPicker === props.thisPicker) { 144 + props.setOpenPicker("null"); 145 + } else { 146 + props.setOpenPicker(props.thisPicker); 147 + } 148 + }} 149 + className="flex gap-2 items-center disabled:text-tertiary grow" 150 + > 151 + <ColorSwatch 152 + color={props.bgColor} 153 + className={`w-6 h-6 rounded-full border-2 border-white shadow-[0_0_0_1px_#8C8C8C] ${props.disabled ? "opacity-50" : ""}`} 154 + style={{ 155 + backgroundImage: props.bgImage 156 + ? `url(${props.bgImage.src})` 157 + : undefined, 158 + backgroundPosition: "center", 159 + backgroundSize: "cover", 160 + }} 161 + /> 162 + <strong className={` text-[#595959]`}>Background</strong> 163 + <div className="italic text-[#8C8C8C]">image</div> 164 + </button> 165 + <div className="flex gap-1"> 166 + <label className="hover:cursor-pointer "> 167 + <div className="flex gap-2 rounded-md text-[#8C8C8C] "> 168 + <BlockImageSmall /> 169 + </div> 170 + <div className="hidden"> 171 + <input 172 + type="file" 173 + accept="image/*" 174 + hidden 175 + onChange={async (e) => { 176 + let file = e.currentTarget.files?.[0]; 177 + if (file) { 178 + const reader = new FileReader(); 179 + reader.onload = (e) => { 180 + if (!props.bgImage) return; 181 + props.setBgImage({ 182 + ...props.bgImage, 183 + src: e.target?.result as string, 184 + file, 185 + }); 186 + }; 187 + reader.readAsDataURL(file); 188 + } 189 + }} 190 + /> 191 + </div> 192 + </label> 193 + <button 194 + className="text-[#8C8C8C]" 195 + onClick={() => props.setBgImage(null)} 196 + > 197 + <DeleteSmall /> 198 + </button> 199 + </div> 200 + </div> 201 + {open && ( 202 + <div className="pageImagePicker flex flex-col gap-2"> 203 + <ImageSettings 204 + bgImage={props.bgImage} 205 + setBgImage={props.setBgImage} 206 + /> 207 + </div> 208 + )} 209 + </> 210 + ); 211 + }; 212 + 213 + export const ImageSettings = (props: { 214 + bgImage: ImageState | null; 215 + setBgImage: (i: ImageState | null) => void; 216 + }) => { 217 + return ( 218 + <> 219 + {/* <div 220 + style={{ 221 + backgroundImage: props.bgImage 222 + ? `url(${props.bgImage.src})` 223 + : undefined, 224 + backgroundPosition: "center", 225 + backgroundSize: "cover", 226 + }} 227 + className="themeBGImagePreview flex gap-2 place-items-center justify-center w-full h-[128px] bg-cover bg-center bg-no-repeat" 228 + > 229 + <label className="hover:cursor-pointer "> 230 + <div 231 + className="flex gap-2 rounded-md px-2 py-1 text-accent-contrast font-bold" 232 + style={{ backgroundColor: "rgba(var(--bg-page), .8" }} 233 + > 234 + <BlockImageSmall /> Change Image 235 + </div> 236 + <div className="hidden"> 237 + <input 238 + type="file" 239 + accept="image/*" 240 + hidden 241 + onChange={async (e) => { 242 + let file = e.currentTarget.files?.[0]; 243 + if (file) { 244 + const reader = new FileReader(); 245 + reader.onload = (e) => { 246 + if (!props.bgImage) return; 247 + props.setBgImage({ 248 + ...props.bgImage, 249 + src: e.target?.result as string, 250 + file, 251 + }); 252 + }; 253 + reader.readAsDataURL(file); 254 + } 255 + }} 256 + /> 257 + </div> 258 + </label> 259 + <button 260 + onClick={() => { 261 + props.setBgImage(null); 262 + }} 263 + > 264 + <CloseContrastSmall 265 + fill={theme.colors["accent-1"]} 266 + stroke={theme.colors["accent-2"]} 267 + /> 268 + </button> 269 + </div> */} 270 + <div className="themeBGImageControls font-bold flex flex-col gap-1 items-center px-3"> 271 + <label htmlFor="cover" className="w-full"> 272 + <Radio 273 + radioCheckedClassName="!text-[#595959]" 274 + radioEmptyClassName="!text-[#969696]" 275 + type="radio" 276 + id="cover" 277 + name="bg-image-options" 278 + value="cover" 279 + checked={!props.bgImage?.repeat} 280 + onChange={async (e) => { 281 + if (!e.currentTarget.checked) return; 282 + if (!props.bgImage) return; 283 + props.setBgImage({ ...props.bgImage, repeat: null }); 284 + }} 285 + > 286 + <div 287 + className={`w-full cursor-pointer ${!props.bgImage?.repeat ? "text-[#595959]" : " text-[#969696]"}`} 288 + > 289 + cover 290 + </div> 291 + </Radio> 292 + </label> 293 + <label htmlFor="repeat" className="pb-3 w-full"> 294 + <Radio 295 + type="radio" 296 + id="repeat" 297 + name="bg-image-options" 298 + value="repeat" 299 + radioCheckedClassName="!text-[#595959]" 300 + radioEmptyClassName="!text-[#969696]" 301 + checked={!!props.bgImage?.repeat} 302 + onChange={async (e) => { 303 + if (!e.currentTarget.checked) return; 304 + if (!props.bgImage) return; 305 + props.setBgImage({ ...props.bgImage, repeat: 500 }); 306 + }} 307 + > 308 + <div className="flex flex-col gap-2 w-full"> 309 + <div className="flex gap-2"> 310 + <div 311 + className={`shink-0 grow-0 w-fit z-10 cursor-pointer ${props.bgImage?.repeat ? "text-[#595959]" : " text-[#969696]"}`} 312 + > 313 + repeat 314 + </div> 315 + <div 316 + className={`flex font-normal ${props.bgImage?.repeat ? "text-[#969696]" : " text-[#C3C3C3]"}`} 317 + > 318 + <Input 319 + type="number" 320 + className="w-10 text-right appearance-none" 321 + max={3000} 322 + min={10} 323 + value={props.bgImage?.repeat || 500} 324 + onChange={(e) => { 325 + if (!props.bgImage) return; 326 + props.setBgImage({ 327 + ...props.bgImage, 328 + repeat: parseInt(e.currentTarget.value), 329 + }); 330 + }} 331 + />{" "} 332 + px 333 + </div> 334 + </div> 335 + <Slider.Root 336 + className={`relative grow flex items-center select-none touch-none w-full h-fit px-1 `} 337 + value={[props.bgImage?.repeat || 500]} 338 + max={3000} 339 + min={10} 340 + step={10} 341 + onValueChange={(value) => { 342 + if (!props.bgImage) return; 343 + props.setBgImage({ ...props.bgImage, repeat: value[0] }); 344 + }} 345 + > 346 + <Slider.Track 347 + className={`${props.bgImage?.repeat ? "bg-[#595959]" : " bg-[#C3C3C3]"} relative grow rounded-full h-[3px]`} 348 + ></Slider.Track> 349 + <Slider.Thumb 350 + className={` 351 + flex w-4 h-4 rounded-full border-2 border-white cursor-pointer 352 + ${props.bgImage?.repeat ? "bg-[#595959]" : " bg-[#C3C3C3] "} 353 + ${props.bgImage?.repeat && "shadow-[0_0_0_1px_#8C8C8C,_inset_0_0_0_1px_#8C8C8C]"} `} 354 + aria-label="Volume" 355 + /> 356 + </Slider.Root> 357 + </div> 358 + </Radio> 359 + </label> 360 + </div> 361 + </> 362 + ); 363 + };
+45
components/ThemeManager/PubPickers/PubTextPickers.tsx
··· 1 + import { pickers } from "../ThemeSetter"; 2 + import { PageTextPicker } from "../Pickers/PageThemePickers"; 3 + import { Color } from "react-aria-components"; 4 + 5 + export const PagePickers = (props: { 6 + primary: Color; 7 + pageBackground: Color; 8 + setPrimary: (color: Color) => void; 9 + setPageBackground: (color: Color) => void; 10 + openPicker: pickers; 11 + setOpenPicker: (thisPicker: pickers) => void; 12 + hasPageBackground: boolean; 13 + }) => { 14 + return ( 15 + <div 16 + className="themeLeafletControls text-primary flex flex-col gap-2 h-full bg-bg-page p-2 rounded-md border border-primary shadow-[0_0_0_1px_rgb(var(--bg-page))]" 17 + style={{ 18 + backgroundColor: props.hasPageBackground 19 + ? "rgba(var(--bg-page), var(--bg-page-alpha))" 20 + : "transparent", 21 + }} 22 + > 23 + <PageTextPicker 24 + value={props.primary} 25 + setValue={props.setPrimary} 26 + openPicker={props.openPicker} 27 + setOpenPicker={props.setOpenPicker} 28 + /> 29 + {/* FONT PICKERS HIDDEN FOR NOW */} 30 + {/* <hr className="border-border-light" /> 31 + <div className="flex gap-2"> 32 + <div className="w-6 h-6 font-bold text-center rounded-md bg-border-light"> 33 + Aa 34 + </div> 35 + <div className="font-bold">Header</div> <div>iA Writer</div> 36 + </div> 37 + <div className="flex gap-2"> 38 + <div className="w-6 h-6 place-items-center text-center rounded-md bg-border-light"> 39 + Aa 40 + </div>{" "} 41 + <div className="font-bold">Body</div> <div>iA Writer</div> 42 + </div> */} 43 + </div> 44 + ); 45 + };
+387
components/ThemeManager/PubThemeSetter.tsx
··· 1 + import { usePublicationData } from "app/lish/[did]/[publication]/dashboard/PublicationSWRProvider"; 2 + import { useState } from "react"; 3 + import { pickers, SectionArrow } from "./ThemeSetter"; 4 + import { Color } from "react-aria-components"; 5 + import { 6 + PubLeafletPublication, 7 + PubLeafletThemeBackgroundImage, 8 + } from "lexicons/api"; 9 + import { AtUri } from "@atproto/syntax"; 10 + import { useLocalPubTheme } from "./PublicationThemeProvider"; 11 + import { BaseThemeProvider } from "./ThemeProvider"; 12 + import { blobRefToSrc } from "src/utils/blobRefToSrc"; 13 + import { ButtonSecondary } from "components/Buttons"; 14 + import { updatePublicationTheme } from "app/lish/createPub/updatePublication"; 15 + import { DotLoader } from "components/utils/DotLoader"; 16 + import { PagePickers } from "./PubPickers/PubTextPickers"; 17 + import { BackgroundPicker } from "./PubPickers/PubBackgroundPickers"; 18 + import { PubAccentPickers } from "./PubPickers/PubAcccentPickers"; 19 + import { Separator } from "components/Layout"; 20 + 21 + export type ImageState = { 22 + src: string; 23 + file?: File; 24 + repeat: number | null; 25 + }; 26 + export const PubThemeSetter = () => { 27 + let [loading, setLoading] = useState(false); 28 + let [sample, setSample] = useState<"pub" | "post">("pub"); 29 + let [openPicker, setOpenPicker] = useState<pickers>("null"); 30 + let { data: pub, mutate } = usePublicationData(); 31 + let record = pub?.record as PubLeafletPublication.Record | undefined; 32 + let [showPageBackground, setShowPageBackground] = useState( 33 + !!record?.theme?.showPageBackground, 34 + ); 35 + let { theme: localPubTheme, setTheme, changes } = useLocalPubTheme(record); 36 + let [image, setImage] = useState<ImageState | null>( 37 + PubLeafletThemeBackgroundImage.isMain(record?.theme?.backgroundImage) 38 + ? { 39 + src: blobRefToSrc( 40 + record.theme.backgroundImage.image.ref, 41 + pub?.identity_did!, 42 + ), 43 + repeat: record.theme.backgroundImage.repeat 44 + ? record.theme.backgroundImage.width || 500 45 + : null, 46 + } 47 + : null, 48 + ); 49 + 50 + let pubBGImage = image?.src || null; 51 + let leafletBGRepeat = image?.repeat || null; 52 + 53 + return ( 54 + <BaseThemeProvider local {...localPubTheme}> 55 + <form 56 + className="bg-accent-1 -mx-3 -mt-2 px-3 py-1 mb-1 flex justify-between items-center" 57 + onSubmit={async (e) => { 58 + e.preventDefault(); 59 + if (!pub) return; 60 + console.log(image); 61 + setLoading(true); 62 + let result = await updatePublicationTheme({ 63 + uri: pub.uri, 64 + theme: { 65 + pageBackground: ColorToRGBA(localPubTheme.bgPage), 66 + showPageBackground: showPageBackground, 67 + backgroundColor: image 68 + ? ColorToRGBA(localPubTheme.bgLeaflet) 69 + : ColorToRGB(localPubTheme.bgLeaflet), 70 + backgroundRepeat: image?.repeat, 71 + backgroundImage: image ? image.file : null, 72 + primary: ColorToRGB(localPubTheme.primary), 73 + accentBackground: ColorToRGB(localPubTheme.accent1), 74 + accentText: ColorToRGB(localPubTheme.accent2), 75 + }, 76 + }); 77 + mutate((pub) => { 78 + if (result?.publication && pub) 79 + return { ...pub, record: result.publication.record }; 80 + return pub; 81 + }, false); 82 + setLoading(false); 83 + }} 84 + > 85 + <h4 className="text-accent-2">Publication Theme</h4> 86 + <ButtonSecondary 87 + compact 88 + disabled={ 89 + !( 90 + showPageBackground !== !!record?.theme?.showPageBackground || 91 + changes || 92 + !!image?.file || 93 + record?.theme?.backgroundImage?.width !== image?.repeat 94 + ) 95 + } 96 + > 97 + {loading ? <DotLoader /> : "Update"} 98 + </ButtonSecondary> 99 + </form> 100 + 101 + <div> 102 + <div className="themeSetterContent flex flex-col w-full overflow-y-scroll no-scrollbar"> 103 + <div className="themeBGLeaflet flex"> 104 + <div 105 + className={`bgPicker flex flex-col gap-0 -mb-[6px] z-10 w-full `} 106 + > 107 + <div className="bgPickerBody w-full flex flex-col gap-2 p-2 mt-1 border border-[#CCCCCC] rounded-md text-[#595959] bg-white"> 108 + <BackgroundPicker 109 + bgImage={image} 110 + setBgImage={setImage} 111 + backgroundColor={localPubTheme.bgLeaflet} 112 + pageBackground={localPubTheme.bgPage} 113 + setPageBackground={(color) => { 114 + setTheme((t) => ({ ...t, bgPage: color })); 115 + }} 116 + setBackgroundColor={(color) => { 117 + setTheme((t) => ({ ...t, bgLeaflet: color })); 118 + }} 119 + openPicker={openPicker} 120 + setOpenPicker={setOpenPicker} 121 + hasPageBackground={!!showPageBackground} 122 + setHasPageBackground={setShowPageBackground} 123 + /> 124 + </div> 125 + 126 + <SectionArrow 127 + fill="white" 128 + stroke="#CCCCCC" 129 + className="ml-2 -mt-[1px]" 130 + /> 131 + </div> 132 + </div> 133 + 134 + <div 135 + style={{ 136 + backgroundImage: pubBGImage ? `url(${pubBGImage})` : undefined, 137 + backgroundRepeat: leafletBGRepeat ? "repeat" : "no-repeat", 138 + backgroundPosition: "center", 139 + backgroundSize: !leafletBGRepeat 140 + ? "cover" 141 + : `calc(${leafletBGRepeat}px / 2 )`, 142 + }} 143 + className={` relative bg-bg-leaflet px-3 py-4 flex flex-col rounded-md border border-border `} 144 + > 145 + <div className={`flex flex-col gap-3 z-10`}> 146 + <PagePickers 147 + pageBackground={localPubTheme.bgPage} 148 + primary={localPubTheme.primary} 149 + setPageBackground={(color) => { 150 + setTheme((t) => ({ ...t, bgPage: color })); 151 + }} 152 + setPrimary={(color) => { 153 + setTheme((t) => ({ ...t, primary: color })); 154 + }} 155 + openPicker={openPicker} 156 + setOpenPicker={(pickers) => setOpenPicker(pickers)} 157 + hasPageBackground={showPageBackground} 158 + /> 159 + <PubAccentPickers 160 + accent1={localPubTheme.accent1} 161 + setAccent1={(color) => { 162 + setTheme((t) => ({ ...t, accent1: color })); 163 + }} 164 + accent2={localPubTheme.accent2} 165 + setAccent2={(color) => { 166 + setTheme((t) => ({ ...t, accent2: color })); 167 + }} 168 + openPicker={openPicker} 169 + setOpenPicker={(pickers) => setOpenPicker(pickers)} 170 + /> 171 + </div> 172 + </div> 173 + <div className="flex flex-col mt-4 "> 174 + <div className="flex gap-2 items-center text-sm text-[#8C8C8C]"> 175 + <div className="text-sm">Preview</div> 176 + <Separator classname="!h-4" />{" "} 177 + <button 178 + className={`${sample === "pub" ? "font-bold text-[#595959]" : ""}`} 179 + onClick={() => setSample("pub")} 180 + > 181 + Pub 182 + </button> 183 + <button 184 + className={`${sample === "post" ? "font-bold text-[#595959]" : ""}`} 185 + onClick={() => setSample("post")} 186 + > 187 + Post 188 + </button> 189 + </div> 190 + {sample === "pub" ? ( 191 + <SamplePub 192 + pubBGImage={pubBGImage} 193 + pubBGRepeat={leafletBGRepeat} 194 + showPageBackground={showPageBackground} 195 + /> 196 + ) : ( 197 + <SamplePost 198 + pubBGImage={pubBGImage} 199 + pubBGRepeat={leafletBGRepeat} 200 + showPageBackground={showPageBackground} 201 + /> 202 + )} 203 + </div> 204 + </div> 205 + </div> 206 + </BaseThemeProvider> 207 + ); 208 + }; 209 + 210 + const SamplePub = (props: { 211 + pubBGImage: string | null; 212 + pubBGRepeat: number | null; 213 + showPageBackground: boolean; 214 + }) => { 215 + let { data: publication } = usePublicationData(); 216 + let record = publication?.record as PubLeafletPublication.Record | null; 217 + 218 + return ( 219 + <div 220 + style={{ 221 + backgroundImage: props.pubBGImage 222 + ? `url(${props.pubBGImage})` 223 + : undefined, 224 + backgroundRepeat: props.pubBGRepeat ? "repeat" : "no-repeat", 225 + backgroundPosition: "center", 226 + backgroundSize: !props.pubBGRepeat 227 + ? "cover" 228 + : `calc(${props.pubBGRepeat}px / 2 )`, 229 + }} 230 + 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 `} 231 + > 232 + <div 233 + className="sampleContent rounded-t-md border-border pb-4 px-[10px] flex flex-col gap-[14px] w-[250px] mx-auto" 234 + style={{ 235 + background: props.showPageBackground 236 + ? "rgba(var(--bg-page), var(--bg-page-alpha))" 237 + : undefined, 238 + }} 239 + > 240 + <div className="flex flex-col justify-center text-center pt-2"> 241 + {record?.icon && publication?.uri && ( 242 + <div 243 + style={{ 244 + backgroundRepeat: "no-repeat", 245 + backgroundPosition: "center", 246 + backgroundSize: "cover", 247 + backgroundImage: `url(/api/atproto_images?did=${new AtUri(publication.uri).host}&cid=${(record.icon?.ref as unknown as { $link: string })["$link"]})`, 248 + }} 249 + className="w-4 h-4 rounded-full place-self-center" 250 + /> 251 + )} 252 + 253 + <div className="text-[11px] font-bold pt-[5px] text-accent-contrast"> 254 + {record?.name} 255 + </div> 256 + <div className="text-[7px] font-normal text-tertiary"> 257 + {record?.description} 258 + </div> 259 + <div className=" flex gap-1 items-center mt-[6px] bg-accent-1 text-accent-2 py-[1px] px-[4px] text-[7px] w-fit font-bold rounded-[2px] mx-auto"> 260 + <div className="h-[7px] w-[7px] rounded-full bg-accent-2" /> 261 + Subscribe with Bluesky 262 + </div> 263 + </div> 264 + 265 + <div className="flex flex-col text-[8px] rounded-md "> 266 + <div className="font-bold">A Sample Post</div> 267 + <div className="text-secondary italic text-[6px]"> 268 + This is a sample description about the sample post 269 + </div> 270 + <div className="text-tertiary text-[5px] pt-[2px]">Jan 1, 20XX </div> 271 + </div> 272 + </div> 273 + </div> 274 + ); 275 + }; 276 + 277 + const SamplePost = (props: { 278 + pubBGImage: string | null; 279 + pubBGRepeat: number | null; 280 + showPageBackground: boolean; 281 + }) => { 282 + let { data: publication } = usePublicationData(); 283 + let record = publication?.record as PubLeafletPublication.Record | null; 284 + return ( 285 + <div 286 + style={{ 287 + backgroundImage: props.pubBGImage 288 + ? `url(${props.pubBGImage})` 289 + : undefined, 290 + backgroundRepeat: props.pubBGRepeat ? "repeat" : "no-repeat", 291 + backgroundPosition: "center", 292 + backgroundSize: !props.pubBGRepeat 293 + ? "cover" 294 + : `calc(${props.pubBGRepeat}px / 2 )`, 295 + }} 296 + 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`} 297 + > 298 + <div 299 + className="sampleContent rounded-t-md border-border pb-0 px-[6px] flex flex-col w-[250px] mx-auto" 300 + style={{ 301 + background: props.showPageBackground 302 + ? "rgba(var(--bg-page), var(--bg-page-alpha))" 303 + : undefined, 304 + }} 305 + > 306 + <div className="flex flex-col "> 307 + <div className="text-[6px] font-bold pt-[6px] text-accent-contrast"> 308 + {record?.name} 309 + </div> 310 + <div className="text-[11px] font-bold text-primary"> 311 + A Sample Post 312 + </div> 313 + <div className="text-[7px] font-normal text-secondary italic"> 314 + A short sample description about the sample post 315 + </div> 316 + <div className="text-tertiary text-[5px] pt-[2px]">Jan 1, 20XX </div> 317 + </div> 318 + <div className="text-[6px] pt-[8px] flex flex-col gap-[6px]"> 319 + <div> 320 + Lorem ipsum dolor sit amet consectetur adipiscing elit. Quisque 321 + faucibus ex sapien vitae pellentesque sem placerat. In id cursus mi 322 + pretium tellus duis convallis. Tempus leo eu aenean sed diam urna 323 + tempor. 324 + </div> 325 + 326 + <div> 327 + Pulvinar vivamus fringilla lacus nec metus bibendum egestas. Iaculis 328 + massa nisl malesuada lacinia integer nunc posuere. Ut hendrerit 329 + semper vel class aptent taciti sociosqu. Ad litora torquent per 330 + conubia nostra inceptos himenaeos. 331 + </div> 332 + <div> 333 + Sed et nisi semper, egestas purus a, egestas nulla. Nulla ultricies, 334 + purus non dapibus tincidunt, nunc sem rhoncus sem, vel malesuada 335 + tellus enim sit amet magna. Donec ac justo a ipsum fermentum 336 + vulputate. Etiam sit amet viverra leo. Aenean accumsan consectetur 337 + velit. Vivamus at justo a nisl imperdiet dictum. Donec scelerisque 338 + ex eget turpis scelerisque tincidunt. Proin non convallis nibh, eget 339 + aliquet ex. Curabitur ornare a ipsum in ultrices. 340 + </div> 341 + </div> 342 + </div> 343 + </div> 344 + ); 345 + }; 346 + 347 + export function ColorToRGBA(color: Color) { 348 + if (!color) 349 + return { 350 + $type: "pub.leaflet.theme.color#rgba" as const, 351 + r: 0, 352 + g: 0, 353 + b: 0, 354 + a: 1, 355 + }; 356 + let c = color.toFormat("rgba"); 357 + const r = c.getChannelValue("red"); 358 + const g = c.getChannelValue("green"); 359 + const b = c.getChannelValue("blue"); 360 + const a = c.getChannelValue("alpha"); 361 + return { 362 + $type: "pub.leaflet.theme.color#rgba" as const, 363 + r: Math.round(r), 364 + g: Math.round(g), 365 + b: Math.round(b), 366 + a: Math.round(a * 100), 367 + }; 368 + } 369 + function ColorToRGB(color: Color) { 370 + if (!color) 371 + return { 372 + $type: "pub.leaflet.theme.color#rgb" as const, 373 + r: 0, 374 + g: 0, 375 + b: 0, 376 + }; 377 + let c = color.toFormat("rgb"); 378 + const r = c.getChannelValue("red"); 379 + const g = c.getChannelValue("green"); 380 + const b = c.getChannelValue("blue"); 381 + return { 382 + $type: "pub.leaflet.theme.color#rgb" as const, 383 + r: Math.round(r), 384 + g: Math.round(g), 385 + b: Math.round(b), 386 + }; 387 + }
+179
components/ThemeManager/PublicationThemeProvider.tsx
··· 1 + "use client"; 2 + import { useMemo, useState } from "react"; 3 + import { parseColor } from "react-aria-components"; 4 + import { useEntity } from "src/replicache"; 5 + import { getColorContrast } from "./ThemeProvider"; 6 + import { useColorAttribute, colorToString } from "./useColorAttribute"; 7 + import { BaseThemeProvider } from "./ThemeProvider"; 8 + import { PubLeafletPublication, PubLeafletThemeColor } from "lexicons/api"; 9 + import { usePublicationData } from "app/lish/[did]/[publication]/dashboard/PublicationSWRProvider"; 10 + import { blobRefToSrc } from "src/utils/blobRefToSrc"; 11 + 12 + const PubThemeDefaults = { 13 + backgroundColor: "#FDFCFA", 14 + pageBackground: "#FDFCFA", 15 + primary: "#272727", 16 + accentText: "#FFFFFF", 17 + accentBackground: "#0000FF", 18 + }; 19 + function parseThemeColor( 20 + c: PubLeafletThemeColor.Rgb | PubLeafletThemeColor.Rgba, 21 + ) { 22 + if (c.$type === "pub.leaflet.theme.color#rgba") { 23 + return parseColor(`rgba(${c.r}, ${c.g}, ${c.b}, ${c.a / 100})`); 24 + } 25 + return parseColor(`rgb(${c.r}, ${c.g}, ${c.b})`); 26 + } 27 + 28 + let useColor = ( 29 + record: PubLeafletPublication.Record | null | undefined, 30 + c: keyof typeof PubThemeDefaults, 31 + ) => { 32 + return useMemo(() => { 33 + let v = record?.theme?.[c]; 34 + if (isColor(v)) { 35 + return parseThemeColor(v); 36 + } else return parseColor(PubThemeDefaults[c]); 37 + }, [record?.theme?.[c]]); 38 + }; 39 + let isColor = ( 40 + c: any, 41 + ): c is PubLeafletThemeColor.Rgb | PubLeafletThemeColor.Rgba => { 42 + return ( 43 + c?.$type === "pub.leaflet.theme.color#rgb" || 44 + c?.$type === "pub.leaflet.theme.color#rgba" 45 + ); 46 + }; 47 + 48 + export function PublicationThemeProviderDashboard(props: { 49 + children: React.ReactNode; 50 + record?: PubLeafletPublication.Record | null; 51 + }) { 52 + let { data: pub } = usePublicationData(); 53 + return ( 54 + <PublicationThemeProvider 55 + pub_creator={pub?.identity_did || ""} 56 + record={pub?.record as PubLeafletPublication.Record} 57 + > 58 + <PublicationBackgroundProvider 59 + record={pub?.record as PubLeafletPublication.Record} 60 + pub_creator={pub?.identity_did || ""} 61 + > 62 + {props.children} 63 + </PublicationBackgroundProvider> 64 + </PublicationThemeProvider> 65 + ); 66 + } 67 + 68 + export function PublicationBackgroundProvider(props: { 69 + record?: PubLeafletPublication.Record | null; 70 + pub_creator: string; 71 + className?: string; 72 + children: React.ReactNode; 73 + }) { 74 + let backgroundImage = props.record?.theme?.backgroundImage?.image?.ref 75 + ? blobRefToSrc( 76 + props.record?.theme?.backgroundImage?.image?.ref, 77 + props.pub_creator, 78 + ) 79 + : null; 80 + 81 + let backgroundImageRepeat = props.record?.theme?.backgroundImage?.repeat; 82 + let backgroundImageSize = props.record?.theme?.backgroundImage?.width || 500; 83 + return ( 84 + <div 85 + className="PubBackgroundWrapper w-full bg-bg-leaflet text-primary h-full flex flex-col bg-cover bg-center bg-no-repeat items-stretch" 86 + style={{ 87 + backgroundImage: `url(${backgroundImage})`, 88 + backgroundRepeat: backgroundImageRepeat ? "repeat" : "no-repeat", 89 + backgroundSize: `${backgroundImageRepeat ? `${backgroundImageSize}px` : "cover"}`, 90 + }} 91 + > 92 + {props.children} 93 + </div> 94 + ); 95 + } 96 + export function PublicationThemeProvider(props: { 97 + local?: boolean; 98 + children: React.ReactNode; 99 + record?: PubLeafletPublication.Record | null; 100 + pub_creator: string; 101 + }) { 102 + let colors = usePubTheme(props.record); 103 + return ( 104 + <BaseThemeProvider local={props.local} {...colors}> 105 + {props.children} 106 + </BaseThemeProvider> 107 + ); 108 + } 109 + 110 + export const usePubTheme = (record?: PubLeafletPublication.Record | null) => { 111 + let bgLeaflet = useColor(record, "backgroundColor"); 112 + let bgPage = useColor(record, "pageBackground"); 113 + bgPage = record?.theme?.pageBackground ? bgPage : bgLeaflet; 114 + let primary = useColor(record, "primary"); 115 + 116 + let accent1 = useColor(record, "accentBackground"); 117 + let accent2 = useColor(record, "accentText"); 118 + 119 + let highlight1 = useEntity(null, "theme/highlight-1")?.data.value; 120 + let highlight2 = useColorAttribute(null, "theme/highlight-2"); 121 + let highlight3 = useColorAttribute(null, "theme/highlight-3"); 122 + 123 + // set accent contrast to the accent color that has the highest contrast with the page background 124 + let accentContrast = [accent1, accent2].sort((a, b) => { 125 + return ( 126 + getColorContrast( 127 + colorToString(b, "rgb"), 128 + colorToString(bgLeaflet, "rgb"), 129 + ) - 130 + getColorContrast(colorToString(a, "rgb"), colorToString(bgLeaflet, "rgb")) 131 + ); 132 + })[0]; 133 + return { 134 + bgLeaflet, 135 + bgPage, 136 + primary, 137 + accent1, 138 + accent2, 139 + highlight1, 140 + highlight2, 141 + highlight3, 142 + accentContrast, 143 + }; 144 + }; 145 + 146 + export const useLocalPubTheme = ( 147 + record: PubLeafletPublication.Record | undefined, 148 + ) => { 149 + const pubTheme = usePubTheme(record); 150 + const [localOverrides, setTheme] = useState<Partial<typeof pubTheme>>({}); 151 + 152 + const mergedTheme = useMemo(() => { 153 + let newTheme = { 154 + ...pubTheme, 155 + ...localOverrides, 156 + }; 157 + let accentContrast = [newTheme.accent1, newTheme.accent2].sort((a, b) => { 158 + return ( 159 + getColorContrast( 160 + colorToString(b, "rgb"), 161 + colorToString(newTheme.bgLeaflet, "rgb"), 162 + ) - 163 + getColorContrast( 164 + colorToString(a, "rgb"), 165 + colorToString(newTheme.bgLeaflet, "rgb"), 166 + ) 167 + ); 168 + })[0]; 169 + return { 170 + ...newTheme, 171 + accentContrast, 172 + }; 173 + }, [pubTheme, localOverrides]); 174 + return { 175 + theme: mergedTheme, 176 + setTheme, 177 + changes: Object.keys(localOverrides).length > 0, 178 + }; 179 + };
+73 -60
components/ThemeManager/ThemeProvider.tsx
··· 8 8 useMemo, 9 9 useState, 10 10 } from "react"; 11 - import { colorToString, useColorAttribute } from "./useColorAttribute"; 11 + import { 12 + colorToString, 13 + useColorAttribute, 14 + useColorAttributeNullable, 15 + } from "./useColorAttribute"; 12 16 import { Color as AriaColor, parseColor } from "react-aria-components"; 13 17 import { parse, contrastLstar, ColorSpace, sRGB } from "colorjs.io/fn"; 14 18 15 19 import { useEntity } from "src/replicache"; 16 20 import { useLeafletPublicationData } from "components/PageSWRDataProvider"; 21 + import { 22 + PublicationBackgroundProvider, 23 + PublicationThemeProvider, 24 + } from "./PublicationThemeProvider"; 25 + import { PubLeafletPublication } from "lexicons/api"; 17 26 18 27 type CSSVariables = { 19 28 "--bg-leaflet": string; ··· 27 36 "--highlight-3": string; 28 37 }; 29 38 39 + // define the color defaults for everything 30 40 export const ThemeDefaults = { 31 41 "theme/page-background": "#F0F7FA", 32 42 "theme/card-background": "#FFFFFF", ··· 42 52 "theme/accent-contrast": "#0000FF", 43 53 }; 44 54 55 + // define a function to set an Aria Color to a CSS Variable in RGB 45 56 function setCSSVariableToColor( 46 57 el: HTMLElement, 47 58 name: string, ··· 49 60 ) { 50 61 el?.style.setProperty(name, colorToString(value, "rgb")); 51 62 } 63 + 64 + //Create a wrapper that applies a theme to each page 52 65 export function ThemeProvider(props: { 53 66 entityID: string | null; 54 67 local?: boolean; 55 68 children: React.ReactNode; 69 + className?: string; 56 70 }) { 57 71 let { data: pub } = useLeafletPublicationData(); 58 - if (!pub) return <LeafletThemeProvider {...props} />; 59 - return <PublicationThemeProvider {...props} />; 60 - } 61 - export function PublicationThemeProvider(props: { 62 - entityID: string | null; 63 - local?: boolean; 64 - children: React.ReactNode; 65 - }) { 66 - let bgLeaflet = useMemo(() => { 67 - return parseColor(`#FDFCFA`); 68 - }, []); 69 - let bgPage = useColorAttribute(props.entityID, "theme/card-background"); 70 - let primary = useColorAttribute(props.entityID, "theme/primary"); 71 - 72 - let highlight1 = useEntity(props.entityID, "theme/highlight-1"); 73 - let highlight2 = useColorAttribute(props.entityID, "theme/highlight-2"); 74 - let highlight3 = useColorAttribute(props.entityID, "theme/highlight-3"); 75 - 76 - let accent1 = useColorAttribute(props.entityID, "theme/accent-background"); 77 - let accent2 = useColorAttribute(props.entityID, "theme/accent-text"); 78 - // set accent contrast to the accent color that has the highest contrast with the page background 79 - let accentContrast = [accent1, accent2].sort((a, b) => { 80 - return ( 81 - getColorContrast(colorToString(b, "rgb"), colorToString(bgPage, "rgb")) - 82 - getColorContrast(colorToString(a, "rgb"), colorToString(bgPage, "rgb")) 83 - ); 84 - })[0]; 85 - 72 + if (!pub || !pub.publications) return <LeafletThemeProvider {...props} />; 86 73 return ( 87 - <BaseThemeProvider 88 - local={props.local} 89 - bgLeaflet={bgLeaflet} 90 - bgPage={bgPage} 91 - primary={primary} 92 - highlight2={highlight2} 93 - highlight3={highlight3} 94 - highlight1={highlight1?.data.value} 95 - accent1={accent1} 96 - accent2={accent2} 97 - accentContrast={accentContrast} 98 - > 99 - {props.children} 100 - </BaseThemeProvider> 74 + <PublicationThemeProvider 75 + {...props} 76 + record={pub.publications?.record as PubLeafletPublication.Record} 77 + pub_creator={pub.publications?.identity_did} 78 + /> 101 79 ); 102 80 } 81 + // for PUBLICATIONS: define Aria Colors for each value and use BaseThemeProvider to wrap the content of the page in the theme 103 82 83 + // for LEAFLETS : define Aria Colors for each value and use BaseThemeProvider to wrap the content of the page in the theme 104 84 export function LeafletThemeProvider(props: { 105 85 entityID: string | null; 106 86 local?: boolean; ··· 142 122 ); 143 123 } 144 124 145 - let BaseThemeProvider = ({ 125 + // handles setting all the Aria Color values to CSS Variables and wrapping the page the theme providers 126 + export const BaseThemeProvider = ({ 146 127 local, 147 128 bgLeaflet, 148 129 bgPage, ··· 260 241 entityID: string; 261 242 children: React.ReactNode; 262 243 }) { 263 - let bgPage = useColorAttribute(props.entityID, "theme/card-background"); 264 - let primary = useColorAttribute(props.entityID, "theme/primary"); 265 - let accent1 = useColorAttribute(props.entityID, "theme/accent-background"); 266 - let accent2 = useColorAttribute(props.entityID, "theme/accent-text"); 267 - let accentContrast = [accent1, accent2].sort((a, b) => { 268 - return ( 269 - getColorContrast(colorToString(b, "rgb"), colorToString(bgPage, "rgb")) - 270 - getColorContrast(colorToString(a, "rgb"), colorToString(bgPage, "rgb")) 271 - ); 272 - })[0]; 244 + let bgPage = useColorAttributeNullable( 245 + props.entityID, 246 + "theme/card-background", 247 + ); 248 + let primary = useColorAttributeNullable(props.entityID, "theme/primary"); 249 + let accent1 = useColorAttributeNullable( 250 + props.entityID, 251 + "theme/accent-background", 252 + ); 253 + let accent2 = useColorAttributeNullable(props.entityID, "theme/accent-text"); 254 + let accentContrast = 255 + bgPage && accent1 && accent2 256 + ? [accent1, accent2].sort((a, b) => { 257 + return ( 258 + getColorContrast( 259 + colorToString(b, "rgb"), 260 + colorToString(bgPage, "rgb"), 261 + ) - 262 + getColorContrast( 263 + colorToString(a, "rgb"), 264 + colorToString(bgPage, "rgb"), 265 + ) 266 + ); 267 + })[0] 268 + : null; 273 269 274 270 return ( 275 271 <CardThemeProviderContext.Provider value={props.entityID}> ··· 277 273 className="contents text-primary" 278 274 style={ 279 275 { 280 - "--accent-1": colorToString(accent1, "rgb"), 281 - "--accent-2": colorToString(accent2, "rgb"), 282 - "--accent-contrast": colorToString(accentContrast, "rgb"), 283 - "--bg-page": colorToString(bgPage, "rgb"), 284 - "--bg-page-alpha": bgPage.getChannelValue("alpha"), 285 - "--primary": colorToString(primary, "rgb"), 276 + "--accent-1": accent1 ? colorToString(accent1, "rgb") : undefined, 277 + "--accent-2": accent2 ? colorToString(accent2, "rgb") : undefined, 278 + "--accent-contrast": accentContrast 279 + ? colorToString(accentContrast, "rgb") 280 + : undefined, 281 + "--bg-page": bgPage ? colorToString(bgPage, "rgb") : undefined, 282 + "--bg-page-alpha": bgPage 283 + ? bgPage.getChannelValue("alpha") 284 + : undefined, 285 + "--primary": primary ? colorToString(primary, "rgb") : undefined, 286 286 } as CSSProperties 287 287 } 288 288 > ··· 292 292 ); 293 293 } 294 294 295 + // Wrapper within the Theme Wrapper that provides background image data 295 296 export const ThemeBackgroundProvider = (props: { 296 297 entityID: string; 297 298 children: React.ReactNode; 298 299 }) => { 300 + let { data: pub } = useLeafletPublicationData(); 299 301 let backgroundImage = useEntity(props.entityID, "theme/background-image"); 300 302 let backgroundImageRepeat = useEntity( 301 303 props.entityID, 302 304 "theme/background-image-repeat", 303 305 ); 306 + if (pub?.publications) { 307 + return ( 308 + <PublicationBackgroundProvider 309 + pub_creator={pub?.publications.identity_did || ""} 310 + record={pub?.publications.record as PubLeafletPublication.Record} 311 + > 312 + {props.children} 313 + </PublicationBackgroundProvider> 314 + ); 315 + } 304 316 return ( 305 317 <div 306 318 className="LeafletBackgroundWrapper w-full bg-bg-leaflet text-primary h-full flex flex-col bg-cover bg-center bg-no-repeat items-stretch" ··· 322 334 ); 323 335 }; 324 336 337 + // used to calculate the contrast between page and accent1, accent2, and determin which is higher contrast 325 338 export function getColorContrast(color1: string, color2: string) { 326 339 ColorSpace.register(sRGB); 327 340
+16 -12
components/ThemeManager/ThemeSetter.tsx
··· 4 4 5 5 import { Color } from "react-aria-components"; 6 6 7 - import { LeafletBGPicker } from "./LeafletBGPicker"; 8 - import { PageThemePickers } from "./PageThemePickers"; 7 + import { LeafletBGPicker } from "./Pickers/LeafletBGPicker"; 8 + import { PageThemePickers } from "./Pickers/PageThemePickers"; 9 9 import { useMemo, useState } from "react"; 10 10 import { ReplicacheMutators, useEntity, useReplicache } from "src/replicache"; 11 11 import { Replicache } from "replicache"; ··· 16 16 import { CheckboxChecked } from "components/Icons/CheckboxChecked"; 17 17 import { CheckboxEmpty } from "components/Icons/CheckboxEmpty"; 18 18 import { PaintSmall } from "components/Icons/PaintSmall"; 19 - import { AccentThemePickers } from "./AccentThemePickers"; 19 + import { AccentPickers } from "./Pickers/AccentPickers"; 20 + import { useLeafletPublicationData } from "components/PageSWRDataProvider"; 20 21 21 22 export type pickers = 22 23 | "null" ··· 44 45 } 45 46 export const ThemePopover = (props: { entityID: string; home?: boolean }) => { 46 47 let { rep } = useReplicache(); 48 + let { data: pub } = useLeafletPublicationData(); 47 49 48 50 // I need to get these variables from replicache and then write them to the DB. I also need to parse them into a state that can be used here. 49 51 let permission = useEntitySetContext().permissions.write; ··· 61 63 }, [rep, props.entityID]); 62 64 63 65 if (!permission) return null; 66 + if (pub) return null; 64 67 65 68 return ( 66 69 <> ··· 111 114 className={`bg-bg-leaflet p-3 mb-2 flex flex-col rounded-md border border-border pb-0`} 112 115 > 113 116 <div className={`flex flex-col z-10 mt-4 -mb-[6px] `}> 114 - <AccentThemePickers 117 + <AccentPickers 115 118 entityID={props.entityID} 116 119 openPicker={openPicker} 117 120 setOpenPicker={(pickers) => setOpenPicker(pickers)} ··· 130 133 131 134 <div className="flex flex-col mt-8 -mb-[6px] z-10"> 132 135 <PageThemePickers 133 - home 134 136 entityID={props.entityID} 135 137 openPicker={openPicker} 136 138 setOpenPicker={(pickers) => setOpenPicker(pickers)} ··· 233 235 onClick={(e) => { 234 236 e.currentTarget === e.target && props.setOpenPicker("page"); 235 237 }} 236 - className={ 237 - pageBorderHidden 238 - ? "py-2 px-0 border border-transparent" 239 - : `${props.home ? "rounded-md " : "rounded-t-lg "} relative cursor-pointer p-2 border border-border border-b-transparent shadow-md text-primary 240 - ` 241 - } 238 + className={` 239 + text-primary relative 240 + ${ 241 + pageBorderHidden 242 + ? "py-2 px-0 border border-transparent" 243 + : `cursor-pointer p-2 border border-border border-b-transparent shadow-md 244 + ${props.home ? "rounded-md " : "rounded-t-lg "}` 245 + }`} 242 246 style={ 243 247 pageBorderHidden 244 248 ? undefined ··· 265 269 } 266 270 } 267 271 /> 268 - <div> 272 + <div className="z-10 relative"> 269 273 <p 270 274 onClick={() => { 271 275 props.setOpenPicker("text");
+16
components/ThemeManager/useColorAttribute.ts
··· 8 8 entity: string | null, 9 9 attribute: keyof FilterAttributes<{ type: "color"; cardinality: "one" }>, 10 10 ) { 11 + // takes a color string and turns it into a react-aria Color type 12 + // we need it to interact with Color Pickers for themeing 11 13 let { rootEntity } = useReplicache(); 12 14 let color = useEntity(entity, attribute); 13 15 let fallbackColor = useEntity(color ? null : rootEntity, attribute); ··· 16 18 return parseColor(c ? `hsba(${c.data.value})` : ThemeDefaults[attribute]); 17 19 }, [color, fallbackColor, attribute]); 18 20 } 21 + export function useColorAttributeNullable( 22 + entity: string | null, 23 + attribute: keyof FilterAttributes<{ type: "color"; cardinality: "one" }>, 24 + ) { 25 + // takes a color string and turns it into a react-aria Color type 26 + // we need it to interact with Color Pickers for themeing 27 + let color = useEntity(entity, attribute); 28 + return useMemo(() => { 29 + let c = color; 30 + return c ? parseColor(`hsba(${c.data.value})`) : null; 31 + }, [color, attribute]); 32 + } 19 33 20 34 export function colorToString(value: Color, space: "rgb" | "hsba") { 35 + // takes a react-aria Color type and turns it into an rgb or hsba color string. 36 + // we need it to set the color in our database when we can the color back from the Color Pickers 21 37 return value.toString(space).slice(space.length + 1, -1); 22 38 }
+33
components/Toggle.tsx
··· 1 + import { theme } from "tailwind.config"; 2 + 3 + export const Toggle = (props: { 4 + toggleOn: boolean; 5 + setToggleOn: (s: boolean) => void; 6 + disabledColor1?: string; 7 + disabledColor2?: string; 8 + }) => { 9 + return ( 10 + <button 11 + className="toggle selected-outline transparent-outline flex items-center h-[20px] w-6 rounded-md border-border" 12 + style={{ 13 + border: props.toggleOn 14 + ? "1px solid " + theme.colors["accent-2"] 15 + : "1px solid " + props.disabledColor2 || theme.colors["border-light"], 16 + justifyContent: props.toggleOn ? "flex-end" : "flex-start", 17 + background: props.toggleOn 18 + ? theme.colors["accent-1"] 19 + : props.disabledColor1 || theme.colors["tertiary"], 20 + }} 21 + onClick={() => props.setToggleOn(!props.toggleOn)} 22 + > 23 + <div 24 + className="h-[14px] w-[10px] m-0.5 rounded-[2px]" 25 + style={{ 26 + background: props.toggleOn 27 + ? theme.colors["accent-2"] 28 + : props.disabledColor2 || theme.colors["border-light"], 29 + }} 30 + /> 31 + </button> 32 + ); 33 + };
+1 -3
components/Toolbar/HighlightToolbar.tsx
··· 14 14 SectionArrow, 15 15 setColorAttribute, 16 16 } from "components/ThemeManager/ThemeSetter"; 17 - import { ColorPicker } from "components/ThemeManager/ColorPicker"; 17 + import { ColorPicker } from "components/ThemeManager/Pickers/ColorPicker"; 18 18 import { useEntity, useReplicache } from "src/replicache"; 19 19 import { useEffect, useMemo, useState } from "react"; 20 20 import { useColorAttribute } from "components/ThemeManager/useColorAttribute"; 21 - import { useParams } from "next/navigation"; 22 21 import { rangeHasMark } from "src/utils/prosemirror/rangeHasMark"; 23 22 24 23 import { Separator, ShortcutKey } from "components/Layout"; ··· 28 27 import { PopoverArrow } from "components/Icons/PopoverArrow"; 29 28 import { ArrowRightTiny } from "components/Icons/ArrowRightTiny"; 30 29 import { PaintSmall } from "components/Icons/PaintSmall"; 31 - import { Color } from "react-aria-components"; 32 30 import { isMac } from "src/utils/isDevice"; 33 31 34 32 export const HighlightButton = (props: {
+14
lexicons/api/index.ts
··· 15 15 import * as PubLeafletGraphSubscription from './types/pub/leaflet/graph/subscription' 16 16 import * as PubLeafletPagesLinearDocument from './types/pub/leaflet/pages/linearDocument' 17 17 import * as PubLeafletRichtextFacet from './types/pub/leaflet/richtext/facet' 18 + import * as PubLeafletThemeBackgroundImage from './types/pub/leaflet/theme/backgroundImage' 19 + import * as PubLeafletThemeColor from './types/pub/leaflet/theme/color' 18 20 import * as ComAtprotoLabelDefs from './types/com/atproto/label/defs' 19 21 import * as ComAtprotoRepoApplyWrites from './types/com/atproto/repo/applyWrites' 20 22 import * as ComAtprotoRepoCreateRecord from './types/com/atproto/repo/createRecord' ··· 40 42 export * as PubLeafletGraphSubscription from './types/pub/leaflet/graph/subscription' 41 43 export * as PubLeafletPagesLinearDocument from './types/pub/leaflet/pages/linearDocument' 42 44 export * as PubLeafletRichtextFacet from './types/pub/leaflet/richtext/facet' 45 + export * as PubLeafletThemeBackgroundImage from './types/pub/leaflet/theme/backgroundImage' 46 + export * as PubLeafletThemeColor from './types/pub/leaflet/theme/color' 43 47 export * as ComAtprotoLabelDefs from './types/com/atproto/label/defs' 44 48 export * as ComAtprotoRepoApplyWrites from './types/com/atproto/repo/applyWrites' 45 49 export * as ComAtprotoRepoCreateRecord from './types/com/atproto/repo/createRecord' ··· 99 103 graph: PubLeafletGraphNS 100 104 pages: PubLeafletPagesNS 101 105 richtext: PubLeafletRichtextNS 106 + theme: PubLeafletThemeNS 102 107 103 108 constructor(client: XrpcClient) { 104 109 this._client = client ··· 106 111 this.graph = new PubLeafletGraphNS(client) 107 112 this.pages = new PubLeafletPagesNS(client) 108 113 this.richtext = new PubLeafletRichtextNS(client) 114 + this.theme = new PubLeafletThemeNS(client) 109 115 this.document = new DocumentRecord(client) 110 116 this.publication = new PublicationRecord(client) 111 117 } ··· 203 209 } 204 210 205 211 export class PubLeafletRichtextNS { 212 + _client: XrpcClient 213 + 214 + constructor(client: XrpcClient) { 215 + this._client = client 216 + } 217 + } 218 + 219 + export class PubLeafletThemeNS { 206 220 _client: XrpcClient 207 221 208 222 constructor(client: XrpcClient) {
+130
lexicons/api/lexicons.ts
··· 91 91 accept: ['image/*'], 92 92 maxSize: 1000000, 93 93 }, 94 + theme: { 95 + type: 'ref', 96 + ref: 'lex:pub.leaflet.publication#theme', 97 + }, 98 + }, 99 + }, 100 + }, 101 + theme: { 102 + type: 'object', 103 + properties: { 104 + backgroundColor: { 105 + type: 'union', 106 + refs: [ 107 + 'lex:pub.leaflet.theme.color#rgba', 108 + 'lex:pub.leaflet.theme.color#rgb', 109 + ], 110 + }, 111 + backgroundImage: { 112 + type: 'ref', 113 + ref: 'lex:pub.leaflet.theme.backgroundImage', 114 + }, 115 + primary: { 116 + type: 'union', 117 + refs: [ 118 + 'lex:pub.leaflet.theme.color#rgba', 119 + 'lex:pub.leaflet.theme.color#rgb', 120 + ], 121 + }, 122 + pageBackground: { 123 + type: 'union', 124 + refs: [ 125 + 'lex:pub.leaflet.theme.color#rgba', 126 + 'lex:pub.leaflet.theme.color#rgb', 127 + ], 128 + }, 129 + showPageBackground: { 130 + type: 'boolean', 131 + default: false, 132 + }, 133 + accentBackground: { 134 + type: 'union', 135 + refs: [ 136 + 'lex:pub.leaflet.theme.color#rgba', 137 + 'lex:pub.leaflet.theme.color#rgb', 138 + ], 139 + }, 140 + accentText: { 141 + type: 'union', 142 + refs: [ 143 + 'lex:pub.leaflet.theme.color#rgba', 144 + 'lex:pub.leaflet.theme.color#rgb', 145 + ], 94 146 }, 95 147 }, 96 148 }, ··· 408 460 description: 'Facet feature for italic text', 409 461 required: [], 410 462 properties: {}, 463 + }, 464 + }, 465 + }, 466 + PubLeafletThemeBackgroundImage: { 467 + lexicon: 1, 468 + id: 'pub.leaflet.theme.backgroundImage', 469 + defs: { 470 + main: { 471 + type: 'object', 472 + required: ['image'], 473 + properties: { 474 + image: { 475 + type: 'blob', 476 + accept: ['image/*'], 477 + maxSize: 1000000, 478 + }, 479 + width: { 480 + type: 'integer', 481 + }, 482 + repeat: { 483 + type: 'boolean', 484 + }, 485 + }, 486 + }, 487 + }, 488 + }, 489 + PubLeafletThemeColor: { 490 + lexicon: 1, 491 + id: 'pub.leaflet.theme.color', 492 + defs: { 493 + rgba: { 494 + type: 'object', 495 + required: ['r', 'g', 'b', 'a'], 496 + properties: { 497 + r: { 498 + type: 'integer', 499 + maximum: 255, 500 + minimum: 0, 501 + }, 502 + g: { 503 + type: 'integer', 504 + maximum: 255, 505 + minimum: 0, 506 + }, 507 + b: { 508 + type: 'integer', 509 + maximum: 255, 510 + minimum: 0, 511 + }, 512 + a: { 513 + type: 'integer', 514 + maximum: 100, 515 + minimum: 0, 516 + }, 517 + }, 518 + }, 519 + rgb: { 520 + type: 'object', 521 + required: ['r', 'g', 'b'], 522 + properties: { 523 + r: { 524 + type: 'integer', 525 + maximum: 255, 526 + minimum: 0, 527 + }, 528 + g: { 529 + type: 'integer', 530 + maximum: 255, 531 + minimum: 0, 532 + }, 533 + b: { 534 + type: 'integer', 535 + maximum: 255, 536 + minimum: 0, 537 + }, 538 + }, 411 539 }, 412 540 }, 413 541 }, ··· 1459 1587 PubLeafletGraphSubscription: 'pub.leaflet.graph.subscription', 1460 1588 PubLeafletPagesLinearDocument: 'pub.leaflet.pages.linearDocument', 1461 1589 PubLeafletRichtextFacet: 'pub.leaflet.richtext.facet', 1590 + PubLeafletThemeBackgroundImage: 'pub.leaflet.theme.backgroundImage', 1591 + PubLeafletThemeColor: 'pub.leaflet.theme.color', 1462 1592 ComAtprotoLabelDefs: 'com.atproto.label.defs', 1463 1593 ComAtprotoRepoApplyWrites: 'com.atproto.repo.applyWrites', 1464 1594 ComAtprotoRepoCreateRecord: 'com.atproto.repo.createRecord',
+39
lexicons/api/types/pub/leaflet/publication.ts
··· 5 5 import { CID } from 'multiformats/cid' 6 6 import { validate as _validate } from '../../../lexicons' 7 7 import { $Typed, is$typed as _is$typed, OmitKey } from '../../../util' 8 + import type * as PubLeafletThemeColor from './theme/color' 9 + import type * as PubLeafletThemeBackgroundImage from './theme/backgroundImage' 8 10 9 11 const is$typed = _is$typed, 10 12 validate = _validate ··· 16 18 base_path?: string 17 19 description?: string 18 20 icon?: BlobRef 21 + theme?: Theme 19 22 [k: string]: unknown 20 23 } 21 24 ··· 28 31 export function validateRecord<V>(v: V) { 29 32 return validate<Record & V>(v, id, hashRecord, true) 30 33 } 34 + 35 + export interface Theme { 36 + $type?: 'pub.leaflet.publication#theme' 37 + backgroundColor?: 38 + | $Typed<PubLeafletThemeColor.Rgba> 39 + | $Typed<PubLeafletThemeColor.Rgb> 40 + | { $type: string } 41 + backgroundImage?: PubLeafletThemeBackgroundImage.Main 42 + primary?: 43 + | $Typed<PubLeafletThemeColor.Rgba> 44 + | $Typed<PubLeafletThemeColor.Rgb> 45 + | { $type: string } 46 + pageBackground?: 47 + | $Typed<PubLeafletThemeColor.Rgba> 48 + | $Typed<PubLeafletThemeColor.Rgb> 49 + | { $type: string } 50 + showPageBackground: boolean 51 + accentBackground?: 52 + | $Typed<PubLeafletThemeColor.Rgba> 53 + | $Typed<PubLeafletThemeColor.Rgb> 54 + | { $type: string } 55 + accentText?: 56 + | $Typed<PubLeafletThemeColor.Rgba> 57 + | $Typed<PubLeafletThemeColor.Rgb> 58 + | { $type: string } 59 + } 60 + 61 + const hashTheme = 'theme' 62 + 63 + export function isTheme<V>(v: V) { 64 + return is$typed(v, id, hashTheme) 65 + } 66 + 67 + export function validateTheme<V>(v: V) { 68 + return validate<Theme & V>(v, id, hashTheme) 69 + }
+28
lexicons/api/types/pub/leaflet/theme/backgroundImage.ts
··· 1 + /** 2 + * GENERATED CODE - DO NOT MODIFY 3 + */ 4 + import { ValidationResult, BlobRef } from '@atproto/lexicon' 5 + import { CID } from 'multiformats/cid' 6 + import { validate as _validate } from '../../../../lexicons' 7 + import { $Typed, is$typed as _is$typed, OmitKey } from '../../../../util' 8 + 9 + const is$typed = _is$typed, 10 + validate = _validate 11 + const id = 'pub.leaflet.theme.backgroundImage' 12 + 13 + export interface Main { 14 + $type?: 'pub.leaflet.theme.backgroundImage' 15 + image: BlobRef 16 + width?: number 17 + repeat?: boolean 18 + } 19 + 20 + const hashMain = 'main' 21 + 22 + export function isMain<V>(v: V) { 23 + return is$typed(v, id, hashMain) 24 + } 25 + 26 + export function validateMain<V>(v: V) { 27 + return validate<Main & V>(v, id, hashMain) 28 + }
+46
lexicons/api/types/pub/leaflet/theme/color.ts
··· 1 + /** 2 + * GENERATED CODE - DO NOT MODIFY 3 + */ 4 + import { ValidationResult, BlobRef } from '@atproto/lexicon' 5 + import { CID } from 'multiformats/cid' 6 + import { validate as _validate } from '../../../../lexicons' 7 + import { $Typed, is$typed as _is$typed, OmitKey } from '../../../../util' 8 + 9 + const is$typed = _is$typed, 10 + validate = _validate 11 + const id = 'pub.leaflet.theme.color' 12 + 13 + export interface Rgba { 14 + $type?: 'pub.leaflet.theme.color#rgba' 15 + r: number 16 + g: number 17 + b: number 18 + a: number 19 + } 20 + 21 + const hashRgba = 'rgba' 22 + 23 + export function isRgba<V>(v: V) { 24 + return is$typed(v, id, hashRgba) 25 + } 26 + 27 + export function validateRgba<V>(v: V) { 28 + return validate<Rgba & V>(v, id, hashRgba) 29 + } 30 + 31 + export interface Rgb { 32 + $type?: 'pub.leaflet.theme.color#rgb' 33 + r: number 34 + g: number 35 + b: number 36 + } 37 + 38 + const hashRgb = 'rgb' 39 + 40 + export function isRgb<V>(v: V) { 41 + return is$typed(v, id, hashRgb) 42 + } 43 + 44 + export function validateRgb<V>(v: V) { 45 + return validate<Rgb & V>(v, id, hashRgb) 46 + }
+2
lexicons/build.ts
··· 2 2 import { BlockLexicons } from "./src/blocks"; 3 3 import { PubLeafletDocument } from "./src/document"; 4 4 import * as PublicationLexicons from "./src/publication"; 5 + import { ThemeLexicons } from "./src/theme"; 5 6 6 7 import * as fs from "fs"; 7 8 import * as path from "path"; ··· 18 19 PubLeafletDocument, 19 20 PubLeafletRichTextFacet, 20 21 PageLexicons.PubLeafletPagesLinearDocument, 22 + ...ThemeLexicons, 21 23 ...BlockLexicons, 22 24 ...Object.values(PublicationLexicons), 23 25 ];
+52
lexicons/pub/leaflet/publication.json
··· 30 30 "image/*" 31 31 ], 32 32 "maxSize": 1000000 33 + }, 34 + "theme": { 35 + "type": "ref", 36 + "ref": "#theme" 33 37 } 38 + } 39 + } 40 + }, 41 + "theme": { 42 + "type": "object", 43 + "properties": { 44 + "backgroundColor": { 45 + "type": "union", 46 + "refs": [ 47 + "pub.leaflet.theme.color#rgba", 48 + "pub.leaflet.theme.color#rgb" 49 + ] 50 + }, 51 + "backgroundImage": { 52 + "type": "ref", 53 + "ref": "pub.leaflet.theme.backgroundImage" 54 + }, 55 + "primary": { 56 + "type": "union", 57 + "refs": [ 58 + "pub.leaflet.theme.color#rgba", 59 + "pub.leaflet.theme.color#rgb" 60 + ] 61 + }, 62 + "pageBackground": { 63 + "type": "union", 64 + "refs": [ 65 + "pub.leaflet.theme.color#rgba", 66 + "pub.leaflet.theme.color#rgb" 67 + ] 68 + }, 69 + "showPageBackground": { 70 + "type": "boolean", 71 + "default": false 72 + }, 73 + "accentBackground": { 74 + "type": "union", 75 + "refs": [ 76 + "pub.leaflet.theme.color#rgba", 77 + "pub.leaflet.theme.color#rgb" 78 + ] 79 + }, 80 + "accentText": { 81 + "type": "union", 82 + "refs": [ 83 + "pub.leaflet.theme.color#rgba", 84 + "pub.leaflet.theme.color#rgb" 85 + ] 34 86 } 35 87 } 36 88 }
+27
lexicons/pub/leaflet/theme/backgroundImage.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "pub.leaflet.theme.backgroundImage", 4 + "defs": { 5 + "main": { 6 + "type": "object", 7 + "required": [ 8 + "image" 9 + ], 10 + "properties": { 11 + "image": { 12 + "type": "blob", 13 + "accept": [ 14 + "image/*" 15 + ], 16 + "maxSize": 1000000 17 + }, 18 + "width": { 19 + "type": "integer" 20 + }, 21 + "repeat": { 22 + "type": "boolean" 23 + } 24 + } 25 + } 26 + } 27 + }
+62
lexicons/pub/leaflet/theme/color.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "pub.leaflet.theme.color", 4 + "defs": { 5 + "rgba": { 6 + "type": "object", 7 + "required": [ 8 + "r", 9 + "g", 10 + "b", 11 + "a" 12 + ], 13 + "properties": { 14 + "r": { 15 + "type": "integer", 16 + "maximum": 255, 17 + "minimum": 0 18 + }, 19 + "g": { 20 + "type": "integer", 21 + "maximum": 255, 22 + "minimum": 0 23 + }, 24 + "b": { 25 + "type": "integer", 26 + "maximum": 255, 27 + "minimum": 0 28 + }, 29 + "a": { 30 + "type": "integer", 31 + "maximum": 100, 32 + "minimum": 0 33 + } 34 + } 35 + }, 36 + "rgb": { 37 + "type": "object", 38 + "required": [ 39 + "r", 40 + "g", 41 + "b" 42 + ], 43 + "properties": { 44 + "r": { 45 + "type": "integer", 46 + "maximum": 255, 47 + "minimum": 0 48 + }, 49 + "g": { 50 + "type": "integer", 51 + "maximum": 255, 52 + "minimum": 0 53 + }, 54 + "b": { 55 + "type": "integer", 56 + "maximum": 255, 57 + "minimum": 0 58 + } 59 + } 60 + } 61 + } 62 + }
+1 -28
lexicons/publish.ts
··· 2 2 import * as path from "path"; 3 3 import * as dns from "dns/promises"; 4 4 import { AtpAgent } from "@atproto/api"; 5 + import { deepEquals } from "src/utils/deepEquals"; 5 6 6 7 function readLexiconFiles(): { id: string }[] { 7 8 const lexiconDir = path.join("lexicons", "pub", "leaflet"); ··· 115 116 }; 116 117 117 118 main(); 118 - function deepEquals(obj1: any, obj2: any): boolean { 119 - // Check if both are the same reference 120 - if (obj1 === obj2) return true; 121 - 122 - // Check if either is null or not an object 123 - if ( 124 - obj1 === null || 125 - obj2 === null || 126 - typeof obj1 !== "object" || 127 - typeof obj2 !== "object" 128 - ) 129 - return false; 130 - 131 - // Get keys from both objects 132 - const keys1 = Object.keys(obj1); 133 - const keys2 = Object.keys(obj2); 134 - 135 - // Check if they have the same number of keys 136 - if (keys1.length !== keys2.length) return false; 137 - 138 - // Check each key and value recursively 139 - for (const key of keys1) { 140 - if (!keys2.includes(key)) return false; 141 - if (!deepEquals(obj1[key], obj2[key])) return false; 142 - } 143 - 144 - return true; 145 - } 146 119 147 120 async function getRecord(rkey: string, agent: AtpAgent) { 148 121 try {
+17
lexicons/src/publication.ts
··· 1 1 import { LexiconDoc } from "@atproto/lexicon"; 2 + import { ColorUnion, PubLeafletThemeBackgroundImage } from "./theme"; 2 3 3 4 export const PubLeafletPublication: LexiconDoc = { 4 5 lexicon: 1, ··· 16 17 base_path: { type: "string", format: "uri" }, 17 18 description: { type: "string", maxLength: 2000 }, 18 19 icon: { type: "blob", accept: ["image/*"], maxSize: 1000000 }, 20 + theme: { type: "ref", ref: "#theme" }, 19 21 }, 22 + }, 23 + }, 24 + theme: { 25 + type: "object", 26 + properties: { 27 + backgroundColor: ColorUnion, 28 + backgroundImage: { 29 + type: "ref", 30 + ref: PubLeafletThemeBackgroundImage.id, 31 + }, 32 + primary: ColorUnion, 33 + pageBackground: ColorUnion, 34 + showPageBackground: { type: "boolean", default: false }, 35 + accentBackground: ColorUnion, 36 + accentText: ColorUnion, 20 37 }, 21 38 }, 22 39 },
+58
lexicons/src/theme.ts
··· 1 + import { LexiconDoc, LexRefUnion } from "@atproto/lexicon"; 2 + 3 + export const PubLeafletThemeBackgroundImage = { 4 + lexicon: 1, 5 + id: "pub.leaflet.theme.backgroundImage", 6 + defs: { 7 + main: { 8 + type: "object", 9 + required: ["image"], 10 + properties: { 11 + image: { 12 + type: "blob", 13 + accept: ["image/*"], 14 + maxSize: 1000000, 15 + }, 16 + width: { type: "integer" }, 17 + repeat: { type: "boolean" }, 18 + }, 19 + }, 20 + }, 21 + }; 22 + export const PubLeafletThemeColor: LexiconDoc = { 23 + lexicon: 1, 24 + id: "pub.leaflet.theme.color", 25 + defs: { 26 + rgba: { 27 + type: "object", 28 + required: ["r", "g", "b", "a"], 29 + properties: { 30 + r: { type: "integer", maximum: 255, minimum: 0 }, 31 + g: { type: "integer", maximum: 255, minimum: 0 }, 32 + b: { type: "integer", maximum: 255, minimum: 0 }, 33 + a: { type: "integer", maximum: 100, minimum: 0 }, 34 + }, 35 + }, 36 + rgb: { 37 + type: "object", 38 + required: ["r", "g", "b"], 39 + properties: { 40 + r: { type: "integer", maximum: 255, minimum: 0 }, 41 + g: { type: "integer", maximum: 255, minimum: 0 }, 42 + b: { type: "integer", maximum: 255, minimum: 0 }, 43 + }, 44 + }, 45 + }, 46 + }; 47 + 48 + export const ThemeLexicons = [ 49 + PubLeafletThemeBackgroundImage, 50 + PubLeafletThemeColor, 51 + ]; 52 + 53 + export const ColorUnion: LexRefUnion = { 54 + type: "union", 55 + refs: Object.keys(PubLeafletThemeColor.defs).map( 56 + (key) => `${PubLeafletThemeColor.id}#${key}`, 57 + ), 58 + };
+28
src/utils/deepEquals.ts
··· 1 + export function deepEquals(obj1: any, obj2: any): boolean { 2 + // Check if both are the same reference 3 + if (obj1 === obj2) return true; 4 + 5 + // Check if either is null or not an object 6 + if ( 7 + obj1 === null || 8 + obj2 === null || 9 + typeof obj1 !== "object" || 10 + typeof obj2 !== "object" 11 + ) 12 + return false; 13 + 14 + // Get keys from both objects 15 + const keys1 = Object.keys(obj1); 16 + const keys2 = Object.keys(obj2); 17 + 18 + // Check if they have the same number of keys 19 + if (keys1.length !== keys2.length) return false; 20 + 21 + // Check each key and value recursively 22 + for (const key of keys1) { 23 + if (!keys2.includes(key)) return false; 24 + if (!deepEquals(obj1[key], obj2[key])) return false; 25 + } 26 + 27 + return true; 28 + }