a tool for shared writing and social publishing

Feature/publish leaflets (#235)

* reorganized Actions in leaflet a bit

* started up on the publish button, WIP

* added an icon for one-offs/looseleafs/whatever

* adjusted popover to new one step design

* wrap up styling tweaks to the publish popover

* title input in publish post details form now pulls first block content
as title

* add a looseleaf button to the homepage

* icon stuffffff

* make save as draft and publish work to existing pub

* make publication optional on documents

* support publishing standalone leaflets

* support adding theme to standalone published docs

* render standalone published docs at /p/

* render standalone published docs properly on homepage

* handle publishing doc theme

* delete entiteis before publishing

* tweak publish page styles

* add published link for standaloen docs in share menu

* style tweaks to make starting a new pub an external link rather than a
redirect

* tiny lil teaks to create pub form

* fix type errors

* added the looseleaf page

* handle canvases better

* some small tweaks

* handle publishing canvas pages as first page

* simplify page handling and support root canvas page

* add basic looseleaf page

* don't show looseleafs tab if none

* add first page to start of array

* fix metadata on read links

* get theme from correct place and fix pub metadata

* remove flex on published page link block content

* fix imports and types

* fix looseleaf titles

---------

Co-authored-by: celine <celine@hyperlink.academy>

authored by awarm.space

celine and committed by
GitHub
16ad5487 7ab65db1

+1870 -823
+6
actions/createPublicationDraft.ts
··· 11 11 redirectUser: false, 12 12 firstBlockType: "text", 13 13 }); 14 + let { data: publication } = await supabaseServerClient 15 + .from("publications") 16 + .select("*") 17 + .eq("uri", publication_uri) 18 + .single(); 19 + if (publication?.identity_did !== identity.atp_did) return; 14 20 15 21 await supabaseServerClient 16 22 .from("leaflets_in_publications")
+1
actions/getIdentityData.ts
··· 30 30 id, 31 31 root_entity, 32 32 permission_token_rights(*), 33 + leaflets_to_documents(*, documents(*)), 33 34 leaflets_in_publications(*, publications(*), documents(*)) 34 35 ) 35 36 )
+33
actions/publications/moveLeafletToPublication.ts
··· 1 + "use server"; 2 + 3 + import { getIdentityData } from "actions/getIdentityData"; 4 + import { supabaseServerClient } from "supabase/serverClient"; 5 + 6 + export async function moveLeafletToPublication( 7 + leaflet_id: string, 8 + publication_uri: string, 9 + metadata: { title: string; description: string }, 10 + entitiesToDelete: string[], 11 + ) { 12 + let identity = await getIdentityData(); 13 + if (!identity || !identity.atp_did) return null; 14 + let { data: publication } = await supabaseServerClient 15 + .from("publications") 16 + .select("*") 17 + .eq("uri", publication_uri) 18 + .single(); 19 + if (publication?.identity_did !== identity.atp_did) return; 20 + 21 + await supabaseServerClient.from("leaflets_in_publications").insert({ 22 + publication: publication_uri, 23 + leaflet: leaflet_id, 24 + doc: null, 25 + title: metadata.title, 26 + description: metadata.description, 27 + }); 28 + 29 + await supabaseServerClient 30 + .from("entities") 31 + .delete() 32 + .in("id", entitiesToDelete); 33 + }
-26
actions/publications/updateLeafletDraftMetadata.ts
··· 1 - "use server"; 2 - 3 - import { getIdentityData } from "actions/getIdentityData"; 4 - import { supabaseServerClient } from "supabase/serverClient"; 5 - 6 - export async function updateLeafletDraftMetadata( 7 - leafletID: string, 8 - publication_uri: string, 9 - title: string, 10 - description: string, 11 - ) { 12 - let identity = await getIdentityData(); 13 - if (!identity?.atp_did) return null; 14 - let { data: publication } = await supabaseServerClient 15 - .from("publications") 16 - .select() 17 - .eq("uri", publication_uri) 18 - .single(); 19 - if (!publication || publication.identity_did !== identity.atp_did) 20 - return null; 21 - await supabaseServerClient 22 - .from("leaflets_in_publications") 23 - .update({ title, description }) 24 - .eq("leaflet", leafletID) 25 - .eq("publication", publication_uri); 26 - }
+202 -51
actions/publishToPublication.ts
··· 44 44 import { List, parseBlocksToList } from "src/utils/parseBlocksToList"; 45 45 import { getBlocksWithTypeLocal } from "src/hooks/queries/useBlocks"; 46 46 import { Lock } from "src/utils/lock"; 47 + import type { PubLeafletPublication } from "lexicons/api"; 48 + import { 49 + ColorToRGB, 50 + ColorToRGBA, 51 + } from "components/ThemeManager/colorToLexicons"; 52 + import { parseColor } from "@react-stately/color"; 47 53 48 54 export async function publishToPublication({ 49 55 root_entity, ··· 51 57 leaflet_id, 52 58 title, 53 59 description, 60 + entitiesToDelete, 54 61 }: { 55 62 root_entity: string; 56 - publication_uri: string; 63 + publication_uri?: string; 57 64 leaflet_id: string; 58 65 title?: string; 59 66 description?: string; 67 + entitiesToDelete?: string[]; 60 68 }) { 61 69 const oauthClient = await createOauthClient(); 62 70 let identity = await getIdentityData(); ··· 66 74 let agent = new AtpBaseClient( 67 75 credentialSession.fetchHandler.bind(credentialSession), 68 76 ); 69 - let { data: draft } = await supabaseServerClient 70 - .from("leaflets_in_publications") 71 - .select("*, publications(*), documents(*)") 72 - .eq("publication", publication_uri) 73 - .eq("leaflet", leaflet_id) 74 - .single(); 75 - if (!draft || identity.atp_did !== draft?.publications?.identity_did) 76 - throw new Error("No draft or not publisher"); 77 + 78 + // Check if we're publishing to a publication or standalone 79 + let draft: any = null; 80 + let existingDocUri: string | null = null; 81 + 82 + if (publication_uri) { 83 + // Publishing to a publication - use leaflets_in_publications 84 + let { data } = await supabaseServerClient 85 + .from("leaflets_in_publications") 86 + .select("*, publications(*), documents(*)") 87 + .eq("publication", publication_uri) 88 + .eq("leaflet", leaflet_id) 89 + .single(); 90 + if (!data || identity.atp_did !== data?.publications?.identity_did) 91 + throw new Error("No draft or not publisher"); 92 + draft = data; 93 + existingDocUri = draft?.doc; 94 + } else { 95 + // Publishing standalone - use leaflets_to_documents 96 + let { data } = await supabaseServerClient 97 + .from("leaflets_to_documents") 98 + .select("*, documents(*)") 99 + .eq("leaflet", leaflet_id) 100 + .single(); 101 + draft = data; 102 + existingDocUri = draft?.document; 103 + } 104 + 105 + // Heuristic: Remove title entities if this is the first time publishing 106 + // (when coming from a standalone leaflet with entitiesToDelete passed in) 107 + if (entitiesToDelete && entitiesToDelete.length > 0 && !existingDocUri) { 108 + await supabaseServerClient 109 + .from("entities") 110 + .delete() 111 + .in("id", entitiesToDelete); 112 + } 113 + 77 114 let { data } = await supabaseServerClient.rpc("get_facts", { 78 115 root: root_entity, 79 116 }); 80 117 let facts = (data as unknown as Fact<Attribute>[]) || []; 81 118 82 - let { firstPageBlocks, pages } = await processBlocksToPages( 119 + let { pages } = await processBlocksToPages( 83 120 facts, 84 121 agent, 85 122 root_entity, ··· 88 125 89 126 let existingRecord = 90 127 (draft?.documents?.data as PubLeafletDocument.Record | undefined) || {}; 128 + 129 + // Extract theme for standalone documents (not for publications) 130 + let theme: PubLeafletPublication.Theme | undefined; 131 + if (!publication_uri) { 132 + theme = await extractThemeFromFacts(facts, root_entity, agent); 133 + } 134 + 91 135 let record: PubLeafletDocument.Record = { 136 + publishedAt: new Date().toISOString(), 137 + ...existingRecord, 92 138 $type: "pub.leaflet.document", 93 139 author: credentialSession.did!, 94 - publication: publication_uri, 95 - publishedAt: new Date().toISOString(), 96 - ...existingRecord, 140 + ...(publication_uri && { publication: publication_uri }), 141 + ...(theme && { theme }), 97 142 title: title || "Untitled", 98 143 description: description || "", 99 - pages: [ 100 - { 101 - $type: "pub.leaflet.pages.linearDocument", 102 - blocks: firstPageBlocks, 103 - }, 104 - ...pages.map((p) => { 105 - if (p.type === "canvas") { 106 - return { 107 - $type: "pub.leaflet.pages.canvas" as const, 108 - id: p.id, 109 - blocks: p.blocks as PubLeafletPagesCanvas.Block[], 110 - }; 111 - } else { 112 - return { 113 - $type: "pub.leaflet.pages.linearDocument" as const, 114 - id: p.id, 115 - blocks: p.blocks as PubLeafletPagesLinearDocument.Block[], 116 - }; 117 - } 118 - }), 119 - ], 144 + pages: pages.map((p) => { 145 + if (p.type === "canvas") { 146 + return { 147 + $type: "pub.leaflet.pages.canvas" as const, 148 + id: p.id, 149 + blocks: p.blocks as PubLeafletPagesCanvas.Block[], 150 + }; 151 + } else { 152 + return { 153 + $type: "pub.leaflet.pages.linearDocument" as const, 154 + id: p.id, 155 + blocks: p.blocks as PubLeafletPagesLinearDocument.Block[], 156 + }; 157 + } 158 + }), 120 159 }; 121 - let rkey = draft?.doc ? new AtUri(draft.doc).rkey : TID.nextStr(); 160 + 161 + // Keep the same rkey if updating an existing document 162 + let rkey = existingDocUri ? new AtUri(existingDocUri).rkey : TID.nextStr(); 122 163 let { data: result } = await agent.com.atproto.repo.putRecord({ 123 164 rkey, 124 165 repo: credentialSession.did!, ··· 127 168 validate: false, //TODO publish the lexicon so we can validate! 128 169 }); 129 170 171 + // Optimistically create database entries 130 172 await supabaseServerClient.from("documents").upsert({ 131 173 uri: result.uri, 132 174 data: record as Json, 133 175 }); 134 - await Promise.all([ 135 - //Optimistically put these in! 136 - supabaseServerClient.from("documents_in_publications").upsert({ 137 - publication: record.publication, 176 + 177 + if (publication_uri) { 178 + // Publishing to a publication - update both tables 179 + await Promise.all([ 180 + supabaseServerClient.from("documents_in_publications").upsert({ 181 + publication: publication_uri, 182 + document: result.uri, 183 + }), 184 + supabaseServerClient 185 + .from("leaflets_in_publications") 186 + .update({ 187 + doc: result.uri, 188 + }) 189 + .eq("leaflet", leaflet_id) 190 + .eq("publication", publication_uri), 191 + ]); 192 + } else { 193 + // Publishing standalone - update leaflets_to_documents 194 + await supabaseServerClient.from("leaflets_to_documents").upsert({ 195 + leaflet: leaflet_id, 138 196 document: result.uri, 139 - }), 140 - supabaseServerClient 141 - .from("leaflets_in_publications") 142 - .update({ 143 - doc: result.uri, 144 - }) 145 - .eq("leaflet", leaflet_id) 146 - .eq("publication", publication_uri), 147 - ]); 197 + title: title || "Untitled", 198 + description: description || "", 199 + }); 200 + 201 + // Heuristic: Remove title entities if this is the first time publishing standalone 202 + // (when entitiesToDelete is provided and there's no existing document) 203 + if (entitiesToDelete && entitiesToDelete.length > 0 && !existingDocUri) { 204 + await supabaseServerClient 205 + .from("entities") 206 + .delete() 207 + .in("id", entitiesToDelete); 208 + } 209 + } 148 210 149 211 return { rkey, record: JSON.parse(JSON.stringify(record)) }; 150 212 } ··· 169 231 170 232 let firstEntity = scan.eav(root_entity, "root/page")?.[0]; 171 233 if (!firstEntity) throw new Error("No root page"); 172 - let blocks = getBlocksWithTypeLocal(facts, firstEntity?.data.value); 173 - let b = await blocksToRecord(blocks, did); 174 - return { firstPageBlocks: b, pages }; 234 + 235 + // Check if the first page is a canvas or linear document 236 + let [pageType] = scan.eav(firstEntity.data.value, "page/type"); 237 + 238 + if (pageType?.data.value === "canvas") { 239 + // First page is a canvas 240 + let canvasBlocks = await canvasBlocksToRecord(firstEntity.data.value, did); 241 + pages.unshift({ 242 + id: firstEntity.data.value, 243 + blocks: canvasBlocks, 244 + type: "canvas", 245 + }); 246 + } else { 247 + // First page is a linear document 248 + let blocks = getBlocksWithTypeLocal(facts, firstEntity?.data.value); 249 + let b = await blocksToRecord(blocks, did); 250 + pages.unshift({ 251 + id: firstEntity.data.value, 252 + blocks: b, 253 + type: "doc", 254 + }); 255 + } 256 + 257 + return { pages }; 175 258 176 259 async function uploadImage(src: string) { 177 260 let data = await fetch(src); ··· 572 655 ? never 573 656 : T /* maybe literal, not the whole `string` */ 574 657 : T; /* not a string */ 658 + 659 + async function extractThemeFromFacts( 660 + facts: Fact<any>[], 661 + root_entity: string, 662 + agent: AtpBaseClient, 663 + ): Promise<PubLeafletPublication.Theme | undefined> { 664 + let scan = scanIndexLocal(facts); 665 + let pageBackground = scan.eav(root_entity, "theme/page-background")?.[0]?.data 666 + .value; 667 + let cardBackground = scan.eav(root_entity, "theme/card-background")?.[0]?.data 668 + .value; 669 + let primary = scan.eav(root_entity, "theme/primary")?.[0]?.data.value; 670 + let accentBackground = scan.eav(root_entity, "theme/accent-background")?.[0] 671 + ?.data.value; 672 + let accentText = scan.eav(root_entity, "theme/accent-text")?.[0]?.data.value; 673 + let showPageBackground = !scan.eav( 674 + root_entity, 675 + "theme/card-border-hidden", 676 + )?.[0]?.data.value; 677 + let backgroundImage = scan.eav(root_entity, "theme/background-image")?.[0]; 678 + let backgroundImageRepeat = scan.eav( 679 + root_entity, 680 + "theme/background-image-repeat", 681 + )?.[0]; 682 + 683 + let theme: PubLeafletPublication.Theme = { 684 + showPageBackground: showPageBackground ?? true, 685 + }; 686 + 687 + if (pageBackground) 688 + theme.backgroundColor = ColorToRGBA(parseColor(`hsba(${pageBackground})`)); 689 + if (cardBackground) 690 + theme.pageBackground = ColorToRGBA(parseColor(`hsba(${cardBackground})`)); 691 + if (primary) theme.primary = ColorToRGB(parseColor(`hsba(${primary})`)); 692 + if (accentBackground) 693 + theme.accentBackground = ColorToRGB( 694 + parseColor(`hsba(${accentBackground})`), 695 + ); 696 + if (accentText) 697 + theme.accentText = ColorToRGB(parseColor(`hsba(${accentText})`)); 698 + 699 + // Upload background image if present 700 + if (backgroundImage?.data) { 701 + let imageData = await fetch(backgroundImage.data.src); 702 + if (imageData.status === 200) { 703 + let binary = await imageData.blob(); 704 + let blob = await agent.com.atproto.repo.uploadBlob(binary, { 705 + headers: { "Content-Type": binary.type }, 706 + }); 707 + 708 + theme.backgroundImage = { 709 + $type: "pub.leaflet.theme.backgroundImage", 710 + image: blob.data.blob, 711 + repeat: backgroundImageRepeat?.data.value ? true : false, 712 + ...(backgroundImageRepeat?.data.value && { 713 + width: backgroundImageRepeat.data.value, 714 + }), 715 + }; 716 + } 717 + } 718 + 719 + // Only return theme if at least one property is set 720 + if (Object.keys(theme).length > 1 || theme.showPageBackground !== true) { 721 + return theme; 722 + } 723 + 724 + return undefined; 725 + }
+1 -1
app/(home-pages)/discover/PubListing.tsx
··· 16 16 }, 17 17 ) => { 18 18 let record = props.record as PubLeafletPublication.Record; 19 - let theme = usePubTheme(record); 19 + let theme = usePubTheme(record.theme); 20 20 let backgroundImage = record?.theme?.backgroundImage?.image?.ref 21 21 ? blobRefToSrc( 22 22 record?.theme?.backgroundImage?.image?.ref,
+1 -2
app/(home-pages)/home/Actions/Actions.tsx
··· 1 1 "use client"; 2 2 import { ThemePopover } from "components/ThemeManager/ThemeSetter"; 3 3 import { CreateNewLeafletButton } from "./CreateNewButton"; 4 - import { HelpPopover } from "components/HelpPopover"; 4 + import { HelpButton } from "app/[leaflet_id]/actions/HelpButton"; 5 5 import { AccountSettings } from "./AccountSettings"; 6 6 import { useIdentityData } from "components/IdentityProvider"; 7 7 import { useReplicache } from "src/replicache"; ··· 18 18 ) : ( 19 19 <LoginActionButton /> 20 20 )} 21 - <HelpPopover /> 22 21 </> 23 22 ); 24 23 };
+18 -5
app/(home-pages)/home/HomeLayout.tsx
··· 30 30 PublicationBanner, 31 31 } from "./HomeEmpty/HomeEmpty"; 32 32 33 - type Leaflet = { 33 + export type Leaflet = { 34 34 added_at: string; 35 35 archived?: boolean | null; 36 36 token: PermissionToken & { ··· 38 38 GetLeafletDataReturnType["result"]["data"], 39 39 null 40 40 >["leaflets_in_publications"]; 41 + leaflets_to_documents?: Exclude< 42 + GetLeafletDataReturnType["result"]["data"], 43 + null 44 + >["leaflets_to_documents"]; 41 45 }; 42 46 }; 43 47 ··· 130 134 ...identity.permission_token_on_homepage.reduce( 131 135 (acc, tok) => { 132 136 let title = 133 - tok.permission_tokens.leaflets_in_publications[0]?.title; 137 + tok.permission_tokens.leaflets_in_publications[0]?.title || 138 + tok.permission_tokens.leaflets_to_documents[0]?.title; 134 139 if (title) acc[tok.permission_tokens.root_entity] = title; 135 140 return acc; 136 141 }, ··· 222 227 value={{ 223 228 ...leaflet, 224 229 leaflets_in_publications: leaflet.leaflets_in_publications || [], 230 + leaflets_to_documents: leaflet.leaflets_to_documents || [], 225 231 blocked_by_admin: null, 226 232 custom_domain_routes: [], 227 233 }} ··· 233 239 draftInPublication={ 234 240 leaflet.leaflets_in_publications?.[0]?.publication 235 241 } 236 - published={!!leaflet.leaflets_in_publications?.find((l) => l.doc)} 242 + published={ 243 + !!leaflet.leaflets_in_publications?.find((l) => l.doc) || 244 + !!leaflet.leaflets_to_documents?.find((l) => !!l.documents) 245 + } 237 246 publishedAt={ 238 247 leaflet.leaflets_in_publications?.find((l) => l.doc)?.documents 239 - ?.indexed_at 248 + ?.indexed_at || 249 + leaflet.leaflets_to_documents?.find((l) => !!l.documents) 250 + ?.documents?.indexed_at 240 251 } 241 252 document_uri={ 242 253 leaflet.leaflets_in_publications?.find((l) => l.doc)?.documents ··· 292 303 293 304 let filteredLeaflets = sortedLeaflets.filter( 294 305 ({ token: leaflet, archived: archived }) => { 295 - let published = !!leaflet.leaflets_in_publications?.find((l) => l.doc); 306 + let published = 307 + !!leaflet.leaflets_in_publications?.find((l) => l.doc) || 308 + !!leaflet.leaflets_to_documents?.find((l) => l.document); 296 309 let drafts = !!leaflet.leaflets_in_publications?.length && !published; 297 310 let docs = !leaflet.leaflets_in_publications?.length && !archived; 298 311 // If no filters are active, show all
+1 -4
app/(home-pages)/home/LeafletList/LeafletInfo.tsx
··· 1 1 "use client"; 2 2 import { PermissionToken, useEntity } from "src/replicache"; 3 3 import { LeafletOptions } from "./LeafletOptions"; 4 - import Link from "next/link"; 5 - import { use, useState } from "react"; 4 + import { useState } from "react"; 6 5 import { timeAgo } from "src/utils/timeAgo"; 7 - import { usePublishLink } from "components/ShareOptions"; 8 - import { Separator } from "components/Layout"; 9 6 import { usePageTitle } from "components/utils/UpdateLeafletTitle"; 10 7 11 8 export const LeafletInfo = (props: {
+1 -1
app/(home-pages)/home/LeafletList/LeafletOptions.tsx
··· 17 17 deletePost, 18 18 unpublishPost, 19 19 } from "app/lish/[did]/[publication]/dashboard/deletePost"; 20 - import { ShareButton } from "components/ShareOptions"; 21 20 import { ShareSmall } from "components/Icons/ShareSmall"; 22 21 import { HideSmall } from "components/Icons/HideSmall"; 23 22 import { hideDoc } from "../storage"; ··· 31 30 usePublicationData, 32 31 mutatePublicationData, 33 32 } from "app/lish/[did]/[publication]/dashboard/PublicationSWRProvider"; 33 + import { ShareButton } from "app/[leaflet_id]/actions/ShareOptions"; 34 34 35 35 export const LeafletOptions = (props: { 36 36 leaflet: PermissionToken;
+2 -1
app/(home-pages)/home/page.tsx
··· 29 29 ...auth_res?.permission_token_on_homepage.reduce( 30 30 (acc, tok) => { 31 31 let title = 32 - tok.permission_tokens.leaflets_in_publications[0]?.title; 32 + tok.permission_tokens.leaflets_in_publications[0]?.title || 33 + tok.permission_tokens.leaflets_to_documents[0]?.title; 33 34 if (title) acc[tok.permission_tokens.root_entity] = title; 34 35 return acc; 35 36 },
+116
app/(home-pages)/looseleafs/LooseleafsLayout.tsx
··· 1 + "use client"; 2 + import { DashboardLayout } from "components/PageLayouts/DashboardLayout"; 3 + import { useCardBorderHidden } from "components/Pages/useCardBorderHidden"; 4 + import { useState } from "react"; 5 + import { useDebouncedEffect } from "src/hooks/useDebouncedEffect"; 6 + import { Fact, PermissionToken } from "src/replicache"; 7 + import { Attribute } from "src/replicache/attributes"; 8 + import { Actions } from "../home/Actions/Actions"; 9 + import { callRPC } from "app/api/rpc/client"; 10 + import { useIdentityData } from "components/IdentityProvider"; 11 + import useSWR from "swr"; 12 + import { getHomeDocs } from "../home/storage"; 13 + import { Leaflet, LeafletList } from "../home/HomeLayout"; 14 + 15 + export const LooseleafsLayout = (props: { 16 + entityID: string | null; 17 + titles: { [root_entity: string]: string }; 18 + initialFacts: { 19 + [root_entity: string]: Fact<Attribute>[]; 20 + }; 21 + }) => { 22 + let [searchValue, setSearchValue] = useState(""); 23 + let [debouncedSearchValue, setDebouncedSearchValue] = useState(""); 24 + 25 + useDebouncedEffect( 26 + () => { 27 + setDebouncedSearchValue(searchValue); 28 + }, 29 + 200, 30 + [searchValue], 31 + ); 32 + 33 + let cardBorderHidden = !!useCardBorderHidden(props.entityID); 34 + return ( 35 + <DashboardLayout 36 + id="looseleafs" 37 + cardBorderHidden={cardBorderHidden} 38 + currentPage="looseleafs" 39 + defaultTab="home" 40 + actions={<Actions />} 41 + tabs={{ 42 + home: { 43 + controls: null, 44 + content: ( 45 + <LooseleafList 46 + titles={props.titles} 47 + initialFacts={props.initialFacts} 48 + cardBorderHidden={cardBorderHidden} 49 + searchValue={debouncedSearchValue} 50 + /> 51 + ), 52 + }, 53 + }} 54 + /> 55 + ); 56 + }; 57 + 58 + export const LooseleafList = (props: { 59 + titles: { [root_entity: string]: string }; 60 + initialFacts: { 61 + [root_entity: string]: Fact<Attribute>[]; 62 + }; 63 + searchValue: string; 64 + cardBorderHidden: boolean; 65 + }) => { 66 + let { identity } = useIdentityData(); 67 + let { data: initialFacts } = useSWR( 68 + "home-leaflet-data", 69 + async () => { 70 + if (identity) { 71 + let { result } = await callRPC("getFactsFromHomeLeaflets", { 72 + tokens: identity.permission_token_on_homepage.map( 73 + (ptrh) => ptrh.permission_tokens.root_entity, 74 + ), 75 + }); 76 + let titles = { 77 + ...result.titles, 78 + ...identity.permission_token_on_homepage.reduce( 79 + (acc, tok) => { 80 + let title = 81 + tok.permission_tokens.leaflets_in_publications[0]?.title || 82 + tok.permission_tokens.leaflets_to_documents[0]?.title; 83 + if (title) acc[tok.permission_tokens.root_entity] = title; 84 + return acc; 85 + }, 86 + {} as { [k: string]: string }, 87 + ), 88 + }; 89 + return { ...result, titles }; 90 + } 91 + }, 92 + { fallbackData: { facts: props.initialFacts, titles: props.titles } }, 93 + ); 94 + 95 + let leaflets: Leaflet[] = identity 96 + ? identity.permission_token_on_homepage 97 + .filter( 98 + (ptoh) => ptoh.permission_tokens.leaflets_to_documents.length > 0, 99 + ) 100 + .map((ptoh) => ({ 101 + added_at: ptoh.created_at, 102 + token: ptoh.permission_tokens as PermissionToken, 103 + })) 104 + : []; 105 + return ( 106 + <LeafletList 107 + defaultDisplay="list" 108 + searchValue={props.searchValue} 109 + leaflets={leaflets} 110 + titles={initialFacts?.titles || {}} 111 + cardBorderHidden={props.cardBorderHidden} 112 + initialFacts={initialFacts?.facts || {}} 113 + showPreview 114 + /> 115 + ); 116 + };
+47
app/(home-pages)/looseleafs/page.tsx
··· 1 + import { getIdentityData } from "actions/getIdentityData"; 2 + import { DashboardLayout } from "components/PageLayouts/DashboardLayout"; 3 + import { Actions } from "../home/Actions/Actions"; 4 + import { Fact } from "src/replicache"; 5 + import { Attribute } from "src/replicache/attributes"; 6 + import { getFactsFromHomeLeaflets } from "app/api/rpc/[command]/getFactsFromHomeLeaflets"; 7 + import { supabaseServerClient } from "supabase/serverClient"; 8 + import { LooseleafsLayout } from "./LooseleafsLayout"; 9 + 10 + export default async function Home() { 11 + let auth_res = await getIdentityData(); 12 + 13 + let [allLeafletFacts] = await Promise.all([ 14 + auth_res 15 + ? getFactsFromHomeLeaflets.handler( 16 + { 17 + tokens: auth_res.permission_token_on_homepage.map( 18 + (r) => r.permission_tokens.root_entity, 19 + ), 20 + }, 21 + { supabase: supabaseServerClient }, 22 + ) 23 + : undefined, 24 + ]); 25 + 26 + let home_docs_initialFacts = allLeafletFacts?.result || {}; 27 + 28 + return ( 29 + <LooseleafsLayout 30 + entityID={auth_res?.home_leaflet?.root_entity || null} 31 + titles={{ 32 + ...home_docs_initialFacts.titles, 33 + ...auth_res?.permission_token_on_homepage.reduce( 34 + (acc, tok) => { 35 + let title = 36 + tok.permission_tokens.leaflets_in_publications[0]?.title || 37 + tok.permission_tokens.leaflets_to_documents[0]?.title; 38 + if (title) acc[tok.permission_tokens.root_entity] = title; 39 + return acc; 40 + }, 41 + {} as { [k: string]: string }, 42 + ), 43 + }} 44 + initialFacts={home_docs_initialFacts.facts || {}} 45 + /> 46 + ); 47 + }
+1 -1
app/(home-pages)/reader/ReaderContent.tsx
··· 102 102 let postRecord = props.documents.data as PubLeafletDocument.Record; 103 103 let postUri = new AtUri(props.documents.uri); 104 104 105 - let theme = usePubTheme(pubRecord); 105 + let theme = usePubTheme(pubRecord?.theme); 106 106 let backgroundImage = pubRecord?.theme?.backgroundImage?.image?.ref 107 107 ? blobRefToSrc( 108 108 pubRecord?.theme?.backgroundImage?.image?.ref,
-98
app/[leaflet_id]/Actions.tsx
··· 1 - import { publishToPublication } from "actions/publishToPublication"; 2 - import { 3 - getBasePublicationURL, 4 - getPublicationURL, 5 - } from "app/lish/createPub/getPublicationURL"; 6 - import { ActionButton } from "components/ActionBar/ActionButton"; 7 - import { GoBackSmall } from "components/Icons/GoBackSmall"; 8 - import { PublishSmall } from "components/Icons/PublishSmall"; 9 - import { useLeafletPublicationData } from "components/PageSWRDataProvider"; 10 - import { SpeedyLink } from "components/SpeedyLink"; 11 - import { useToaster } from "components/Toast"; 12 - import { DotLoader } from "components/utils/DotLoader"; 13 - import { useParams, useRouter } from "next/navigation"; 14 - import { useState } from "react"; 15 - import { useReplicache } from "src/replicache"; 16 - import { Json } from "supabase/database.types"; 17 - 18 - export const BackToPubButton = (props: { 19 - publication: { 20 - identity_did: string; 21 - indexed_at: string; 22 - name: string; 23 - record: Json; 24 - uri: string; 25 - }; 26 - }) => { 27 - return ( 28 - <SpeedyLink 29 - href={`${getBasePublicationURL(props.publication)}/dashboard`} 30 - className="hover:no-underline!" 31 - > 32 - <ActionButton 33 - icon={<GoBackSmall className="shrink-0" />} 34 - label="To Pub" 35 - /> 36 - </SpeedyLink> 37 - ); 38 - }; 39 - 40 - export const PublishButton = () => { 41 - let { data: pub } = useLeafletPublicationData(); 42 - let params = useParams(); 43 - let router = useRouter(); 44 - if (!pub?.doc) 45 - return ( 46 - <ActionButton 47 - primary 48 - icon={<PublishSmall className="shrink-0" />} 49 - label={"Publish!"} 50 - onClick={() => { 51 - router.push(`/${params.leaflet_id}/publish`); 52 - }} 53 - /> 54 - ); 55 - 56 - return <UpdateButton />; 57 - }; 58 - 59 - const UpdateButton = () => { 60 - let [isLoading, setIsLoading] = useState(false); 61 - let { data: pub, mutate } = useLeafletPublicationData(); 62 - let { permission_token, rootEntity } = useReplicache(); 63 - let toaster = useToaster(); 64 - 65 - return ( 66 - <ActionButton 67 - primary 68 - icon={<PublishSmall className="shrink-0" />} 69 - label={isLoading ? <DotLoader /> : "Update!"} 70 - onClick={async () => { 71 - if (!pub || !pub.publications) return; 72 - setIsLoading(true); 73 - let doc = await publishToPublication({ 74 - root_entity: rootEntity, 75 - publication_uri: pub.publications.uri, 76 - leaflet_id: permission_token.id, 77 - title: pub.title, 78 - description: pub.description, 79 - }); 80 - setIsLoading(false); 81 - mutate(); 82 - toaster({ 83 - content: ( 84 - <div> 85 - {pub.doc ? "Updated! " : "Published! "} 86 - <SpeedyLink 87 - href={`${getPublicationURL(pub.publications)}/${doc?.rkey}`} 88 - > 89 - link 90 - </SpeedyLink> 91 - </div> 92 - ), 93 - type: "success", 94 - }); 95 - }} 96 - /> 97 - ); 98 - };
+16 -20
app/[leaflet_id]/Footer.tsx
··· 4 4 import { Media } from "components/Media"; 5 5 import { ThemePopover } from "components/ThemeManager/ThemeSetter"; 6 6 import { Toolbar } from "components/Toolbar"; 7 - import { ShareOptions } from "components/ShareOptions"; 8 - import { HomeButton } from "components/HomeButton"; 7 + import { ShareOptions } from "app/[leaflet_id]/actions/ShareOptions"; 8 + import { HomeButton } from "app/[leaflet_id]/actions/HomeButton"; 9 + import { PublishButton } from "./actions/PublishButton"; 9 10 import { useEntitySetContext } from "components/EntitySetProvider"; 10 - import { HelpPopover } from "components/HelpPopover"; 11 + import { HelpButton } from "app/[leaflet_id]/actions/HelpButton"; 11 12 import { Watermark } from "components/Watermark"; 12 - import { BackToPubButton, PublishButton } from "./Actions"; 13 + import { BackToPubButton } from "./actions/BackToPubButton"; 13 14 import { useLeafletPublicationData } from "components/PageSWRDataProvider"; 14 15 import { useIdentityData } from "components/IdentityProvider"; 15 16 ··· 36 37 /> 37 38 </div> 38 39 ) : entity_set.permissions.write ? ( 39 - pub?.publications && 40 - identity?.atp_did && 41 - pub.publications.identity_did === identity.atp_did ? ( 42 - <ActionFooter> 40 + <ActionFooter> 41 + {pub?.publications && 42 + identity?.atp_did && 43 + pub.publications.identity_did === identity.atp_did ? ( 43 44 <BackToPubButton publication={pub.publications} /> 44 - <PublishButton /> 45 - <ShareOptions /> 46 - <HelpPopover /> 47 - <ThemePopover entityID={props.entityID} /> 48 - </ActionFooter> 49 - ) : ( 50 - <ActionFooter> 45 + ) : ( 51 46 <HomeButton /> 52 - <ShareOptions /> 53 - <HelpPopover /> 54 - <ThemePopover entityID={props.entityID} /> 55 - </ActionFooter> 56 - ) 47 + )} 48 + 49 + <PublishButton entityID={props.entityID} /> 50 + <ShareOptions /> 51 + <ThemePopover entityID={props.entityID} /> 52 + </ActionFooter> 57 53 ) : ( 58 54 <div className="pb-2 px-2 z-10 flex justify-end"> 59 55 <Watermark mobile />
+12 -28
app/[leaflet_id]/Sidebar.tsx
··· 1 1 "use client"; 2 - import { ActionButton } from "components/ActionBar/ActionButton"; 3 2 import { Sidebar } from "components/ActionBar/Sidebar"; 4 3 import { useEntitySetContext } from "components/EntitySetProvider"; 5 - import { HelpPopover } from "components/HelpPopover"; 6 - import { HomeButton } from "components/HomeButton"; 4 + import { HelpButton } from "app/[leaflet_id]/actions/HelpButton"; 5 + import { HomeButton } from "app/[leaflet_id]/actions/HomeButton"; 7 6 import { Media } from "components/Media"; 8 7 import { useLeafletPublicationData } from "components/PageSWRDataProvider"; 9 - import { ShareOptions } from "components/ShareOptions"; 8 + import { ShareOptions } from "app/[leaflet_id]/actions/ShareOptions"; 10 9 import { ThemePopover } from "components/ThemeManager/ThemeSetter"; 10 + import { PublishButton } from "./actions/PublishButton"; 11 11 import { Watermark } from "components/Watermark"; 12 - import { useUIState } from "src/useUIState"; 13 - import { BackToPubButton, PublishButton } from "./Actions"; 12 + import { BackToPubButton } from "./actions/BackToPubButton"; 14 13 import { useIdentityData } from "components/IdentityProvider"; 15 14 import { useReplicache } from "src/replicache"; 16 15 ··· 29 28 <div className="sidebarContainer flex flex-col justify-end h-full w-16 relative"> 30 29 {entity_set.permissions.write && ( 31 30 <Sidebar> 31 + <PublishButton entityID={rootEntity} /> 32 + <ShareOptions /> 33 + <ThemePopover entityID={rootEntity} /> 34 + <HelpButton /> 35 + <hr className="text-border" /> 32 36 {pub?.publications && 33 37 identity?.atp_did && 34 38 pub.publications.identity_did === identity.atp_did ? ( 35 - <> 36 - <PublishButton /> 37 - <ShareOptions /> 38 - <ThemePopover entityID={rootEntity} /> 39 - <HelpPopover /> 40 - <hr className="text-border" /> 41 - <BackToPubButton publication={pub.publications} /> 42 - </> 39 + <BackToPubButton publication={pub.publications} /> 43 40 ) : ( 44 - <> 45 - <ShareOptions /> 46 - <ThemePopover entityID={rootEntity} /> 47 - <HelpPopover /> 48 - <hr className="text-border" /> 49 - <HomeButton /> 50 - </> 41 + <HomeButton /> 51 42 )} 52 43 </Sidebar> 53 44 )} ··· 59 50 </Media> 60 51 ); 61 52 } 62 - 63 - const blurPage = () => { 64 - useUIState.setState(() => ({ 65 - focusedEntity: null, 66 - selectedBlocks: [], 67 - })); 68 - };
+27
app/[leaflet_id]/actions/BackToPubButton.tsx
··· 1 + import { getBasePublicationURL } from "app/lish/createPub/getPublicationURL"; 2 + import { ActionButton } from "components/ActionBar/ActionButton"; 3 + import { GoBackSmall } from "components/Icons/GoBackSmall"; 4 + import { SpeedyLink } from "components/SpeedyLink"; 5 + import { Json } from "supabase/database.types"; 6 + 7 + export const BackToPubButton = (props: { 8 + publication: { 9 + identity_did: string; 10 + indexed_at: string; 11 + name: string; 12 + record: Json; 13 + uri: string; 14 + }; 15 + }) => { 16 + return ( 17 + <SpeedyLink 18 + href={`${getBasePublicationURL(props.publication)}/dashboard`} 19 + className="hover:no-underline!" 20 + > 21 + <ActionButton 22 + icon={<GoBackSmall className="shrink-0" />} 23 + label="To Pub" 24 + /> 25 + </SpeedyLink> 26 + ); 27 + };
+427
app/[leaflet_id]/actions/PublishButton.tsx
··· 1 + "use client"; 2 + import { publishToPublication } from "actions/publishToPublication"; 3 + import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; 4 + import { ActionButton } from "components/ActionBar/ActionButton"; 5 + import { 6 + PubIcon, 7 + PubListEmptyContent, 8 + PubListEmptyIllo, 9 + } from "components/ActionBar/Publications"; 10 + import { ButtonPrimary, ButtonTertiary } from "components/Buttons"; 11 + import { AddSmall } from "components/Icons/AddSmall"; 12 + import { LooseLeafSmall } from "components/Icons/LooseleafSmall"; 13 + import { PublishSmall } from "components/Icons/PublishSmall"; 14 + import { useIdentityData } from "components/IdentityProvider"; 15 + import { InputWithLabel } from "components/Input"; 16 + import { Menu, MenuItem } from "components/Layout"; 17 + import { 18 + useLeafletDomains, 19 + useLeafletPublicationData, 20 + } from "components/PageSWRDataProvider"; 21 + import { Popover } from "components/Popover"; 22 + import { SpeedyLink } from "components/SpeedyLink"; 23 + import { useToaster } from "components/Toast"; 24 + import { DotLoader } from "components/utils/DotLoader"; 25 + import { PubLeafletPublication } from "lexicons/api"; 26 + import { useParams, useRouter, useSearchParams } from "next/navigation"; 27 + import { useState, useMemo } from "react"; 28 + import { useIsMobile } from "src/hooks/isMobile"; 29 + import { useReplicache, useEntity } from "src/replicache"; 30 + import { Json } from "supabase/database.types"; 31 + import { 32 + useBlocks, 33 + useCanvasBlocksWithType, 34 + } from "src/hooks/queries/useBlocks"; 35 + import * as Y from "yjs"; 36 + import * as base64 from "base64-js"; 37 + import { YJSFragmentToString } from "components/Blocks/TextBlock/RenderYJSFragment"; 38 + import { BlueskyLogin } from "app/login/LoginForm"; 39 + import { moveLeafletToPublication } from "actions/publications/moveLeafletToPublication"; 40 + import { AddTiny } from "components/Icons/AddTiny"; 41 + 42 + export const PublishButton = (props: { entityID: string }) => { 43 + let { data: pub } = useLeafletPublicationData(); 44 + let params = useParams(); 45 + let router = useRouter(); 46 + 47 + if (!pub) return <PublishToPublicationButton entityID={props.entityID} />; 48 + if (!pub?.doc) 49 + return ( 50 + <ActionButton 51 + primary 52 + icon={<PublishSmall className="shrink-0" />} 53 + label={"Publish!"} 54 + onClick={() => { 55 + router.push(`/${params.leaflet_id}/publish`); 56 + }} 57 + /> 58 + ); 59 + 60 + return <UpdateButton />; 61 + }; 62 + 63 + const UpdateButton = () => { 64 + let [isLoading, setIsLoading] = useState(false); 65 + let { data: pub, mutate } = useLeafletPublicationData(); 66 + let { permission_token, rootEntity } = useReplicache(); 67 + let { identity } = useIdentityData(); 68 + let toaster = useToaster(); 69 + 70 + return ( 71 + <ActionButton 72 + primary 73 + icon={<PublishSmall className="shrink-0" />} 74 + label={isLoading ? <DotLoader /> : "Update!"} 75 + onClick={async () => { 76 + if (!pub) return; 77 + setIsLoading(true); 78 + let doc = await publishToPublication({ 79 + root_entity: rootEntity, 80 + publication_uri: pub.publications?.uri, 81 + leaflet_id: permission_token.id, 82 + title: pub.title, 83 + description: pub.description, 84 + }); 85 + setIsLoading(false); 86 + mutate(); 87 + 88 + // Generate URL based on whether it's in a publication or standalone 89 + let docUrl = pub.publications 90 + ? `${getPublicationURL(pub.publications)}/${doc?.rkey}` 91 + : `https://leaflet.pub/p/${identity?.atp_did}/${doc?.rkey}`; 92 + 93 + toaster({ 94 + content: ( 95 + <div> 96 + {pub.doc ? "Updated! " : "Published! "} 97 + <SpeedyLink href={docUrl}>link</SpeedyLink> 98 + </div> 99 + ), 100 + type: "success", 101 + }); 102 + }} 103 + /> 104 + ); 105 + }; 106 + 107 + const PublishToPublicationButton = (props: { entityID: string }) => { 108 + let { identity } = useIdentityData(); 109 + let { permission_token } = useReplicache(); 110 + let query = useSearchParams(); 111 + console.log(query.get("publish")); 112 + let [open, setOpen] = useState(query.get("publish") !== null); 113 + 114 + let isMobile = useIsMobile(); 115 + identity && identity.atp_did && identity.publications.length > 0; 116 + let [selectedPub, setSelectedPub] = useState<string | undefined>(undefined); 117 + let router = useRouter(); 118 + let { title, entitiesToDelete } = useTitle(props.entityID); 119 + let [description, setDescription] = useState(""); 120 + 121 + return ( 122 + <Popover 123 + asChild 124 + open={open} 125 + onOpenChange={(o) => setOpen(o)} 126 + side={isMobile ? "top" : "right"} 127 + align={isMobile ? "center" : "start"} 128 + className="sm:max-w-sm w-[1000px]" 129 + trigger={ 130 + <ActionButton 131 + primary 132 + icon={<PublishSmall className="shrink-0" />} 133 + label={"Publish on ATP"} 134 + /> 135 + } 136 + > 137 + {!identity || !identity.atp_did ? ( 138 + <div className="-mx-2 -my-1"> 139 + <div 140 + className={`bg-[var(--accent-light)] w-full rounded-md flex flex-col text-center justify-center p-2 pb-4 text-sm`} 141 + > 142 + <div className="mx-auto pt-2 scale-90"> 143 + <PubListEmptyIllo /> 144 + </div> 145 + <div className="pt-1 font-bold">Publish on AT Proto</div> 146 + { 147 + <> 148 + <div className="pb-2 text-secondary text-xs"> 149 + Link a Bluesky account to start <br /> a publishing on AT 150 + Proto 151 + </div> 152 + 153 + <BlueskyLogin 154 + compact 155 + redirectRoute={`/${permission_token.id}?publish`} 156 + /> 157 + </> 158 + } 159 + </div> 160 + </div> 161 + ) : ( 162 + <div className="flex flex-col"> 163 + <PostDetailsForm 164 + title={title} 165 + description={description} 166 + setDescription={setDescription} 167 + /> 168 + <hr className="border-border-light my-3" /> 169 + <div> 170 + <PubSelector 171 + publications={identity.publications} 172 + selectedPub={selectedPub} 173 + setSelectedPub={setSelectedPub} 174 + /> 175 + </div> 176 + <hr className="border-border-light mt-3 mb-2" /> 177 + 178 + <div className="flex gap-2 items-center place-self-end"> 179 + {selectedPub !== "looseleaf" && selectedPub && ( 180 + <SaveAsDraftButton 181 + selectedPub={selectedPub} 182 + leafletId={permission_token.id} 183 + metadata={{ title: title, description }} 184 + entitiesToDelete={entitiesToDelete} 185 + /> 186 + )} 187 + <ButtonPrimary 188 + disabled={selectedPub === undefined} 189 + onClick={async (e) => { 190 + if (!selectedPub) return; 191 + e.preventDefault(); 192 + if (selectedPub === "create") return; 193 + 194 + // For looseleaf, navigate without publication_uri 195 + if (selectedPub === "looseleaf") { 196 + router.push( 197 + `${permission_token.id}/publish?title=${encodeURIComponent(title)}&description=${encodeURIComponent(description)}&entitiesToDelete=${encodeURIComponent(JSON.stringify(entitiesToDelete))}`, 198 + ); 199 + } else { 200 + router.push( 201 + `${permission_token.id}/publish?publication_uri=${encodeURIComponent(selectedPub)}&title=${encodeURIComponent(title)}&description=${encodeURIComponent(description)}&entitiesToDelete=${encodeURIComponent(JSON.stringify(entitiesToDelete))}`, 202 + ); 203 + } 204 + }} 205 + > 206 + Next{selectedPub === "create" && ": Create Pub!"} 207 + </ButtonPrimary> 208 + </div> 209 + </div> 210 + )} 211 + </Popover> 212 + ); 213 + }; 214 + 215 + const SaveAsDraftButton = (props: { 216 + selectedPub: string | undefined; 217 + leafletId: string; 218 + metadata: { title: string; description: string }; 219 + entitiesToDelete: string[]; 220 + }) => { 221 + let { mutate } = useLeafletPublicationData(); 222 + let { rep } = useReplicache(); 223 + let [isLoading, setIsLoading] = useState(false); 224 + 225 + return ( 226 + <ButtonTertiary 227 + onClick={async (e) => { 228 + if (!props.selectedPub) return; 229 + if (props.selectedPub === "create") return; 230 + e.preventDefault(); 231 + setIsLoading(true); 232 + await moveLeafletToPublication( 233 + props.leafletId, 234 + props.selectedPub, 235 + props.metadata, 236 + props.entitiesToDelete, 237 + ); 238 + await Promise.all([rep?.pull(), mutate()]); 239 + setIsLoading(false); 240 + }} 241 + > 242 + {isLoading ? <DotLoader /> : "Save as Draft"} 243 + </ButtonTertiary> 244 + ); 245 + }; 246 + 247 + const PostDetailsForm = (props: { 248 + title: string; 249 + description: string; 250 + setDescription: (d: string) => void; 251 + }) => { 252 + return ( 253 + <div className=" flex flex-col gap-1"> 254 + <div className="text-sm text-tertiary">Post Details</div> 255 + <div className="flex flex-col gap-2"> 256 + <InputWithLabel label="Title" value={props.title} disabled /> 257 + <InputWithLabel 258 + label="Description (optional)" 259 + textarea 260 + value={props.description} 261 + className="h-[4lh]" 262 + onChange={(e) => props.setDescription(e.currentTarget.value)} 263 + /> 264 + </div> 265 + </div> 266 + ); 267 + }; 268 + 269 + const PubSelector = (props: { 270 + selectedPub: string | undefined; 271 + setSelectedPub: (s: string) => void; 272 + publications: { 273 + identity_did: string; 274 + indexed_at: string; 275 + name: string; 276 + record: Json | null; 277 + uri: string; 278 + }[]; 279 + }) => { 280 + // HEY STILL TO DO 281 + // test out logged out, logged in but no pubs, and pubbed up flows 282 + 283 + return ( 284 + <div className="flex flex-col gap-1"> 285 + <div className="text-sm text-tertiary">Publish to…</div> 286 + {props.publications.length === 0 || props.publications === undefined ? ( 287 + <div className="flex flex-col gap-1"> 288 + <div className="flex gap-2 menuItem"> 289 + <LooseLeafSmall className="shrink-0" /> 290 + <div className="flex flex-col leading-snug"> 291 + <div className="text-secondary font-bold"> 292 + Publish as Looseleaf 293 + </div> 294 + <div className="text-tertiary text-sm font-normal"> 295 + Publish this as a one off doc to AT Proto 296 + </div> 297 + </div> 298 + </div> 299 + <div className="flex gap-2 px-2 py-1 "> 300 + <PublishSmall className="shrink-0 text-border" /> 301 + <div className="flex flex-col leading-snug"> 302 + <div className="text-border font-bold"> 303 + Publish to Publication 304 + </div> 305 + <div className="text-border text-sm font-normal"> 306 + Publish your writing to a blog on AT Proto 307 + </div> 308 + <hr className="my-2 drashed border-border-light border-dashed" /> 309 + <div className="text-tertiary text-sm font-normal "> 310 + You don't have any Publications yet.{" "} 311 + <a target="_blank" href="/lish/createPub"> 312 + Create one 313 + </a>{" "} 314 + to get started! 315 + </div> 316 + </div> 317 + </div> 318 + </div> 319 + ) : ( 320 + <div className="flex flex-col gap-1"> 321 + <PubOption 322 + selected={props.selectedPub === "looseleaf"} 323 + onSelect={() => props.setSelectedPub("looseleaf")} 324 + > 325 + <LooseLeafSmall /> 326 + Publish as Looseleaf 327 + </PubOption> 328 + <hr className="border-border-light border-dashed " /> 329 + {props.publications.map((p) => { 330 + let pubRecord = p.record as PubLeafletPublication.Record; 331 + return ( 332 + <PubOption 333 + key={p.uri} 334 + selected={props.selectedPub === p.uri} 335 + onSelect={() => props.setSelectedPub(p.uri)} 336 + > 337 + <> 338 + <PubIcon record={pubRecord} uri={p.uri} /> 339 + {p.name} 340 + </> 341 + </PubOption> 342 + ); 343 + })} 344 + <div className="flex items-center px-2 py-1 text-accent-contrast gap-2"> 345 + <AddTiny className="m-1 shrink-0" /> 346 + 347 + <a target="_blank" href="/lish/createPub"> 348 + Start a new Publication 349 + </a> 350 + </div> 351 + </div> 352 + )} 353 + </div> 354 + ); 355 + }; 356 + 357 + const PubOption = (props: { 358 + selected: boolean; 359 + onSelect: () => void; 360 + children: React.ReactNode; 361 + }) => { 362 + return ( 363 + <button 364 + className={`flex gap-2 menuItem font-bold text-secondary ${props.selected && "bg-[var(--accent-light)]! outline! outline-offset-1! outline-accent-contrast!"}`} 365 + onClick={() => { 366 + props.onSelect(); 367 + }} 368 + > 369 + {props.children} 370 + </button> 371 + ); 372 + }; 373 + 374 + let useTitle = (entityID: string) => { 375 + let rootPage = useEntity(entityID, "root/page")[0].data.value; 376 + let canvasBlocks = useCanvasBlocksWithType(rootPage).filter( 377 + (b) => b.type === "text" || b.type === "heading", 378 + ); 379 + let blocks = useBlocks(rootPage).filter( 380 + (b) => b.type === "text" || b.type === "heading", 381 + ); 382 + let firstBlock = canvasBlocks[0] || blocks[0]; 383 + 384 + let firstBlockText = useEntity(firstBlock?.value, "block/text")?.data.value; 385 + 386 + const leafletTitle = useMemo(() => { 387 + if (!firstBlockText) return "Untitled"; 388 + let doc = new Y.Doc(); 389 + const update = base64.toByteArray(firstBlockText); 390 + Y.applyUpdate(doc, update); 391 + let nodes = doc.getXmlElement("prosemirror").toArray(); 392 + return YJSFragmentToString(nodes[0]) || "Untitled"; 393 + }, [firstBlockText]); 394 + 395 + // Only handle second block logic for linear documents, not canvas 396 + let isCanvas = canvasBlocks.length > 0; 397 + let secondBlock = !isCanvas ? blocks[1] : undefined; 398 + let secondBlockTextValue = useEntity(secondBlock?.value || null, "block/text") 399 + ?.data.value; 400 + const secondBlockText = useMemo(() => { 401 + if (!secondBlockTextValue) return ""; 402 + let doc = new Y.Doc(); 403 + const update = base64.toByteArray(secondBlockTextValue); 404 + Y.applyUpdate(doc, update); 405 + let nodes = doc.getXmlElement("prosemirror").toArray(); 406 + return YJSFragmentToString(nodes[0]) || ""; 407 + }, [secondBlockTextValue]); 408 + 409 + let entitiesToDelete = useMemo(() => { 410 + let etod: string[] = []; 411 + // Only delete first block if it's a heading type 412 + if (firstBlock?.type === "heading") { 413 + etod.push(firstBlock.value); 414 + } 415 + // Delete second block if it's empty text (only for linear documents) 416 + if ( 417 + !isCanvas && 418 + secondBlockText.trim() === "" && 419 + secondBlock?.type === "text" 420 + ) { 421 + etod.push(secondBlock.value); 422 + } 423 + return etod; 424 + }, [firstBlock, secondBlockText, secondBlock, isCanvas]); 425 + 426 + return { title: leafletTitle, entitiesToDelete }; 427 + };
+2 -5
app/[leaflet_id]/page.tsx
··· 13 13 import { supabaseServerClient } from "supabase/serverClient"; 14 14 import { get_leaflet_data } from "app/api/rpc/[command]/get_leaflet_data"; 15 15 import { NotFoundLayout } from "components/PageLayouts/NotFoundLayout"; 16 + import { getPublicationMetadataFromLeafletData } from "src/utils/getPublicationMetadataFromLeafletData"; 16 17 17 18 export const preferredRegion = ["sfo1"]; 18 19 export const dynamic = "force-dynamic"; ··· 70 71 ); 71 72 let rootEntity = res.data?.root_entity; 72 73 if (!rootEntity || !res.data) return { title: "Leaflet not found" }; 73 - let publication_data = 74 - res.data?.leaflets_in_publications?.[0] || 75 - res.data?.permission_token_rights[0].entity_sets?.permission_tokens?.find( 76 - (p) => p.leaflets_in_publications.length, 77 - )?.leaflets_in_publications?.[0]; 74 + let publication_data = getPublicationMetadataFromLeafletData(res.data); 78 75 if (publication_data) { 79 76 return { 80 77 title: publication_data.title || "Untitled",
+68 -16
app/[leaflet_id]/publish/PublishPost.tsx
··· 18 18 editorStateToFacetedText, 19 19 } from "./BskyPostEditorProsemirror"; 20 20 import { EditorState } from "prosemirror-state"; 21 + import { LooseLeafSmall } from "components/Icons/LooseleafSmall"; 22 + import { PubIcon } from "components/ActionBar/Publications"; 21 23 22 24 type Props = { 23 25 title: string; ··· 25 27 root_entity: string; 26 28 profile: ProfileViewDetailed; 27 29 description: string; 28 - publication_uri: string; 30 + publication_uri?: string; 29 31 record?: PubLeafletPublication.Record; 30 32 posts_in_pub?: number; 33 + entitiesToDelete?: string[]; 31 34 }; 32 35 33 36 export function PublishPost(props: Props) { ··· 72 75 leaflet_id: props.leaflet_id, 73 76 title: props.title, 74 77 description: props.description, 78 + entitiesToDelete: props.entitiesToDelete, 75 79 }); 76 80 if (!doc) return; 77 81 78 - let post_url = `https://${props.record?.base_path}/${doc.rkey}`; 82 + // Generate post URL based on whether it's in a publication or standalone 83 + let post_url = props.record?.base_path 84 + ? `https://${props.record.base_path}/${doc.rkey}` 85 + : `https://leaflet.pub/p/${props.profile.did}/${doc.rkey}`; 86 + 79 87 let [text, facets] = editorStateRef.current 80 88 ? editorStateToFacetedText(editorStateRef.current) 81 89 : []; ··· 95 103 96 104 return ( 97 105 <div className="flex flex-col gap-4 w-[640px] max-w-full sm:px-4 px-3 text-primary"> 98 - <h3>Publish Options</h3> 99 106 <form 100 107 onSubmit={(e) => { 101 108 e.preventDefault(); ··· 103 110 }} 104 111 > 105 112 <div className="container flex flex-col gap-2 sm:p-3 p-4"> 113 + <PublishingTo 114 + publication_uri={props.publication_uri} 115 + record={props.record} 116 + /> 117 + <hr className="border-border-light my-1" /> 106 118 <Radio 107 119 checked={shareOption === "quiet"} 108 120 onChange={(e) => { ··· 164 176 <div className="flex flex-col p-2"> 165 177 <div className="font-bold">{props.title}</div> 166 178 <div className="text-tertiary">{props.description}</div> 167 - <hr className="border-border-light mt-2 mb-1" /> 168 - <p className="text-xs text-tertiary"> 169 - {props.record?.base_path} 170 - </p> 179 + {props.record && ( 180 + <> 181 + <hr className="border-border-light mt-2 mb-1" /> 182 + <p className="text-xs text-tertiary"> 183 + {props.record?.base_path} 184 + </p> 185 + </> 186 + )} 171 187 </div> 172 188 </div> 173 189 <div className="text-xs text-secondary italic place-self-end pt-2"> ··· 198 214 ); 199 215 }; 200 216 217 + const PublishingTo = (props: { 218 + publication_uri?: string; 219 + record?: PubLeafletPublication.Record; 220 + }) => { 221 + if (props.publication_uri && props.record) { 222 + return ( 223 + <div className="flex flex-col gap-1"> 224 + <h3>Publishing to</h3> 225 + <div className="flex gap-2 items-center p-2 rounded-md bg-[var(--accent-light)]"> 226 + <PubIcon record={props.record} uri={props.publication_uri} /> 227 + <div className="font-bold text-secondary">{props.record.name}</div> 228 + </div> 229 + </div> 230 + ); 231 + } 232 + 233 + return ( 234 + <div className="flex flex-col gap-1"> 235 + <h3>Publishing as</h3> 236 + <div className="flex gap-2 items-center p-2 rounded-md bg-[var(--accent-light)]"> 237 + <LooseLeafSmall className="shrink-0" /> 238 + <div className="font-bold text-secondary">Looseleaf</div> 239 + </div> 240 + </div> 241 + ); 242 + }; 243 + 201 244 const PublishPostSuccess = (props: { 202 245 post_url: string; 203 - publication_uri: string; 246 + publication_uri?: string; 204 247 record: Props["record"]; 205 248 posts_in_pub: number; 206 249 }) => { 207 - let uri = new AtUri(props.publication_uri); 250 + let uri = props.publication_uri ? new AtUri(props.publication_uri) : null; 208 251 return ( 209 252 <div className="container p-4 m-3 sm:m-4 flex flex-col gap-1 justify-center text-center w-fit h-fit mx-auto"> 210 253 <PublishIllustration posts_in_pub={props.posts_in_pub} /> 211 - <h2 className="pt-2 text-primary">Published!</h2> 212 - <Link 213 - className="hover:no-underline! font-bold place-self-center pt-2" 214 - href={`/lish/${uri.host}/${encodeURIComponent(props.record?.name || "")}/dashboard`} 215 - > 216 - <ButtonPrimary>Back to Dashboard</ButtonPrimary> 217 - </Link> 254 + <h2 className="pt-2">Published!</h2> 255 + {uri && props.record ? ( 256 + <Link 257 + className="hover:no-underline! font-bold place-self-center pt-2" 258 + href={`/lish/${uri.host}/${encodeURIComponent(props.record.name || "")}/dashboard`} 259 + > 260 + <ButtonPrimary>Back to Dashboard</ButtonPrimary> 261 + </Link> 262 + ) : ( 263 + <Link 264 + className="hover:no-underline! font-bold place-self-center pt-2" 265 + href="/" 266 + > 267 + <ButtonPrimary>Back to Home</ButtonPrimary> 268 + </Link> 269 + )} 218 270 <a href={props.post_url}>See published post</a> 219 271 </div> 220 272 );
+63 -9
app/[leaflet_id]/publish/page.tsx
··· 13 13 type Props = { 14 14 // this is now a token id not leaflet! Should probs rename 15 15 params: Promise<{ leaflet_id: string }>; 16 + searchParams: Promise<{ 17 + publication_uri: string; 18 + title: string; 19 + description: string; 20 + entitiesToDelete: string; 21 + }>; 16 22 }; 17 23 export default async function PublishLeafletPage(props: Props) { 18 24 let leaflet_id = (await props.params).leaflet_id; ··· 27 33 *, 28 34 documents_in_publications(count) 29 35 ), 30 - documents(*))`, 36 + documents(*)), 37 + leaflets_to_documents( 38 + *, 39 + documents(*) 40 + )`, 31 41 ) 32 42 .eq("id", leaflet_id) 33 43 .single(); 34 44 let rootEntity = data?.root_entity; 35 - if (!data || !rootEntity || !data.leaflets_in_publications[0]) 45 + 46 + // Try to find publication from leaflets_in_publications first 47 + let publication = data?.leaflets_in_publications[0]?.publications; 48 + 49 + // If not found, check if publication_uri is in searchParams 50 + if (!publication) { 51 + let pub_uri = (await props.searchParams).publication_uri; 52 + if (pub_uri) { 53 + console.log(decodeURIComponent(pub_uri)); 54 + let { data: pubData, error } = await supabaseServerClient 55 + .from("publications") 56 + .select("*, documents_in_publications(count)") 57 + .eq("uri", decodeURIComponent(pub_uri)) 58 + .single(); 59 + console.log(error); 60 + publication = pubData; 61 + } 62 + } 63 + 64 + // Check basic data requirements 65 + if (!data || !rootEntity) 36 66 return ( 37 67 <div> 38 68 missin something ··· 42 72 43 73 let identity = await getIdentityData(); 44 74 if (!identity || !identity.atp_did) return null; 45 - let pub = data.leaflets_in_publications[0]; 46 - let agent = new AtpAgent({ service: "https://public.api.bsky.app" }); 47 75 76 + // Get title and description from either source 77 + let title = 78 + data.leaflets_in_publications[0]?.title || 79 + data.leaflets_to_documents[0]?.title || 80 + decodeURIComponent((await props.searchParams).title || ""); 81 + let description = 82 + data.leaflets_in_publications[0]?.description || 83 + data.leaflets_to_documents[0]?.description || 84 + decodeURIComponent((await props.searchParams).description || ""); 85 + 86 + let agent = new AtpAgent({ service: "https://public.api.bsky.app" }); 48 87 let profile = await agent.getProfile({ actor: identity.atp_did }); 88 + 89 + // Parse entitiesToDelete from URL params 90 + let searchParams = await props.searchParams; 91 + let entitiesToDelete: string[] = []; 92 + try { 93 + if (searchParams.entitiesToDelete) { 94 + entitiesToDelete = JSON.parse( 95 + decodeURIComponent(searchParams.entitiesToDelete), 96 + ); 97 + } 98 + } catch (e) { 99 + // If parsing fails, just use empty array 100 + } 101 + 49 102 return ( 50 103 <ReplicacheProvider 51 104 rootEntity={rootEntity} ··· 57 110 leaflet_id={leaflet_id} 58 111 root_entity={rootEntity} 59 112 profile={profile.data} 60 - title={pub.title} 61 - publication_uri={pub.publication} 62 - description={pub.description} 63 - record={pub.publications?.record as PubLeafletPublication.Record} 64 - posts_in_pub={pub.publications?.documents_in_publications[0].count} 113 + title={title} 114 + description={description} 115 + publication_uri={publication?.uri} 116 + record={publication?.record as PubLeafletPublication.Record | undefined} 117 + posts_in_pub={publication?.documents_in_publications[0]?.count} 118 + entitiesToDelete={entitiesToDelete} 65 119 /> 66 120 </ReplicacheProvider> 67 121 );
+4 -2
app/api/rpc/[command]/get_leaflet_data.ts
··· 7 7 >; 8 8 9 9 const leaflets_in_publications_query = `leaflets_in_publications(*, publications(*), documents(*))`; 10 + const leaflets_to_documents_query = `leaflets_to_documents(*, documents(*))`; 10 11 export const get_leaflet_data = makeRoute({ 11 12 route: "get_leaflet_data", 12 13 input: z.object({ ··· 18 19 .from("permission_tokens") 19 20 .select( 20 21 `*, 21 - permission_token_rights(*, entity_sets(permission_tokens(${leaflets_in_publications_query}))), 22 + permission_token_rights(*, entity_sets(permission_tokens(${leaflets_in_publications_query}, ${leaflets_to_documents_query}))), 22 23 custom_domain_routes!custom_domain_routes_edit_permission_token_fkey(*), 23 - ${leaflets_in_publications_query}`, 24 + ${leaflets_in_publications_query}, 25 + ${leaflets_to_documents_query}`, 24 26 ) 25 27 .eq("id", token_id) 26 28 .single();
+1
app/globals.css
··· 339 339 @apply focus-within:outline-offset-1; 340 340 341 341 @apply disabled:border-border-light; 342 + @apply disabled:hover:border-border-light; 342 343 @apply disabled:bg-border-light; 343 344 @apply disabled:text-tertiary; 344 345 }
+19 -26
app/lish/[did]/[publication]/[rkey]/CanvasPage.tsx
··· 21 21 import { PostHeader } from "./PostHeader/PostHeader"; 22 22 import { useDrawerOpen } from "./Interactions/InteractionDrawer"; 23 23 import { PollData } from "./fetchPollData"; 24 + import { SharedPageProps } from "./PostPages"; 24 25 25 26 export function CanvasPage({ 26 - document, 27 27 blocks, 28 - did, 29 - profile, 30 - preferences, 31 - pubRecord, 32 - prerenderedCodeBlocks, 33 - bskyPostData, 34 - pollData, 35 - document_uri, 36 - pageId, 37 - pageOptions, 38 - fullPageScroll, 39 28 pages, 40 - }: { 41 - document_uri: string; 42 - document: PostPageData; 29 + ...props 30 + }: Omit<SharedPageProps, "allPages"> & { 43 31 blocks: PubLeafletPagesCanvas.Block[]; 44 - profile: ProfileViewDetailed; 45 - pubRecord: PubLeafletPublication.Record; 46 - did: string; 47 - prerenderedCodeBlocks?: Map<string, string>; 48 - bskyPostData: AppBskyFeedDefs.PostView[]; 49 - pollData: PollData[]; 50 - preferences: { showComments?: boolean }; 51 - pageId?: string; 52 - pageOptions?: React.ReactNode; 53 - fullPageScroll: boolean; 54 32 pages: (PubLeafletPagesLinearDocument.Main | PubLeafletPagesCanvas.Main)[]; 55 33 }) { 34 + const { 35 + document, 36 + did, 37 + profile, 38 + preferences, 39 + pubRecord, 40 + theme, 41 + prerenderedCodeBlocks, 42 + bskyPostData, 43 + pollData, 44 + document_uri, 45 + pageId, 46 + pageOptions, 47 + fullPageScroll, 48 + } = props; 56 49 if (!document) return null; 57 50 58 - let hasPageBackground = !!pubRecord.theme?.showPageBackground; 51 + let hasPageBackground = !!theme?.showPageBackground; 59 52 let isSubpage = !!pageId; 60 53 let drawer = useDrawerOpen(document_uri); 61 54
+145
app/lish/[did]/[publication]/[rkey]/DocumentPageRenderer.tsx
··· 1 + import { AtpAgent } from "@atproto/api"; 2 + import { AtUri } from "@atproto/syntax"; 3 + import { ids } from "lexicons/api/lexicons"; 4 + import { 5 + PubLeafletBlocksBskyPost, 6 + PubLeafletDocument, 7 + PubLeafletPagesLinearDocument, 8 + PubLeafletPagesCanvas, 9 + PubLeafletPublication, 10 + } from "lexicons/api"; 11 + import { QuoteHandler } from "./QuoteHandler"; 12 + import { 13 + PublicationBackgroundProvider, 14 + PublicationThemeProvider, 15 + } from "components/ThemeManager/PublicationThemeProvider"; 16 + import { getPostPageData } from "./getPostPageData"; 17 + import { PostPageContextProvider } from "./PostPageContext"; 18 + import { PostPages } from "./PostPages"; 19 + import { extractCodeBlocks } from "./extractCodeBlocks"; 20 + import { LeafletLayout } from "components/LeafletLayout"; 21 + import { fetchPollData } from "./fetchPollData"; 22 + 23 + export async function DocumentPageRenderer({ 24 + did, 25 + rkey, 26 + }: { 27 + did: string; 28 + rkey: string; 29 + }) { 30 + let agent = new AtpAgent({ 31 + service: "https://public.api.bsky.app", 32 + fetch: (...args) => 33 + fetch(args[0], { 34 + ...args[1], 35 + next: { revalidate: 3600 }, 36 + }), 37 + }); 38 + 39 + let [document, profile] = await Promise.all([ 40 + getPostPageData(AtUri.make(did, ids.PubLeafletDocument, rkey).toString()), 41 + agent.getProfile({ actor: did }), 42 + ]); 43 + 44 + if (!document?.data) 45 + return ( 46 + <div className="bg-bg-leaflet h-full p-3 text-center relative"> 47 + <div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 max-w-md w-full"> 48 + <div className=" px-3 py-4 opaque-container flex flex-col gap-1 mx-2 "> 49 + <h3>Sorry, post not found!</h3> 50 + <p> 51 + This may be a glitch on our end. If the issue persists please{" "} 52 + <a href="mailto:contact@leaflet.pub">send us a note</a>. 53 + </p> 54 + </div> 55 + </div> 56 + </div> 57 + ); 58 + 59 + let record = document.data as PubLeafletDocument.Record; 60 + let bskyPosts = 61 + record.pages.flatMap((p) => { 62 + let page = p as PubLeafletPagesLinearDocument.Main; 63 + return page.blocks?.filter( 64 + (b) => b.block.$type === ids.PubLeafletBlocksBskyPost, 65 + ); 66 + }) || []; 67 + 68 + // Batch bsky posts into groups of 25 and fetch in parallel 69 + let bskyPostBatches = []; 70 + for (let i = 0; i < bskyPosts.length; i += 25) { 71 + bskyPostBatches.push(bskyPosts.slice(i, i + 25)); 72 + } 73 + 74 + let bskyPostResponses = await Promise.all( 75 + bskyPostBatches.map((batch) => 76 + agent.getPosts( 77 + { 78 + uris: batch.map((p) => { 79 + let block = p?.block as PubLeafletBlocksBskyPost.Main; 80 + return block.postRef.uri; 81 + }), 82 + }, 83 + { headers: {} }, 84 + ), 85 + ), 86 + ); 87 + 88 + let bskyPostData = 89 + bskyPostResponses.length > 0 90 + ? bskyPostResponses.flatMap((response) => response.data.posts) 91 + : []; 92 + 93 + // Extract poll blocks and fetch vote data 94 + let pollBlocks = record.pages.flatMap((p) => { 95 + let page = p as PubLeafletPagesLinearDocument.Main; 96 + return ( 97 + page.blocks?.filter((b) => b.block.$type === ids.PubLeafletBlocksPoll) || 98 + [] 99 + ); 100 + }); 101 + let pollData = await fetchPollData( 102 + pollBlocks.map((b) => (b.block as any).pollRef.uri), 103 + ); 104 + 105 + // Get theme from publication or document (for standalone docs) 106 + let pubRecord = document.documents_in_publications[0]?.publications 107 + ?.record as PubLeafletPublication.Record | undefined; 108 + let theme = pubRecord?.theme || record.theme || null; 109 + let pub_creator = 110 + document.documents_in_publications[0]?.publications?.identity_did || did; 111 + 112 + let firstPage = record.pages[0]; 113 + 114 + let firstPageBlocks = 115 + ( 116 + firstPage as 117 + | PubLeafletPagesLinearDocument.Main 118 + | PubLeafletPagesCanvas.Main 119 + ).blocks || []; 120 + let prerenderedCodeBlocks = await extractCodeBlocks(firstPageBlocks); 121 + 122 + return ( 123 + <PostPageContextProvider value={document}> 124 + <PublicationThemeProvider theme={theme} pub_creator={pub_creator}> 125 + <PublicationBackgroundProvider theme={theme} pub_creator={pub_creator}> 126 + <LeafletLayout> 127 + <PostPages 128 + document_uri={document.uri} 129 + preferences={pubRecord?.preferences || {}} 130 + pubRecord={pubRecord} 131 + profile={JSON.parse(JSON.stringify(profile.data))} 132 + document={document} 133 + bskyPostData={bskyPostData} 134 + did={did} 135 + prerenderedCodeBlocks={prerenderedCodeBlocks} 136 + pollData={pollData} 137 + /> 138 + </LeafletLayout> 139 + 140 + <QuoteHandler /> 141 + </PublicationBackgroundProvider> 142 + </PublicationThemeProvider> 143 + </PostPageContextProvider> 144 + ); 145 + }
+38 -44
app/lish/[did]/[publication]/[rkey]/LinearDocumentPage.tsx
··· 23 23 import { PageWrapper } from "components/Pages/Page"; 24 24 import { decodeQuotePosition } from "./quotePosition"; 25 25 import { PollData } from "./fetchPollData"; 26 + import { SharedPageProps } from "./PostPages"; 26 27 27 28 export function LinearDocumentPage({ 28 - document, 29 29 blocks, 30 - did, 31 - profile, 32 - preferences, 33 - pubRecord, 34 - prerenderedCodeBlocks, 35 - bskyPostData, 36 - document_uri, 37 - pageId, 38 - pageOptions, 39 - pollData, 40 - fullPageScroll, 41 - }: { 42 - document_uri: string; 43 - document: PostPageData; 30 + ...props 31 + }: Omit<SharedPageProps, "allPages"> & { 44 32 blocks: PubLeafletPagesLinearDocument.Block[]; 45 - profile?: ProfileViewDetailed; 46 - pubRecord: PubLeafletPublication.Record; 47 - did: string; 48 - prerenderedCodeBlocks?: Map<string, string>; 49 - bskyPostData: AppBskyFeedDefs.PostView[]; 50 - pollData: PollData[]; 51 - preferences: { showComments?: boolean }; 52 - pageId?: string; 53 - pageOptions?: React.ReactNode; 54 - fullPageScroll: boolean; 55 33 }) { 34 + const { 35 + document, 36 + did, 37 + profile, 38 + preferences, 39 + pubRecord, 40 + theme, 41 + prerenderedCodeBlocks, 42 + bskyPostData, 43 + pollData, 44 + document_uri, 45 + pageId, 46 + pageOptions, 47 + fullPageScroll, 48 + } = props; 56 49 let { identity } = useIdentityData(); 57 50 let drawer = useDrawerOpen(document_uri); 58 51 59 - if (!document || !document.documents_in_publications[0].publications) 60 - return null; 52 + if (!document) return null; 61 53 62 - let hasPageBackground = !!pubRecord.theme?.showPageBackground; 54 + let hasPageBackground = !!theme?.showPageBackground; 63 55 let record = document.data as PubLeafletDocument.Record; 64 56 65 57 const isSubpage = !!pageId; ··· 114 106 <EditTiny /> Edit Post 115 107 </a> 116 108 ) : ( 117 - <SubscribeWithBluesky 118 - isPost 119 - base_url={getPublicationURL( 120 - document.documents_in_publications[0].publications, 121 - )} 122 - pub_uri={ 123 - document.documents_in_publications[0].publications.uri 124 - } 125 - subscribers={ 126 - document.documents_in_publications[0].publications 127 - .publication_subscriptions 128 - } 129 - pubName={ 130 - document.documents_in_publications[0].publications.name 131 - } 132 - /> 109 + document.documents_in_publications[0]?.publications && ( 110 + <SubscribeWithBluesky 111 + isPost 112 + base_url={getPublicationURL( 113 + document.documents_in_publications[0].publications, 114 + )} 115 + pub_uri={ 116 + document.documents_in_publications[0].publications.uri 117 + } 118 + subscribers={ 119 + document.documents_in_publications[0].publications 120 + .publication_subscriptions 121 + } 122 + pubName={ 123 + document.documents_in_publications[0].publications.name 124 + } 125 + /> 126 + ) 133 127 )} 134 128 </div> 135 129 </>
+14 -21
app/lish/[did]/[publication]/[rkey]/PostHeader/PostHeader.tsx
··· 27 27 28 28 let record = document?.data as PubLeafletDocument.Record; 29 29 let profile = props.profile; 30 - let pub = props.data?.documents_in_publications[0].publications; 31 - let pubRecord = pub?.record as PubLeafletPublication.Record; 30 + let pub = props.data?.documents_in_publications[0]?.publications; 32 31 33 32 const formattedDate = useLocalizedDate( 34 33 record.publishedAt || new Date().toISOString(), ··· 36 35 year: "numeric", 37 36 month: "long", 38 37 day: "2-digit", 39 - } 38 + }, 40 39 ); 41 40 42 - if (!document?.data || !document.documents_in_publications[0].publications) 43 - return; 41 + if (!document?.data) return; 44 42 return ( 45 43 <div 46 44 className="max-w-prose w-full mx-auto px-3 sm:px-4 sm:pt-3 pt-2" ··· 48 46 > 49 47 <div className="pubHeader flex flex-col pb-5"> 50 48 <div className="flex justify-between w-full"> 51 - <SpeedyLink 52 - className="font-bold hover:no-underline text-accent-contrast" 53 - href={ 54 - document && 55 - getPublicationURL( 56 - document.documents_in_publications[0].publications, 57 - ) 58 - } 59 - > 60 - {pub?.name} 61 - </SpeedyLink> 49 + {pub && ( 50 + <SpeedyLink 51 + className="font-bold hover:no-underline text-accent-contrast" 52 + href={document && getPublicationURL(pub)} 53 + > 54 + {pub?.name} 55 + </SpeedyLink> 56 + )} 62 57 {identity && 63 - identity.atp_did === 64 - document.documents_in_publications[0]?.publications 65 - .identity_did && 58 + pub && 59 + identity.atp_did === pub.identity_did && 66 60 document.leaflets_in_publications[0] && ( 67 61 <a 68 62 className=" rounded-full flex place-items-center" ··· 90 84 ) : null} 91 85 {record.publishedAt ? ( 92 86 <> 93 - | 94 - <p>{formattedDate}</p> 87 + |<p>{formattedDate}</p> 95 88 </> 96 89 ) : null} 97 90 |{" "}
+98 -78
app/lish/[did]/[publication]/[rkey]/PostPages.tsx
··· 98 98 }; 99 99 }); 100 100 101 + // Shared props type for both page components 102 + export type SharedPageProps = { 103 + document: PostPageData; 104 + did: string; 105 + profile: ProfileViewDetailed; 106 + preferences: { showComments?: boolean }; 107 + pubRecord?: PubLeafletPublication.Record; 108 + theme?: PubLeafletPublication.Theme | null; 109 + prerenderedCodeBlocks?: Map<string, string>; 110 + bskyPostData: AppBskyFeedDefs.PostView[]; 111 + pollData: PollData[]; 112 + document_uri: string; 113 + fullPageScroll: boolean; 114 + pageId?: string; 115 + pageOptions?: React.ReactNode; 116 + allPages: (PubLeafletPagesLinearDocument.Main | PubLeafletPagesCanvas.Main)[]; 117 + }; 118 + 119 + // Component that renders either Canvas or Linear page based on page type 120 + function PageRenderer({ 121 + page, 122 + ...sharedProps 123 + }: { 124 + page: PubLeafletPagesLinearDocument.Main | PubLeafletPagesCanvas.Main; 125 + } & SharedPageProps) { 126 + const isCanvas = PubLeafletPagesCanvas.isMain(page); 127 + 128 + if (isCanvas) { 129 + return ( 130 + <CanvasPage 131 + {...sharedProps} 132 + blocks={(page as PubLeafletPagesCanvas.Main).blocks || []} 133 + pages={sharedProps.allPages} 134 + /> 135 + ); 136 + } 137 + 138 + return ( 139 + <LinearDocumentPage 140 + {...sharedProps} 141 + blocks={(page as PubLeafletPagesLinearDocument.Main).blocks || []} 142 + /> 143 + ); 144 + } 145 + 101 146 export function PostPages({ 102 147 document, 103 - blocks, 104 148 did, 105 149 profile, 106 150 preferences, ··· 112 156 }: { 113 157 document_uri: string; 114 158 document: PostPageData; 115 - blocks: PubLeafletPagesLinearDocument.Block[]; 116 159 profile: ProfileViewDetailed; 117 - pubRecord: PubLeafletPublication.Record; 160 + pubRecord?: PubLeafletPublication.Record; 118 161 did: string; 119 162 prerenderedCodeBlocks?: Map<string, string>; 120 163 bskyPostData: AppBskyFeedDefs.PostView[]; ··· 123 166 }) { 124 167 let drawer = useDrawerOpen(document_uri); 125 168 useInitializeOpenPages(); 126 - let pages = useOpenPages(); 127 - if (!document || !document.documents_in_publications[0].publications) 128 - return null; 169 + let openPageIds = useOpenPages(); 170 + if (!document) return null; 129 171 130 - let hasPageBackground = !!pubRecord.theme?.showPageBackground; 131 172 let record = document.data as PubLeafletDocument.Record; 173 + let theme = pubRecord?.theme || record.theme || null; 174 + let hasPageBackground = !!theme?.showPageBackground; 132 175 let quotesAndMentions = document.quotesAndMentions; 133 176 134 - let fullPageScroll = !hasPageBackground && !drawer && pages.length === 0; 177 + let firstPage = record.pages[0] as 178 + | PubLeafletPagesLinearDocument.Main 179 + | PubLeafletPagesCanvas.Main; 180 + 181 + // Shared props used for all pages 182 + const sharedProps: SharedPageProps = { 183 + document, 184 + did, 185 + profile, 186 + preferences, 187 + pubRecord, 188 + theme, 189 + prerenderedCodeBlocks, 190 + bskyPostData, 191 + pollData, 192 + document_uri, 193 + allPages: record.pages as ( 194 + | PubLeafletPagesLinearDocument.Main 195 + | PubLeafletPagesCanvas.Main 196 + )[], 197 + fullPageScroll: !hasPageBackground && !drawer && openPageIds.length === 0, 198 + }; 199 + 135 200 return ( 136 201 <> 137 - {!fullPageScroll && <BookendSpacer />} 138 - <LinearDocumentPage 139 - document={document} 140 - blocks={blocks} 141 - did={did} 142 - profile={profile} 143 - fullPageScroll={fullPageScroll} 144 - pollData={pollData} 145 - preferences={preferences} 146 - pubRecord={pubRecord} 147 - prerenderedCodeBlocks={prerenderedCodeBlocks} 148 - bskyPostData={bskyPostData} 149 - document_uri={document_uri} 150 - /> 202 + {!sharedProps.fullPageScroll && <BookendSpacer />} 203 + 204 + <PageRenderer page={firstPage} {...sharedProps} /> 151 205 152 206 {drawer && !drawer.pageId && ( 153 207 <InteractionDrawer 154 208 document_uri={document.uri} 155 209 comments={ 156 - pubRecord.preferences?.showComments === false 210 + pubRecord?.preferences?.showComments === false 157 211 ? [] 158 212 : document.comments_on_documents 159 213 } ··· 162 216 /> 163 217 )} 164 218 165 - {pages.map((p) => { 219 + {openPageIds.map((pageId) => { 166 220 let page = record.pages.find( 167 - (page) => 168 - ( 169 - page as 170 - | PubLeafletPagesLinearDocument.Main 171 - | PubLeafletPagesCanvas.Main 172 - ).id === p, 221 + (p) => 222 + (p as PubLeafletPagesLinearDocument.Main | PubLeafletPagesCanvas.Main) 223 + .id === pageId, 173 224 ) as 174 225 | PubLeafletPagesLinearDocument.Main 175 226 | PubLeafletPagesCanvas.Main 176 227 | undefined; 177 - if (!page) return null; 178 228 179 - const isCanvas = PubLeafletPagesCanvas.isMain(page); 229 + if (!page) return null; 180 230 181 231 return ( 182 - <Fragment key={p}> 232 + <Fragment key={pageId}> 183 233 <SandwichSpacer /> 184 - {isCanvas ? ( 185 - <CanvasPage 186 - fullPageScroll={false} 187 - document={document} 188 - blocks={(page as PubLeafletPagesCanvas.Main).blocks} 189 - did={did} 190 - preferences={preferences} 191 - profile={profile} 192 - pubRecord={pubRecord} 193 - prerenderedCodeBlocks={prerenderedCodeBlocks} 194 - pollData={pollData} 195 - bskyPostData={bskyPostData} 196 - document_uri={document_uri} 197 - pageId={page.id} 198 - pages={record.pages as PubLeafletPagesLinearDocument.Main[]} 199 - pageOptions={ 200 - <PageOptions 201 - onClick={() => closePage(page?.id!)} 202 - hasPageBackground={hasPageBackground} 203 - /> 204 - } 205 - /> 206 - ) : ( 207 - <LinearDocumentPage 208 - fullPageScroll={false} 209 - document={document} 210 - blocks={(page as PubLeafletPagesLinearDocument.Main).blocks} 211 - did={did} 212 - preferences={preferences} 213 - pubRecord={pubRecord} 214 - pollData={pollData} 215 - prerenderedCodeBlocks={prerenderedCodeBlocks} 216 - bskyPostData={bskyPostData} 217 - document_uri={document_uri} 218 - pageId={page.id} 219 - pageOptions={ 220 - <PageOptions 221 - onClick={() => closePage(page?.id!)} 222 - hasPageBackground={hasPageBackground} 223 - /> 224 - } 225 - /> 226 - )} 234 + <PageRenderer 235 + page={page} 236 + {...sharedProps} 237 + fullPageScroll={false} 238 + pageId={page.id} 239 + pageOptions={ 240 + <PageOptions 241 + onClick={() => closePage(page.id!)} 242 + hasPageBackground={hasPageBackground} 243 + /> 244 + } 245 + /> 227 246 {drawer && drawer.pageId === page.id && ( 228 247 <InteractionDrawer 229 248 pageId={page.id} 230 249 document_uri={document.uri} 231 250 comments={ 232 - pubRecord.preferences?.showComments === false 251 + pubRecord?.preferences?.showComments === false 233 252 ? [] 234 253 : document.comments_on_documents 235 254 } ··· 240 259 </Fragment> 241 260 ); 242 261 })} 243 - {!fullPageScroll && <BookendSpacer />} 262 + 263 + {!sharedProps.fullPageScroll && <BookendSpacer />} 244 264 </> 245 265 ); 246 266 }
+4 -5
app/lish/[did]/[publication]/[rkey]/PublishedPageBlock.tsx
··· 106 106 <div className="grow"> 107 107 {title && ( 108 108 <div 109 - className={`pageBlockOne outline-none resize-none align-top flex gap-2 ${title.$type === "pub.leaflet.blocks.header" ? "font-bold text-base" : ""}`} 109 + className={`pageBlockOne outline-none resize-none align-top gap-2 ${title.$type === "pub.leaflet.blocks.header" ? "font-bold text-base" : ""}`} 110 110 > 111 111 <TextBlock 112 112 facets={title.facets} ··· 118 118 )} 119 119 {description && ( 120 120 <div 121 - className={`pageBlockLineTwo outline-none resize-none align-top flex gap-2 ${description.$type === "pub.leaflet.blocks.header" ? "font-bold" : ""}`} 121 + className={`pageBlockLineTwo outline-none resize-none align-top gap-2 ${description.$type === "pub.leaflet.blocks.header" ? "font-bold" : ""}`} 122 122 > 123 123 <TextBlock 124 124 facets={description.facets} ··· 151 151 let previewRef = useRef<HTMLDivElement | null>(null); 152 152 let { rootEntity } = useReplicache(); 153 153 let data = useContext(PostPageContext); 154 - let theme = data?.documents_in_publications[0]?.publications 155 - ?.record as PubLeafletPublication.Record; 154 + let theme = data?.theme; 156 155 let pageWidth = `var(--page-width-unitless)`; 157 - let cardBorderHidden = !theme.theme?.showPageBackground; 156 + let cardBorderHidden = !theme?.showPageBackground; 158 157 return ( 159 158 <div 160 159 ref={previewRef}
+3 -2
app/lish/[did]/[publication]/[rkey]/extractCodeBlocks.ts
··· 1 1 import { 2 2 PubLeafletDocument, 3 3 PubLeafletPagesLinearDocument, 4 + PubLeafletPagesCanvas, 4 5 PubLeafletBlocksCode, 5 6 } from "lexicons/api"; 6 7 import { codeToHtml, bundledLanguagesInfo, bundledThemesInfo } from "shiki"; 7 8 8 9 export async function extractCodeBlocks( 9 - blocks: PubLeafletPagesLinearDocument.Block[], 10 + blocks: PubLeafletPagesLinearDocument.Block[] | PubLeafletPagesCanvas.Block[], 10 11 ): Promise<Map<string, string>> { 11 12 const codeBlocks = new Map<string, string>(); 12 13 13 - // Process all pages in the document 14 + // Process all blocks (works for both linear and canvas) 14 15 for (let i = 0; i < blocks.length; i++) { 15 16 const block = blocks[i]; 16 17 const currentIndex = [i];
+8 -1
app/lish/[did]/[publication]/[rkey]/getPostPageData.ts
··· 1 1 import { supabaseServerClient } from "supabase/serverClient"; 2 2 import { AtUri } from "@atproto/syntax"; 3 - import { PubLeafletPublication } from "lexicons/api"; 3 + import { PubLeafletDocument, PubLeafletPublication } from "lexicons/api"; 4 4 5 5 export async function getPostPageData(uri: string) { 6 6 let { data: document } = await supabaseServerClient ··· 43 43 ...uniqueBacklinks, 44 44 ]; 45 45 46 + let theme = 47 + ( 48 + document?.documents_in_publications[0]?.publications 49 + ?.record as PubLeafletPublication.Record 50 + )?.theme || (document?.data as PubLeafletDocument.Record)?.theme; 51 + 46 52 return { 47 53 ...document, 48 54 quotesAndMentions, 55 + theme, 49 56 }; 50 57 } 51 58
+6 -156
app/lish/[did]/[publication]/[rkey]/page.tsx
··· 1 1 import { supabaseServerClient } from "supabase/serverClient"; 2 2 import { AtUri } from "@atproto/syntax"; 3 3 import { ids } from "lexicons/api/lexicons"; 4 - import { 5 - PubLeafletBlocksBskyPost, 6 - PubLeafletDocument, 7 - PubLeafletPagesLinearDocument, 8 - PubLeafletPublication, 9 - } from "lexicons/api"; 4 + import { PubLeafletDocument } from "lexicons/api"; 10 5 import { Metadata } from "next"; 11 - import { AtpAgent } from "@atproto/api"; 12 - import { QuoteHandler } from "./QuoteHandler"; 13 - import { InteractionDrawer } from "./Interactions/InteractionDrawer"; 14 - import { 15 - PublicationBackgroundProvider, 16 - PublicationThemeProvider, 17 - } from "components/ThemeManager/PublicationThemeProvider"; 18 - import { getPostPageData } from "./getPostPageData"; 19 - import { PostPageContextProvider } from "./PostPageContext"; 20 - import { PostPages } from "./PostPages"; 21 - import { extractCodeBlocks } from "./extractCodeBlocks"; 22 - import { LeafletLayout } from "components/LeafletLayout"; 23 - import { fetchPollData } from "./fetchPollData"; 6 + import { DocumentPageRenderer } from "./DocumentPageRenderer"; 24 7 25 8 export async function generateMetadata(props: { 26 9 params: Promise<{ publication: string; did: string; rkey: string }>; ··· 57 40 export default async function Post(props: { 58 41 params: Promise<{ publication: string; did: string; rkey: string }>; 59 42 }) { 60 - let did = decodeURIComponent((await props.params).did); 43 + let params = await props.params; 44 + let did = decodeURIComponent(params.did); 45 + 61 46 if (!did) 62 47 return ( 63 48 <div className="p-4 text-lg text-center flex flex-col gap-4"> ··· 68 53 </p> 69 54 </div> 70 55 ); 71 - let agent = new AtpAgent({ 72 - service: "https://public.api.bsky.app", 73 - fetch: (...args) => 74 - fetch(args[0], { 75 - ...args[1], 76 - next: { revalidate: 3600 }, 77 - }), 78 - }); 79 - let [document, profile] = await Promise.all([ 80 - getPostPageData( 81 - AtUri.make( 82 - did, 83 - ids.PubLeafletDocument, 84 - (await props.params).rkey, 85 - ).toString(), 86 - ), 87 - agent.getProfile({ actor: did }), 88 - ]); 89 - if (!document?.data || !document.documents_in_publications[0].publications) 90 - return ( 91 - <div className="bg-bg-leaflet h-full p-3 text-center relative"> 92 - <div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 max-w-md w-full"> 93 - <div className=" px-3 py-4 opaque-container flex flex-col gap-1 mx-2 "> 94 - <h3>Sorry, post not found!</h3> 95 - <p> 96 - This may be a glitch on our end. If the issue persists please{" "} 97 - <a href="mailto:contact@leaflet.pub">send us a note</a>. 98 - </p> 99 - </div> 100 - </div> 101 - </div> 102 - ); 103 - let record = document.data as PubLeafletDocument.Record; 104 - let bskyPosts = 105 - record.pages.flatMap((p) => { 106 - let page = p as PubLeafletPagesLinearDocument.Main; 107 - return page.blocks?.filter( 108 - (b) => b.block.$type === ids.PubLeafletBlocksBskyPost, 109 - ); 110 - }) || []; 111 56 112 - // Batch bsky posts into groups of 25 and fetch in parallel 113 - let bskyPostBatches = []; 114 - for (let i = 0; i < bskyPosts.length; i += 25) { 115 - bskyPostBatches.push(bskyPosts.slice(i, i + 25)); 116 - } 117 - 118 - let bskyPostResponses = await Promise.all( 119 - bskyPostBatches.map((batch) => 120 - agent.getPosts( 121 - { 122 - uris: batch.map((p) => { 123 - let block = p?.block as PubLeafletBlocksBskyPost.Main; 124 - return block.postRef.uri; 125 - }), 126 - }, 127 - { headers: {} }, 128 - ), 129 - ), 130 - ); 131 - 132 - let bskyPostData = 133 - bskyPostResponses.length > 0 134 - ? bskyPostResponses.flatMap((response) => response.data.posts) 135 - : []; 136 - 137 - // Extract poll blocks and fetch vote data 138 - let pollBlocks = record.pages.flatMap((p) => { 139 - let page = p as PubLeafletPagesLinearDocument.Main; 140 - return ( 141 - page.blocks?.filter((b) => b.block.$type === ids.PubLeafletBlocksPoll) || 142 - [] 143 - ); 144 - }); 145 - let pollData = await fetchPollData( 146 - pollBlocks.map((b) => (b.block as any).pollRef.uri), 147 - ); 148 - 149 - let pubRecord = document.documents_in_publications[0]?.publications 150 - .record as PubLeafletPublication.Record; 151 - 152 - let firstPage = record.pages[0]; 153 - let blocks: PubLeafletPagesLinearDocument.Block[] = []; 154 - if (PubLeafletPagesLinearDocument.isMain(firstPage)) { 155 - blocks = firstPage.blocks || []; 156 - } 157 - 158 - let prerenderedCodeBlocks = await extractCodeBlocks(blocks); 159 - 160 - return ( 161 - <PostPageContextProvider value={document}> 162 - <PublicationThemeProvider 163 - record={pubRecord} 164 - pub_creator={ 165 - document.documents_in_publications[0].publications.identity_did 166 - } 167 - > 168 - <PublicationBackgroundProvider 169 - record={pubRecord} 170 - pub_creator={ 171 - document.documents_in_publications[0].publications.identity_did 172 - } 173 - > 174 - {/* 175 - TODO: SCROLL PAGE TO FIT DRAWER 176 - If the drawer fits without scrolling, dont scroll 177 - If both drawer and page fit if you scrolled it, scroll it all into the center 178 - If the drawer and pafe doesn't all fit, scroll to drawer 179 - 180 - TODO: SROLL BAR 181 - If there is no drawer && there is no page bg, scroll the entire page 182 - If there is either a drawer open OR a page background, scroll just the post content 183 - 184 - TODO: HIGHLIGHTING BORKED 185 - on chrome, if you scroll backward, things stop working 186 - seems like if you use an older browser, sel direction is not a thing yet 187 - */} 188 - <LeafletLayout> 189 - <PostPages 190 - document_uri={document.uri} 191 - preferences={pubRecord.preferences || {}} 192 - pubRecord={pubRecord} 193 - profile={JSON.parse(JSON.stringify(profile.data))} 194 - document={document} 195 - bskyPostData={bskyPostData} 196 - did={did} 197 - blocks={blocks} 198 - prerenderedCodeBlocks={prerenderedCodeBlocks} 199 - pollData={pollData} 200 - /> 201 - </LeafletLayout> 202 - 203 - <QuoteHandler /> 204 - </PublicationBackgroundProvider> 205 - </PublicationThemeProvider> 206 - </PostPageContextProvider> 207 - ); 57 + return <DocumentPageRenderer did={did} rkey={params.rkey} />; 208 58 }
+1 -1
app/lish/[did]/[publication]/dashboard/PublishedPostsLists.tsx
··· 13 13 import { MoreOptionsVerticalTiny } from "components/Icons/MoreOptionsVerticalTiny"; 14 14 import { DeleteSmall } from "components/Icons/DeleteSmall"; 15 15 import { ShareSmall } from "components/Icons/ShareSmall"; 16 - import { ShareButton } from "components/ShareOptions"; 16 + import { ShareButton } from "app/[leaflet_id]/actions/ShareOptions"; 17 17 import { SpeedyLink } from "components/SpeedyLink"; 18 18 import { QuoteTiny } from "components/Icons/QuoteTiny"; 19 19 import { CommentTiny } from "components/Icons/CommentTiny";
+2 -2
app/lish/[did]/[publication]/page.tsx
··· 60 60 try { 61 61 return ( 62 62 <PublicationThemeProvider 63 - record={record} 63 + theme={record?.theme} 64 64 pub_creator={publication.identity_did} 65 65 > 66 66 <PublicationBackgroundProvider 67 - record={record} 67 + theme={record?.theme} 68 68 pub_creator={publication.identity_did} 69 69 > 70 70 <PublicationHomeLayout
+6 -7
app/lish/createPub/CreatePubForm.tsx
··· 127 127 onChange={(e) => setShowInDiscover(e.target.checked)} 128 128 > 129 129 <div className=" pt-0.5 flex flex-col text-sm text-tertiary "> 130 - <p className="font-bold italic"> 131 - Show In{" "} 130 + <p className="font-bold italic">Show In Discover</p> 131 + <p className="text-sm text-tertiary font-normal"> 132 + Your posts will appear on our{" "} 132 133 <a href="/discover" target="_blank"> 133 134 Discover 134 - </a> 135 - </p> 136 - <p className="text-sm text-tertiary font-normal"> 137 - You'll be able to change this later! 135 + </a>{" "} 136 + page. You can change this at any time! 138 137 </p> 139 138 </div> 140 139 </Checkbox> 141 140 <hr className="border-border-light" /> 142 141 143 - <div className="flex w-full justify-center"> 142 + <div className="flex w-full justify-end"> 144 143 <ButtonPrimary 145 144 type="submit" 146 145 disabled={
+5 -1
app/lish/createPub/UpdatePubForm.tsx
··· 170 170 </a> 171 171 </p> 172 172 <p className="text-xs text-tertiary font-normal"> 173 - This publication will appear on our public Discover page 173 + Your posts will appear on our{" "} 174 + <a href="/discover" target="_blank"> 175 + Discover 176 + </a>{" "} 177 + page. You can change this at any time! 174 178 </p> 175 179 </div> 176 180 </Checkbox>
+1 -1
app/lish/createPub/page.tsx
··· 26 26 <div className="createPubContent h-full flex items-center max-w-sm w-full mx-auto"> 27 27 <div className="createPubFormWrapper h-fit w-full flex flex-col gap-4"> 28 28 <h2 className="text-center">Create Your Publication!</h2> 29 - <div className="container w-full p-3"> 29 + <div className="opaque-container w-full sm:py-4 p-3"> 30 30 <CreatePubForm /> 31 31 </div> 32 32 </div>
+1 -1
app/login/LoginForm.tsx
··· 213 213 </ButtonPrimary> 214 214 <button 215 215 type="button" 216 - className={`${props.compact ? "text-xs" : "text-sm"} text-accent-contrast place-self-center mt-[6px]`} 216 + className={`${props.compact ? "text-xs mt-0.5" : "text-sm mt-[6px]"} text-accent-contrast place-self-center`} 217 217 onClick={() => setSigningWithHandle(true)} 218 218 > 219 219 use an ATProto handle
+19
app/p/[didOrHandle]/[rkey]/l-quote/[quote]/opengraph-image.ts
··· 1 + import { getMicroLinkOgImage } from "src/utils/getMicroLinkOgImage"; 2 + import { decodeQuotePosition } from "app/lish/[did]/[publication]/[rkey]/quotePosition"; 3 + 4 + export const runtime = "edge"; 5 + export const revalidate = 60; 6 + 7 + export default async function OpenGraphImage(props: { 8 + params: { didOrHandle: string; rkey: string; quote: string }; 9 + }) { 10 + let quotePosition = decodeQuotePosition(props.params.quote); 11 + return getMicroLinkOgImage( 12 + `/p/${decodeURIComponent(props.params.didOrHandle)}/${props.params.rkey}/l-quote/${props.params.quote}#${quotePosition?.pageId ? `${quotePosition.pageId}~` : ""}${quotePosition?.start.block.join(".")}_${quotePosition?.start.offset}`, 13 + { 14 + width: 620, 15 + height: 324, 16 + deviceScaleFactor: 2, 17 + }, 18 + ); 19 + }
+8
app/p/[didOrHandle]/[rkey]/l-quote/[quote]/page.tsx
··· 1 + import PostPage from "app/p/[didOrHandle]/[rkey]/page"; 2 + 3 + export { generateMetadata } from "app/p/[didOrHandle]/[rkey]/page"; 4 + export default async function Post(props: { 5 + params: Promise<{ didOrHandle: string; rkey: string }>; 6 + }) { 7 + return <PostPage {...props} />; 8 + }
+90
app/p/[didOrHandle]/[rkey]/page.tsx
··· 1 + import { supabaseServerClient } from "supabase/serverClient"; 2 + import { AtUri } from "@atproto/syntax"; 3 + import { ids } from "lexicons/api/lexicons"; 4 + import { PubLeafletDocument } from "lexicons/api"; 5 + import { Metadata } from "next"; 6 + import { idResolver } from "app/(home-pages)/reader/idResolver"; 7 + import { DocumentPageRenderer } from "app/lish/[did]/[publication]/[rkey]/DocumentPageRenderer"; 8 + 9 + export async function generateMetadata(props: { 10 + params: Promise<{ didOrHandle: string; rkey: string }>; 11 + }): Promise<Metadata> { 12 + let params = await props.params; 13 + let didOrHandle = decodeURIComponent(params.didOrHandle); 14 + 15 + // Resolve handle to DID if necessary 16 + let did = didOrHandle; 17 + if (!didOrHandle.startsWith("did:")) { 18 + try { 19 + let resolved = await idResolver.handle.resolve(didOrHandle); 20 + if (resolved) did = resolved; 21 + } catch (e) { 22 + return { title: "404" }; 23 + } 24 + } 25 + 26 + let { data: document } = await supabaseServerClient 27 + .from("documents") 28 + .select("*, documents_in_publications(publications(*))") 29 + .eq("uri", AtUri.make(did, ids.PubLeafletDocument, params.rkey)) 30 + .single(); 31 + 32 + if (!document) return { title: "404" }; 33 + 34 + let docRecord = document.data as PubLeafletDocument.Record; 35 + 36 + // For documents in publications, include publication name 37 + let publicationName = document.documents_in_publications[0]?.publications?.name; 38 + 39 + return { 40 + icons: { 41 + other: { 42 + rel: "alternate", 43 + url: document.uri, 44 + }, 45 + }, 46 + title: publicationName 47 + ? `${docRecord.title} - ${publicationName}` 48 + : docRecord.title, 49 + description: docRecord?.description || "", 50 + }; 51 + } 52 + 53 + export default async function StandaloneDocumentPage(props: { 54 + params: Promise<{ didOrHandle: string; rkey: string }>; 55 + }) { 56 + let params = await props.params; 57 + let didOrHandle = decodeURIComponent(params.didOrHandle); 58 + 59 + // Resolve handle to DID if necessary 60 + let did = didOrHandle; 61 + if (!didOrHandle.startsWith("did:")) { 62 + try { 63 + let resolved = await idResolver.handle.resolve(didOrHandle); 64 + if (!resolved) { 65 + return ( 66 + <div className="p-4 text-lg text-center flex flex-col gap-4"> 67 + <p>Sorry, can&apos;t resolve handle.</p> 68 + <p> 69 + This may be a glitch on our end. If the issue persists please{" "} 70 + <a href="mailto:contact@leaflet.pub">send us a note</a>. 71 + </p> 72 + </div> 73 + ); 74 + } 75 + did = resolved; 76 + } catch (e) { 77 + return ( 78 + <div className="p-4 text-lg text-center flex flex-col gap-4"> 79 + <p>Sorry, can&apos;t resolve handle.</p> 80 + <p> 81 + This may be a glitch on our end. If the issue persists please{" "} 82 + <a href="mailto:contact@leaflet.pub">send us a note</a>. 83 + </p> 84 + </div> 85 + ); 86 + } 87 + } 88 + 89 + return <DocumentPageRenderer did={did} rkey={params.rkey} />; 90 + }
+20 -17
appview/index.ts
··· 104 104 data: record.value as Json, 105 105 }); 106 106 if (docResult.error) console.log(docResult.error); 107 - let publicationURI = new AtUri(record.value.publication); 107 + if (record.value.publication) { 108 + let publicationURI = new AtUri(record.value.publication); 109 + 110 + if (publicationURI.host !== evt.uri.host) { 111 + console.log("Unauthorized to create post!"); 112 + return; 113 + } 114 + let docInPublicationResult = await supabase 115 + .from("documents_in_publications") 116 + .upsert({ 117 + publication: record.value.publication, 118 + document: evt.uri.toString(), 119 + }); 120 + await supabase 121 + .from("documents_in_publications") 122 + .delete() 123 + .neq("publication", record.value.publication) 124 + .eq("document", evt.uri.toString()); 108 125 109 - if (publicationURI.host !== evt.uri.host) { 110 - console.log("Unauthorized to create post!"); 111 - return; 126 + if (docInPublicationResult.error) 127 + console.log(docInPublicationResult.error); 112 128 } 113 - let docInPublicationResult = await supabase 114 - .from("documents_in_publications") 115 - .upsert({ 116 - publication: record.value.publication, 117 - document: evt.uri.toString(), 118 - }); 119 - await supabase 120 - .from("documents_in_publications") 121 - .delete() 122 - .neq("publication", record.value.publication) 123 - .eq("document", evt.uri.toString()); 124 - if (docInPublicationResult.error) 125 - console.log(docInPublicationResult.error); 126 129 } 127 130 if (evt.event === "delete") { 128 131 await supabase.from("documents").delete().eq("uri", evt.uri.toString());
+12 -5
components/ActionBar/Navigation.tsx
··· 18 18 import { SpeedyLink } from "components/SpeedyLink"; 19 19 import { Separator } from "components/Layout"; 20 20 21 - export type navPages = "home" | "reader" | "pub" | "discover" | "notifications"; 21 + export type navPages = 22 + | "home" 23 + | "reader" 24 + | "pub" 25 + | "discover" 26 + | "notifications" 27 + | "looseleafs"; 22 28 23 29 export const DesktopNavigation = (props: { 24 30 currentPage: navPages; ··· 47 53 publication?: string; 48 54 }) => { 49 55 let { identity } = useIdentityData(); 50 - let thisPublication = identity?.publications?.find( 51 - (pub) => pub.uri === props.publication, 52 - ); 56 + 53 57 return ( 54 58 <div className="flex gap-1 "> 55 59 <Popover ··· 100 104 <DiscoverButton current={props.currentPage === "discover"} /> 101 105 102 106 <hr className="border-border-light my-1" /> 103 - <PublicationButtons currentPubUri={thisPublication?.uri} /> 107 + <PublicationButtons 108 + currentPage={props.currentPage} 109 + currentPubUri={thisPublication?.uri} 110 + /> 104 111 </> 105 112 ); 106 113 };
+47 -25
components/ActionBar/Publications.tsx
··· 12 12 import { PublishSmall } from "components/Icons/PublishSmall"; 13 13 import { Popover } from "components/Popover"; 14 14 import { BlueskyLogin } from "app/login/LoginForm"; 15 - import { ButtonPrimary } from "components/Buttons"; 15 + import { ButtonSecondary } from "components/Buttons"; 16 16 import { useIsMobile } from "src/hooks/isMobile"; 17 17 import { useState } from "react"; 18 + import { LooseLeafSmall } from "components/Icons/LooseleafSmall"; 19 + import { navPages } from "./Navigation"; 18 20 19 21 export const PublicationButtons = (props: { 22 + currentPage: navPages; 20 23 currentPubUri: string | undefined; 21 24 }) => { 22 25 let { identity } = useIdentityData(); 26 + let looseleaves = identity?.permission_token_on_homepage.find( 27 + (f) => f.permission_tokens.leaflets_to_documents, 28 + ); 23 29 24 30 // don't show pub list button if not logged in or no pub list 25 31 // we show a "start a pub" banner instead 26 32 if (!identity || !identity.atp_did || identity.publications.length === 0) 27 33 return <PubListEmpty />; 34 + 28 35 return ( 29 36 <div className="pubListWrapper w-full flex flex-col gap-1 sm:bg-transparent sm:border-0"> 37 + {looseleaves && ( 38 + <> 39 + <SpeedyLink 40 + href={`/looseleafs`} 41 + className="flex gap-2 items-start text-secondary font-bold hover:no-underline! hover:text-accent-contrast w-full" 42 + > 43 + {/*TODO How should i get if this is the current page or not? 44 + theres not "pub" to check the uri for. Do i need to add it as an option to NavPages? thats kinda annoying*/} 45 + <ActionButton 46 + label="Looseleafs" 47 + icon={<LooseLeafSmall />} 48 + nav 49 + className={ 50 + props.currentPage === "looseleafs" 51 + ? "bg-bg-page! border-border!" 52 + : "" 53 + } 54 + /> 55 + </SpeedyLink> 56 + <hr className="border-border-light border-dashed mx-1" /> 57 + </> 58 + )} 59 + 30 60 {identity.publications?.map((d) => { 31 61 return ( 32 62 <PublicationOption 33 63 {...d} 34 64 key={d.uri} 35 65 record={d.record} 36 - asActionButton 37 66 current={d.uri === props.currentPubUri} 38 67 /> 39 68 ); ··· 52 81 uri: string; 53 82 name: string; 54 83 record: Json; 55 - asActionButton?: boolean; 56 84 current?: boolean; 57 85 }) => { 58 86 let record = props.record as PubLeafletPublication.Record | null; ··· 63 91 href={`${getBasePublicationURL(props)}/dashboard`} 64 92 className="flex gap-2 items-start text-secondary font-bold hover:no-underline! hover:text-accent-contrast w-full" 65 93 > 66 - {props.asActionButton ? ( 67 - <ActionButton 68 - label={record.name} 69 - icon={<PubIcon record={record} uri={props.uri} />} 70 - nav 71 - className={props.current ? "bg-bg-page! border-border!" : ""} 72 - /> 73 - ) : ( 74 - <> 75 - <PubIcon record={record} uri={props.uri} /> 76 - <div className="truncate">{record.name}</div> 77 - </> 78 - )} 94 + <ActionButton 95 + label={record.name} 96 + icon={<PubIcon record={record} uri={props.uri} />} 97 + nav 98 + className={props.current ? "bg-bg-page! border-border!" : ""} 99 + /> 79 100 </SpeedyLink> 80 101 ); 81 102 }; 82 103 83 104 const PubListEmpty = () => { 84 - let { identity } = useIdentityData(); 85 105 let isMobile = useIsMobile(); 86 106 87 107 let [state, setState] = useState<"default" | "info">("default"); ··· 98 118 /> 99 119 ); 100 120 101 - if (isMobile && state === "info") return <PublishPopoverContent />; 121 + if (isMobile && state === "info") return <PubListEmptyContent />; 102 122 else 103 123 return ( 104 124 <Popover ··· 115 135 /> 116 136 } 117 137 > 118 - <PublishPopoverContent /> 138 + <PubListEmptyContent /> 119 139 </Popover> 120 140 ); 121 141 }; 122 142 123 - const PublishPopoverContent = () => { 143 + export const PubListEmptyContent = (props: { compact?: boolean }) => { 124 144 let { identity } = useIdentityData(); 125 145 126 146 return ( 127 - <div className="bg-[var(--accent-light)] w-full rounded-md flex flex-col text-center justify-center p-2 pb-4 text-sm"> 147 + <div 148 + className={`bg-[var(--accent-light)] w-full rounded-md flex flex-col text-center justify-center p-2 pb-4 text-sm`} 149 + > 128 150 <div className="mx-auto pt-2 scale-90"> 129 151 <PubListEmptyIllo /> 130 152 </div> 131 153 <div className="pt-1 font-bold">Publish on AT Proto</div> 132 - {identity && identity.atp_did ? ( 154 + {identity && !identity.atp_did ? ( 133 155 // has ATProto account and no pubs 134 156 <> 135 157 <div className="pb-2 text-secondary text-xs"> ··· 137 159 on AT Proto 138 160 </div> 139 161 <SpeedyLink href={`lish/createPub`} className=" hover:no-underline!"> 140 - <ButtonPrimary className="text-sm mx-auto" compact> 162 + <ButtonSecondary className="text-sm mx-auto" compact> 141 163 Start a Publication! 142 - </ButtonPrimary> 164 + </ButtonSecondary> 143 165 </SpeedyLink> 144 166 </> 145 167 ) : ( 146 168 // no ATProto account and no pubs 147 169 <> 148 170 <div className="pb-2 text-secondary text-xs"> 149 - Link a Bluesky account to start a new publication on AT Proto 171 + Link a Bluesky account to start <br /> a new publication on AT Proto 150 172 </div> 151 173 152 174 <BlueskyLogin compact />
+2 -2
components/Blocks/RSVPBlock/SendUpdate.tsx
··· 9 9 import { sendUpdateToRSVPS } from "actions/sendUpdateToRSVPS"; 10 10 import { useReplicache } from "src/replicache"; 11 11 import { Checkbox } from "components/Checkbox"; 12 - import { usePublishLink } from "components/ShareOptions"; 12 + import { useReadOnlyShareLink } from "app/[leaflet_id]/actions/ShareOptions"; 13 13 14 14 export function SendUpdateButton(props: { entityID: string }) { 15 - let publishLink = usePublishLink(); 15 + let publishLink = useReadOnlyShareLink(); 16 16 let { permissions } = useEntitySetContext(); 17 17 let { permission_token } = useReplicache(); 18 18 let [input, setInput] = useState("");
+35 -21
components/Buttons.tsx
··· 10 10 import { PopoverArrow } from "./Icons/PopoverArrow"; 11 11 12 12 type ButtonProps = Omit<JSX.IntrinsicElements["button"], "content">; 13 + 13 14 export const ButtonPrimary = forwardRef< 14 15 HTMLButtonElement, 15 16 ButtonProps & { ··· 35 36 m-0 h-max 36 37 ${fullWidth ? "w-full" : fullWidthOnMobile ? "w-full sm:w-max" : "w-max"} 37 38 ${compact ? "py-0 px-1" : "px-2 py-0.5 "} 38 - bg-accent-1 outline-transparent border border-accent-1 39 - rounded-md text-base font-bold text-accent-2 39 + bg-accent-1 disabled:bg-border-light 40 + border border-accent-1 rounded-md disabled:border-border-light 41 + outline outline-transparent outline-offset-1 focus:outline-accent-1 hover:outline-accent-1 42 + text-base font-bold text-accent-2 disabled:text-border disabled:hover:text-border 40 43 flex gap-2 items-center justify-center shrink-0 41 - transparent-outline focus:outline-accent-1 hover:outline-accent-1 outline-offset-1 42 - disabled:bg-border-light disabled:border-border-light disabled:text-border disabled:hover:text-border 43 44 ${className} 44 45 `} 45 46 > ··· 70 71 <button 71 72 {...buttonProps} 72 73 ref={ref} 73 - className={`m-0 h-max 74 + className={` 75 + m-0 h-max 74 76 ${fullWidth ? "w-full" : fullWidthOnMobile ? "w-full sm:w-max" : "w-max"} 75 - ${props.compact ? "py-0 px-1" : "px-2 py-0.5 "} 76 - bg-bg-page outline-transparent 77 - rounded-md text-base font-bold text-accent-contrast 78 - flex gap-2 items-center justify-center shrink-0 79 - transparent-outline focus:outline-accent-contrast hover:outline-accent-contrast outline-offset-1 80 - border border-accent-contrast 81 - disabled:bg-border-light disabled:text-border disabled:hover:text-border 82 - ${props.className} 83 - `} 77 + ${compact ? "py-0 px-1" : "px-2 py-0.5 "} 78 + bg-bg-page disabled:bg-border-light 79 + border border-accent-contrast rounded-md 80 + outline outline-transparent focus:outline-accent-contrast hover:outline-accent-contrast outline-offset-1 81 + text-base font-bold text-accent-contrast disabled:text-border disabled:hover:text-border 82 + flex gap-2 items-center justify-center shrink-0 83 + ${props.className} 84 + `} 84 85 > 85 86 {props.children} 86 87 </button> ··· 92 93 HTMLButtonElement, 93 94 { 94 95 fullWidth?: boolean; 96 + fullWidthOnMobile?: boolean; 95 97 children: React.ReactNode; 96 98 compact?: boolean; 97 99 } & ButtonProps 98 100 >((props, ref) => { 99 - let { fullWidth, children, compact, ...buttonProps } = props; 101 + let { 102 + className, 103 + fullWidth, 104 + fullWidthOnMobile, 105 + compact, 106 + children, 107 + ...buttonProps 108 + } = props; 100 109 return ( 101 110 <button 102 111 {...buttonProps} 103 112 ref={ref} 104 - className={`m-0 h-max ${fullWidth ? "w-full" : "w-max"} ${compact ? "px-0" : "px-1"} 105 - bg-transparent text-base font-bold text-accent-contrast 106 - flex gap-2 items-center justify-center shrink-0 107 - hover:underline disabled:text-border 108 - ${props.className} 109 - `} 113 + className={` 114 + m-0 h-max 115 + ${fullWidth ? "w-full" : fullWidthOnMobile ? "w-full sm:w-max" : "w-max"} 116 + ${compact ? "py-0 px-1" : "px-2 py-0.5 "} 117 + bg-transparent hover:bg-[var(--accent-light)] 118 + border border-transparent rounded-md hover:border-[var(--accent-light)] 119 + outline outline-transparent focus:outline-[var(--accent-light)] hover:outline-[var(--accent-light)] outline-offset-1 120 + text-base font-bold text-accent-contrast disabled:text-border 121 + flex gap-2 items-center justify-center shrink-0 122 + ${props.className} 123 + `} 110 124 > 111 125 {children} 112 126 </button>
+6 -6
components/HelpPopover.tsx app/[leaflet_id]/actions/HelpButton.tsx
··· 1 1 "use client"; 2 - import { ShortcutKey } from "./Layout"; 3 - import { Media } from "./Media"; 4 - import { Popover } from "./Popover"; 2 + import { ShortcutKey } from "../../../components/Layout"; 3 + import { Media } from "../../../components/Media"; 4 + import { Popover } from "../../../components/Popover"; 5 5 import { metaKey } from "src/utils/metaKey"; 6 - import { useEntitySetContext } from "./EntitySetProvider"; 6 + import { useEntitySetContext } from "../../../components/EntitySetProvider"; 7 7 import { useState } from "react"; 8 8 import { ActionButton } from "components/ActionBar/ActionButton"; 9 - import { HelpSmall } from "./Icons/HelpSmall"; 9 + import { HelpSmall } from "../../../components/Icons/HelpSmall"; 10 10 import { isMac } from "src/utils/isDevice"; 11 11 import { useIsMobile } from "src/hooks/isMobile"; 12 12 13 - export const HelpPopover = (props: { noShortcuts?: boolean }) => { 13 + export const HelpButton = (props: { noShortcuts?: boolean }) => { 14 14 let entity_set = useEntitySetContext(); 15 15 let isMobile = useIsMobile(); 16 16
+17 -20
components/HomeButton.tsx app/[leaflet_id]/actions/HomeButton.tsx
··· 1 1 "use client"; 2 2 import Link from "next/link"; 3 - import { useEntitySetContext } from "./EntitySetProvider"; 3 + import { useEntitySetContext } from "../../../components/EntitySetProvider"; 4 4 import { ActionButton } from "components/ActionBar/ActionButton"; 5 - import { useParams, useSearchParams } from "next/navigation"; 6 - import { useIdentityData } from "./IdentityProvider"; 5 + import { useSearchParams } from "next/navigation"; 6 + import { useIdentityData } from "../../../components/IdentityProvider"; 7 7 import { useReplicache } from "src/replicache"; 8 8 import { addLeafletToHome } from "actions/addLeafletToHome"; 9 - import { useSmoker } from "./Toast"; 10 - import { AddToHomeSmall } from "./Icons/AddToHomeSmall"; 11 - import { HomeSmall } from "./Icons/HomeSmall"; 12 - import { permission } from "process"; 9 + import { useSmoker } from "../../../components/Toast"; 10 + import { AddToHomeSmall } from "../../../components/Icons/AddToHomeSmall"; 11 + import { HomeSmall } from "../../../components/Icons/HomeSmall"; 12 + import { produce } from "immer"; 13 13 14 14 export function HomeButton() { 15 15 let { permissions } = useEntitySetContext(); ··· 47 47 await addLeafletToHome(permission_token.id); 48 48 mutate((identity) => { 49 49 if (!identity) return; 50 - return { 51 - ...identity, 52 - permission_token_on_homepage: [ 53 - ...identity.permission_token_on_homepage, 54 - { 55 - archived: null, 56 - created_at: new Date().toISOString(), 57 - permission_tokens: { 58 - ...permission_token, 59 - leaflets_in_publications: [], 60 - }, 50 + return produce<typeof identity>((draft) => { 51 + draft.permission_token_on_homepage.push({ 52 + created_at: new Date().toISOString(), 53 + archived: null, 54 + permission_tokens: { 55 + ...permission_token, 56 + leaflets_to_documents: [], 57 + leaflets_in_publications: [], 61 58 }, 62 - ], 63 - }; 59 + }); 60 + })(identity); 64 61 }); 65 62 smoker({ 66 63 position: {
+19
components/Icons/LooseleafSmall.tsx
··· 1 + import { Props } from "./Props"; 2 + 3 + export const LooseLeafSmall = (props: Props) => { 4 + return ( 5 + <svg 6 + width="24" 7 + height="24" 8 + viewBox="0 0 24 24" 9 + fill="none" 10 + xmlns="http://www.w3.org/2000/svg" 11 + {...props} 12 + > 13 + <path 14 + d="M16.5339 4.65788L21.9958 5.24186C22.4035 5.28543 22.7014 5.6481 22.6638 6.05632C22.5159 7.65303 22.3525 9.87767 22.0925 11.9186C21.9621 12.9418 21.805 13.9374 21.6091 14.8034C21.4166 15.6542 21.1733 16.442 20.8454 17.0104C20.1989 18.131 19.0036 18.9569 17.9958 19.4782C17.4793 19.7453 16.9792 19.9495 16.569 20.0827C16.3649 20.1489 16.1724 20.2013 16.0046 20.234C15.8969 20.255 15.7254 20.2816 15.5495 20.2682C15.5466 20.2681 15.5423 20.2684 15.5378 20.2682C15.527 20.2678 15.5112 20.267 15.4919 20.2663C15.4526 20.2647 15.3959 20.2623 15.3239 20.2584C15.1788 20.2506 14.9699 20.2366 14.7116 20.2145C14.1954 20.1703 13.4757 20.0909 12.6598 19.9489C11.0477 19.6681 8.97633 19.1301 7.36198 18.0807C6.70824 17.6557 5.95381 17.064 5.21842 16.4469C5.09798 16.5214 4.97261 16.591 4.81803 16.6706C4.28341 16.9455 3.71779 17.0389 3.17935 16.9137C2.64094 16.7885 2.20091 16.4608 1.89126 16.0231C1.28226 15.1618 1.16463 13.8852 1.5729 12.5514L1.60708 12.4606C1.7005 12.255 1.88295 12.1001 2.10513 12.0436C2.35906 11.9792 2.62917 12.0524 2.81607 12.236L2.82486 12.2448C2.8309 12.2507 2.84033 12.2596 2.8522 12.2712C2.87664 12.295 2.91343 12.3309 2.9606 12.3766C3.05513 12.4682 3.19281 12.6016 3.3649 12.7653C3.70953 13.0931 4.19153 13.5443 4.73795 14.0378C5.84211 15.0349 7.17372 16.1691 8.17937 16.8229C9.53761 17.7059 11.3696 18.2017 12.9177 18.4713C13.6815 18.6043 14.3565 18.679 14.8395 18.7204C15.0804 18.741 15.2731 18.7533 15.404 18.7604C15.4691 18.7639 15.5195 18.7659 15.5524 18.7672C15.5684 18.7679 15.5809 18.7689 15.5886 18.7692H15.5983L15.6374 18.7731C15.6457 18.7724 15.671 18.7704 15.7175 18.7614C15.8087 18.7436 15.9399 18.7095 16.1052 18.6559C16.4345 18.549 16.8594 18.3773 17.3063 18.1461C18.2257 17.6706 19.1147 17.0089 19.5466 16.2604C19.7578 15.8941 19.9618 15.2874 20.1462 14.4723C20.3271 13.6723 20.4767 12.7294 20.6042 11.7292C20.8232 10.0102 20.9711 8.17469 21.1042 6.65397L16.3747 6.14909C15.963 6.10498 15.6648 5.73562 15.7087 5.3239C15.7528 4.91222 16.1222 4.61399 16.5339 4.65788ZM12.0593 13.1315L12.2038 13.1647L12.3776 13.235C12.7592 13.4197 12.9689 13.7541 13.0837 14.0573C13.2089 14.3885 13.2545 14.7654 13.2858 15.0573C13.3144 15.3233 13.3319 15.5214 13.361 15.6774C13.4345 15.6215 13.5233 15.5493 13.6413 15.4479C13.7924 15.318 14.0034 15.1374 14.2429 15.0114C14.4965 14.878 14.8338 14.7772 15.2175 14.8747C15.5354 14.9556 15.7394 15.1539 15.8679 15.3229C15.9757 15.4648 16.0814 15.6631 16.1247 15.736C16.1889 15.8438 16.2218 15.8788 16.239 15.8922C16.2438 15.896 16.2462 15.8979 16.2497 15.8991C16.2541 15.9005 16.2717 15.9049 16.3093 15.9049C16.6541 15.9051 16.934 16.1851 16.9343 16.5299C16.9343 16.875 16.6543 17.1548 16.3093 17.1549C15.9766 17.1549 15.6957 17.0542 15.4694 16.8776C15.2617 16.7153 15.1322 16.5129 15.0505 16.3756C14.9547 16.2147 14.9262 16.1561 14.8815 16.0944C14.8684 16.0989 14.849 16.1051 14.8249 16.1178C14.7289 16.1684 14.6182 16.2555 14.4557 16.3952C14.3175 16.514 14.1171 16.6946 13.9069 16.821C13.6882 16.9524 13.3571 17.0902 12.9684 16.9938C12.4305 16.8602 12.2473 16.3736 12.1764 16.1051C12.1001 15.8159 12.0709 15.4542 12.0427 15.1911C12.0102 14.8884 11.9751 14.662 11.9138 14.4997C11.9011 14.4662 11.8884 14.4403 11.8776 14.4206C11.7899 14.4801 11.6771 14.5721 11.5329 14.7047C11.3855 14.8404 11.181 15.0386 11.0016 15.196C10.8175 15.3575 10.5936 15.5364 10.3512 15.6569C10.19 15.737 9.99118 15.7919 9.77214 15.7594C9.55026 15.7264 9.38367 15.6153 9.27019 15.5045C9.08085 15.3197 8.96362 15.0503 8.91081 14.9391C8.8766 14.8671 8.85074 14.814 8.82585 14.7692C8.541 14.777 8.27798 14.5891 8.20378 14.3014C8.11797 13.9674 8.31907 13.6269 8.653 13.5407L8.79558 13.5124C8.93966 13.4936 9.0875 13.5034 9.23308 13.5485C9.42396 13.6076 9.569 13.7155 9.67449 13.8239C9.85113 14.0055 9.96389 14.244 10.027 14.3776C10.0723 14.3417 10.124 14.3034 10.1774 14.2565C10.3474 14.1073 10.4942 13.9615 10.6862 13.7848C10.8571 13.6276 11.0614 13.4475 11.2731 13.32C11.4428 13.2178 11.7294 13.081 12.0593 13.1315ZM2.84537 14.3366C2.88081 14.6965 2.98677 14.9742 3.11588 15.1569C3.24114 15.334 3.38295 15.4211 3.5192 15.4528C3.63372 15.4794 3.79473 15.4775 4.00553 15.3932C3.9133 15.3109 3.82072 15.2311 3.73209 15.151C3.40947 14.8597 3.10909 14.5828 2.84537 14.3366ZM8.73601 3.86003C9.14672 3.91292 9.43715 4.28918 9.38445 4.69987C9.25964 5.66903 9.14642 7.35598 8.87077 9.02018C8.59001 10.7151 8.11848 12.5766 7.20085 14.1003C6.98712 14.4551 6.52539 14.5698 6.17057 14.3561C5.81623 14.1423 5.70216 13.6814 5.91569 13.3268C6.68703 12.0463 7.121 10.4066 7.39128 8.77506C7.66663 7.11265 7.74965 5.64618 7.89616 4.50847C7.94916 4.09794 8.32546 3.80744 8.73601 3.86003ZM11.7614 8.36784C12.1238 8.21561 12.4973 8.25977 12.8054 8.46452C13.0762 8.64474 13.2601 8.92332 13.3884 9.18912C13.5214 9.46512 13.6241 9.79028 13.7009 10.1354C13.7561 10.3842 13.7827 10.6162 13.8034 10.8044C13.8257 11.0069 13.8398 11.1363 13.864 11.2438C13.8806 11.3174 13.8959 11.3474 13.9011 11.3561C13.9095 11.3609 13.9289 11.3695 13.9655 11.3786C14.0484 11.3991 14.0814 11.3929 14.0895 11.3913C14.1027 11.3885 14.1323 11.3804 14.2028 11.3366C14.3137 11.2677 14.6514 11.0042 15.0563 10.8288L15.1364 10.7985C15.3223 10.7392 15.4987 10.7526 15.6335 10.7838C15.7837 10.8188 15.918 10.883 16.0231 10.9421C16.2276 11.057 16.4458 11.2251 16.613 11.3503C16.8019 11.4917 16.9527 11.5999 17.0827 11.6676C17.1539 11.7047 17.1908 11.7142 17.2009 11.7165L17.2849 11.7047C17.5751 11.6944 17.8425 11.8891 17.9138 12.1823C17.995 12.5174 17.7897 12.8554 17.4548 12.9372C17.0733 13.0299 16.7253 12.8909 16.5046 12.776C16.2705 12.6541 16.042 12.4845 15.864 12.3512C15.6704 12.2064 15.5344 12.1038 15.4216 12.0387C15.2178 12.1436 15.1125 12.2426 14.862 12.3981C14.7283 12.4811 14.5564 12.5716 14.3415 12.6159C14.1216 12.6611 13.8975 12.6501 13.6647 12.5924C13.3819 12.5222 13.1344 12.3858 12.9479 12.1657C12.7701 11.9555 12.689 11.7172 12.6442 11.5182C12.601 11.3259 12.58 11.112 12.5612 10.9411C12.5408 10.7561 12.5194 10.5827 12.4802 10.4059C12.4169 10.1215 12.3411 9.89526 12.2624 9.73209C12.2296 9.66404 12.1981 9.61255 12.1716 9.57487C12.1263 9.61576 12.0615 9.68493 11.9802 9.7985C11.8864 9.92952 11.7821 10.0922 11.6589 10.2838C11.5393 10.4698 11.4043 10.6782 11.2634 10.8786C11.123 11.0782 10.9664 11.2843 10.7975 11.4635C10.633 11.6381 10.4285 11.8185 10.1862 11.9342C9.87476 12.0828 9.50095 11.9507 9.35222 11.6393C9.20377 11.3279 9.33594 10.9551 9.64714 10.8063C9.69148 10.7851 9.77329 10.7282 9.88835 10.6061C9.99931 10.4883 10.1167 10.3365 10.2409 10.1598C10.3647 9.98378 10.4855 9.79617 10.6071 9.60709C10.7249 9.42397 10.8479 9.23258 10.9636 9.07096C11.1814 8.76677 11.4424 8.50191 11.7614 8.36784ZM12.4304 2.81218C13.631 2.81246 14.6042 3.78628 14.6042 4.98698C14.6041 5.39899 14.4869 5.78271 14.2878 6.111L15.0007 6.9069C15.2772 7.21532 15.2515 7.689 14.9431 7.96549C14.6347 8.24164 14.1609 8.21606 13.8845 7.90788L13.1139 7.0485C12.8988 7.11984 12.6695 7.16075 12.4304 7.16081C11.2296 7.16081 10.2558 6.18766 10.2555 4.98698C10.2555 3.7861 11.2295 2.81218 12.4304 2.81218ZM12.4304 4.31218C12.0579 4.31218 11.7555 4.61453 11.7555 4.98698C11.7558 5.35924 12.058 5.66081 12.4304 5.66081C12.8024 5.66053 13.104 5.35907 13.1042 4.98698C13.1042 4.6147 12.8026 4.31246 12.4304 4.31218Z" 15 + fill="currentColor" 16 + /> 17 + </svg> 18 + ); 19 + };
+10 -2
components/Input.tsx
··· 100 100 JSX.IntrinsicElements["textarea"], 101 101 ) => { 102 102 let { label, textarea, ...inputProps } = props; 103 - let style = `appearance-none w-full font-normal not-italic bg-transparent text-base text-primary focus:outline-0 ${props.className} outline-hidden resize-none`; 103 + let style = ` 104 + appearance-none resize-none w-full 105 + bg-transparent 106 + outline-hidden focus:outline-0 107 + font-normal not-italic text-base text-primary disabled:text-tertiary 108 + disabled:cursor-not-allowed 109 + ${props.className}`; 104 110 return ( 105 - <label className=" input-with-border flex flex-col gap-px text-sm text-tertiary font-bold italic leading-tight py-1! px-[6px]!"> 111 + <label 112 + className={`input-with-border flex flex-col gap-px text-sm text-tertiary font-bold italic leading-tight py-1! px-[6px]! ${props.disabled && "bg-border-light! cursor-not-allowed! hover:border-border!"}`} 113 + > 106 114 {props.label} 107 115 {textarea ? ( 108 116 <textarea {...inputProps} className={style} />
+1
components/Layout.tsx
··· 1 + "use client"; 1 2 import * as DropdownMenu from "@radix-ui/react-dropdown-menu"; 2 3 import { theme } from "tailwind.config"; 3 4 import { NestedCardThemeProvider } from "./ThemeManager/ThemeProvider";
+6 -6
components/PageSWRDataProvider.tsx
··· 7 7 import { getPollData } from "actions/pollActions"; 8 8 import type { GetLeafletDataReturnType } from "app/api/rpc/[command]/get_leaflet_data"; 9 9 import { createContext, useContext } from "react"; 10 + import { getPublicationMetadataFromLeafletData } from "src/utils/getPublicationMetadataFromLeafletData"; 10 11 11 12 export const StaticLeafletDataContext = createContext< 12 13 null | GetLeafletDataReturnType["result"]["data"] ··· 66 67 }; 67 68 export function useLeafletPublicationData() { 68 69 let { data, mutate } = useLeafletData(); 70 + 71 + // First check for leaflets in publications 72 + let pubData = getPublicationMetadataFromLeafletData(data); 73 + 69 74 return { 70 - data: 71 - data?.leaflets_in_publications?.[0] || 72 - data?.permission_token_rights[0].entity_sets?.permission_tokens?.find( 73 - (p) => p.leaflets_in_publications.length, 74 - )?.leaflets_in_publications?.[0] || 75 - null, 75 + data: pubData || null, 76 76 mutate, 77 77 }; 78 78 }
+1 -1
components/Pages/Page.tsx
··· 61 61 /> 62 62 } 63 63 > 64 - {props.first && ( 64 + {props.first && pageType === "doc" && ( 65 65 <> 66 66 <PublicationMetadata /> 67 67 </>
+5 -2
components/Pages/PageShareMenu.tsx
··· 1 1 import { useLeafletDomains } from "components/PageSWRDataProvider"; 2 - import { ShareButton, usePublishLink } from "components/ShareOptions"; 2 + import { 3 + ShareButton, 4 + useReadOnlyShareLink, 5 + } from "app/[leaflet_id]/actions/ShareOptions"; 3 6 import { useEffect, useState } from "react"; 4 7 5 8 export const PageShareMenu = (props: { entityID: string }) => { 6 - let publishLink = usePublishLink(); 9 + let publishLink = useReadOnlyShareLink(); 7 10 let { data: domains } = useLeafletDomains(); 8 11 let [collabLink, setCollabLink] = useState<null | string>(null); 9 12 useEffect(() => {
+19 -13
components/Pages/PublicationMetadata.tsx
··· 25 25 let record = pub?.documents?.data as PubLeafletDocument.Record | null; 26 26 let publishedAt = record?.publishedAt; 27 27 28 - if (!pub || !pub.publications) return null; 28 + if (!pub) return null; 29 29 30 30 if (typeof title !== "string") { 31 31 title = pub?.title || ""; ··· 36 36 return ( 37 37 <div className={`flex flex-col px-3 sm:px-4 pb-5 sm:pt-3 pt-2`}> 38 38 <div className="flex gap-2"> 39 - <Link 40 - href={ 41 - identity?.atp_did === pub.publications?.identity_did 42 - ? `${getBasePublicationURL(pub.publications)}/dashboard` 43 - : getPublicationURL(pub.publications) 44 - } 45 - className="leafletMetadata text-accent-contrast font-bold hover:no-underline" 46 - > 47 - {pub.publications?.name} 48 - </Link> 39 + {pub.publications && ( 40 + <Link 41 + href={ 42 + identity?.atp_did === pub.publications?.identity_did 43 + ? `${getBasePublicationURL(pub.publications)}/dashboard` 44 + : getPublicationURL(pub.publications) 45 + } 46 + className="leafletMetadata text-accent-contrast font-bold hover:no-underline" 47 + > 48 + {pub.publications?.name} 49 + </Link> 50 + )} 49 51 <div className="font-bold text-tertiary px-1 text-sm flex place-items-center bg-border-light rounded-md "> 50 52 Editor 51 53 </div> ··· 81 83 <Link 82 84 target="_blank" 83 85 className="text-sm" 84 - href={`${getPublicationURL(pub.publications)}/${new AtUri(pub.doc).rkey}`} 86 + href={ 87 + pub.publications 88 + ? `${getPublicationURL(pub.publications)}/${new AtUri(pub.doc).rkey}` 89 + : `/p/${new AtUri(pub.doc).host}/${new AtUri(pub.doc).rkey}` 90 + } 85 91 > 86 92 View Post 87 93 </Link> ··· 169 175 let record = pub?.documents?.data as PubLeafletDocument.Record | null; 170 176 let publishedAt = record?.publishedAt; 171 177 172 - if (!pub || !pub.publications) return null; 178 + if (!pub) return null; 173 179 174 180 return ( 175 181 <div className={`flex flex-col px-3 sm:px-4 pb-5 sm:pt-3 pt-2`}>
+2 -2
components/ShareOptions/DomainOptions.tsx app/[leaflet_id]/actions/ShareOptions/DomainOptions.tsx
··· 8 8 import { addDomain } from "actions/domains/addDomain"; 9 9 import { callRPC } from "app/api/rpc/client"; 10 10 import { useLeafletDomains } from "components/PageSWRDataProvider"; 11 - import { usePublishLink } from "."; 11 + import { useReadOnlyShareLink } from "."; 12 12 import { addDomainPath } from "actions/domains/addDomainPath"; 13 13 import { useReplicache } from "src/replicache"; 14 14 import { deleteDomain } from "actions/domains/deleteDomain"; ··· 74 74 75 75 let toaster = useToaster(); 76 76 let smoker = useSmoker(); 77 - let publishLink = usePublishLink(); 77 + let publishLink = useReadOnlyShareLink(); 78 78 79 79 return ( 80 80 <div className="px-3 py-1 flex flex-col gap-3 max-w-full w-[600px]">
components/ShareOptions/getShareLink.ts app/[leaflet_id]/actions/ShareOptions/getShareLink.ts
+9 -8
components/ShareOptions/index.tsx app/[leaflet_id]/actions/ShareOptions/index.tsx
··· 21 21 22 22 export type ShareMenuStates = "default" | "login" | "domain"; 23 23 24 - export let usePublishLink = () => { 24 + export let useReadOnlyShareLink = () => { 25 25 let { permission_token, rootEntity } = useReplicache(); 26 26 let entity_set = useEntitySetContext(); 27 27 let { data: publishLink } = useSWR( ··· 60 60 trigger={ 61 61 <ActionButton 62 62 icon=<ShareSmall /> 63 - primary={!!!pub} 64 - secondary={!!pub} 63 + secondary 65 64 label={`Share ${pub ? "Draft" : ""}`} 66 65 /> 67 66 } ··· 93 92 94 93 let record = pub?.documents?.data as PubLeafletDocument.Record | null; 95 94 96 - let postLink = 97 - pub?.publications && pub.documents 98 - ? `${getPublicationURL(pub.publications)}/${new AtUri(pub?.documents.uri).rkey}` 99 - : null; 100 - let publishLink = usePublishLink(); 95 + let docURI = pub?.documents ? new AtUri(pub?.documents.uri) : null; 96 + let postLink = !docURI 97 + ? null 98 + : pub?.publications 99 + ? `${getPublicationURL(pub.publications)}/${docURI.rkey}` 100 + : `p/${docURI.host}/${docURI.rkey}`; 101 + let publishLink = useReadOnlyShareLink(); 101 102 let [collabLink, setCollabLink] = useState<null | string>(null); 102 103 useEffect(() => { 103 104 // strip leading '/' character from pathname
+2 -43
components/ThemeManager/PubThemeSetter.tsx
··· 16 16 import { PubAccentPickers } from "./PubPickers/PubAcccentPickers"; 17 17 import { Separator } from "components/Layout"; 18 18 import { PubSettingsHeader } from "app/lish/[did]/[publication]/dashboard/PublicationSettings"; 19 + import { ColorToRGB, ColorToRGBA } from "./colorToLexicons"; 19 20 20 21 export type ImageState = { 21 22 src: string; ··· 39 40 theme: localPubTheme, 40 41 setTheme, 41 42 changes, 42 - } = useLocalPubTheme(record, showPageBackground); 43 + } = useLocalPubTheme(record?.theme, showPageBackground); 43 44 let [image, setImage] = useState<ImageState | null>( 44 45 PubLeafletThemeBackgroundImage.isMain(record?.theme?.backgroundImage) 45 46 ? { ··· 343 344 </div> 344 345 ); 345 346 }; 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 - }
+24 -25
components/ThemeManager/PublicationThemeProvider.tsx
··· 26 26 } 27 27 28 28 let useColor = ( 29 - record: PubLeafletPublication.Record | null | undefined, 29 + theme: PubLeafletPublication.Record["theme"] | null | undefined, 30 30 c: keyof typeof PubThemeDefaults, 31 31 ) => { 32 32 return useMemo(() => { 33 - let v = record?.theme?.[c]; 33 + let v = theme?.[c]; 34 34 if (isColor(v)) { 35 35 return parseThemeColor(v); 36 36 } else return parseColor(PubThemeDefaults[c]); 37 - }, [record?.theme?.[c]]); 37 + }, [theme?.[c]]); 38 38 }; 39 39 let isColor = ( 40 40 c: any, ··· 53 53 return ( 54 54 <PublicationThemeProvider 55 55 pub_creator={pub?.identity_did || ""} 56 - record={pub?.record as PubLeafletPublication.Record} 56 + theme={(pub?.record as PubLeafletPublication.Record)?.theme} 57 57 > 58 58 <PublicationBackgroundProvider 59 - record={pub?.record as PubLeafletPublication.Record} 59 + theme={(pub?.record as PubLeafletPublication.Record)?.theme} 60 60 pub_creator={pub?.identity_did || ""} 61 61 > 62 62 {props.children} ··· 66 66 } 67 67 68 68 export function PublicationBackgroundProvider(props: { 69 - record?: PubLeafletPublication.Record | null; 69 + theme?: PubLeafletPublication.Record["theme"] | null; 70 70 pub_creator: string; 71 71 className?: string; 72 72 children: React.ReactNode; 73 73 }) { 74 - let backgroundImage = props.record?.theme?.backgroundImage?.image?.ref 75 - ? blobRefToSrc( 76 - props.record?.theme?.backgroundImage?.image?.ref, 77 - props.pub_creator, 78 - ) 74 + let backgroundImage = props.theme?.backgroundImage?.image?.ref 75 + ? blobRefToSrc(props.theme?.backgroundImage?.image?.ref, props.pub_creator) 79 76 : null; 80 77 81 - let backgroundImageRepeat = props.record?.theme?.backgroundImage?.repeat; 82 - let backgroundImageSize = props.record?.theme?.backgroundImage?.width || 500; 78 + let backgroundImageRepeat = props.theme?.backgroundImage?.repeat; 79 + let backgroundImageSize = props.theme?.backgroundImage?.width || 500; 83 80 return ( 84 81 <div 85 82 className="PubBackgroundWrapper w-full bg-bg-leaflet text-primary h-full flex flex-col bg-cover bg-center bg-no-repeat items-stretch" ··· 96 93 export function PublicationThemeProvider(props: { 97 94 local?: boolean; 98 95 children: React.ReactNode; 99 - record?: PubLeafletPublication.Record | null; 96 + theme?: PubLeafletPublication.Record["theme"] | null; 100 97 pub_creator: string; 101 98 }) { 102 - let colors = usePubTheme(props.record); 99 + let colors = usePubTheme(props.theme); 103 100 return ( 104 101 <BaseThemeProvider local={props.local} {...colors}> 105 102 {props.children} ··· 107 104 ); 108 105 } 109 106 110 - export const usePubTheme = (record?: PubLeafletPublication.Record | null) => { 111 - let bgLeaflet = useColor(record, "backgroundColor"); 112 - let bgPage = useColor(record, "pageBackground"); 113 - bgPage = record?.theme?.pageBackground ? bgPage : bgLeaflet; 114 - let showPageBackground = record?.theme?.showPageBackground; 107 + export const usePubTheme = ( 108 + theme?: PubLeafletPublication.Record["theme"] | null, 109 + ) => { 110 + let bgLeaflet = useColor(theme, "backgroundColor"); 111 + let bgPage = useColor(theme, "pageBackground"); 112 + bgPage = theme?.pageBackground ? bgPage : bgLeaflet; 113 + let showPageBackground = theme?.showPageBackground; 115 114 116 - let primary = useColor(record, "primary"); 115 + let primary = useColor(theme, "primary"); 117 116 118 - let accent1 = useColor(record, "accentBackground"); 119 - let accent2 = useColor(record, "accentText"); 117 + let accent1 = useColor(theme, "accentBackground"); 118 + let accent2 = useColor(theme, "accentText"); 120 119 121 120 let highlight1 = useEntity(null, "theme/highlight-1")?.data.value; 122 121 let highlight2 = useColorAttribute(null, "theme/highlight-2"); ··· 136 135 }; 137 136 138 137 export const useLocalPubTheme = ( 139 - record: PubLeafletPublication.Record | undefined, 138 + theme: PubLeafletPublication.Record["theme"] | undefined, 140 139 showPageBackground?: boolean, 141 140 ) => { 142 - const pubTheme = usePubTheme(record); 141 + const pubTheme = usePubTheme(theme); 143 142 const [localOverrides, setTheme] = useState<Partial<typeof pubTheme>>({}); 144 143 145 144 const mergedTheme = useMemo(() => {
+4 -2
components/ThemeManager/ThemeProvider.tsx
··· 73 73 return ( 74 74 <PublicationThemeProvider 75 75 {...props} 76 - record={pub.publications?.record as PubLeafletPublication.Record} 76 + theme={(pub.publications?.record as PubLeafletPublication.Record)?.theme} 77 77 pub_creator={pub.publications?.identity_did} 78 78 /> 79 79 ); ··· 339 339 return ( 340 340 <PublicationBackgroundProvider 341 341 pub_creator={pub?.publications.identity_did || ""} 342 - record={pub?.publications.record as PubLeafletPublication.Record} 342 + theme={ 343 + (pub.publications?.record as PubLeafletPublication.Record)?.theme 344 + } 343 345 > 344 346 {props.children} 345 347 </PublicationBackgroundProvider>
+2 -2
components/ThemeManager/ThemeSetter.tsx
··· 70 70 }, [rep, props.entityID]); 71 71 72 72 if (!permission) return null; 73 - if (pub) return null; 73 + if (pub?.publications) return null; 74 74 75 75 return ( 76 76 <> ··· 111 111 }, [rep, props.entityID]); 112 112 113 113 if (!permission) return null; 114 - if (pub) return null; 114 + if (pub?.publications) return null; 115 115 return ( 116 116 <div className="themeSetterContent flex flex-col w-full overflow-y-scroll no-scrollbar"> 117 117 <div className="themeBGLeaflet flex">
+44
components/ThemeManager/colorToLexicons.ts
··· 1 + import { Color } from "react-aria-components"; 2 + 3 + export function ColorToRGBA(color: Color) { 4 + if (!color) 5 + return { 6 + $type: "pub.leaflet.theme.color#rgba" as const, 7 + r: 0, 8 + g: 0, 9 + b: 0, 10 + a: 1, 11 + }; 12 + let c = color.toFormat("rgba"); 13 + const r = c.getChannelValue("red"); 14 + const g = c.getChannelValue("green"); 15 + const b = c.getChannelValue("blue"); 16 + const a = c.getChannelValue("alpha"); 17 + return { 18 + $type: "pub.leaflet.theme.color#rgba" as const, 19 + r: Math.round(r), 20 + g: Math.round(g), 21 + b: Math.round(b), 22 + a: Math.round(a * 100), 23 + }; 24 + } 25 + 26 + export function ColorToRGB(color: Color) { 27 + if (!color) 28 + return { 29 + $type: "pub.leaflet.theme.color#rgb" as const, 30 + r: 0, 31 + g: 0, 32 + b: 0, 33 + }; 34 + let c = color.toFormat("rgb"); 35 + const r = c.getChannelValue("red"); 36 + const g = c.getChannelValue("green"); 37 + const b = c.getChannelValue("blue"); 38 + return { 39 + $type: "pub.leaflet.theme.color#rgb" as const, 40 + r: Math.round(r), 41 + g: Math.round(g), 42 + b: Math.round(b), 43 + }; 44 + }
+5 -1
lexicons/api/lexicons.ts
··· 1408 1408 description: 'Record containing a document', 1409 1409 record: { 1410 1410 type: 'object', 1411 - required: ['pages', 'author', 'title', 'publication'], 1411 + required: ['pages', 'author', 'title'], 1412 1412 properties: { 1413 1413 title: { 1414 1414 type: 'string', ··· 1435 1435 author: { 1436 1436 type: 'string', 1437 1437 format: 'at-identifier', 1438 + }, 1439 + theme: { 1440 + type: 'ref', 1441 + ref: 'lex:pub.leaflet.publication#theme', 1438 1442 }, 1439 1443 pages: { 1440 1444 type: 'array',
+3 -1
lexicons/api/types/pub/leaflet/document.ts
··· 6 6 import { validate as _validate } from '../../../lexicons' 7 7 import { type $Typed, is$typed as _is$typed, type OmitKey } from '../../../util' 8 8 import type * as ComAtprotoRepoStrongRef from '../../com/atproto/repo/strongRef' 9 + import type * as PubLeafletPublication from './publication' 9 10 import type * as PubLeafletPagesLinearDocument from './pages/linearDocument' 10 11 import type * as PubLeafletPagesCanvas from './pages/canvas' 11 12 ··· 19 20 postRef?: ComAtprotoRepoStrongRef.Main 20 21 description?: string 21 22 publishedAt?: string 22 - publication: string 23 + publication?: string 23 24 author: string 25 + theme?: PubLeafletPublication.Theme 24 26 pages: ( 25 27 | $Typed<PubLeafletPagesLinearDocument.Main> 26 28 | $Typed<PubLeafletPagesCanvas.Main>
+5 -2
lexicons/pub/leaflet/document.json
··· 13 13 "required": [ 14 14 "pages", 15 15 "author", 16 - "title", 17 - "publication" 16 + "title" 18 17 ], 19 18 "properties": { 20 19 "title": { ··· 42 41 "author": { 43 42 "type": "string", 44 43 "format": "at-identifier" 44 + }, 45 + "theme": { 46 + "type": "ref", 47 + "ref": "pub.leaflet.publication#theme" 45 48 }, 46 49 "pages": { 47 50 "type": "array",
+2 -1
lexicons/src/document.ts
··· 14 14 description: "Record containing a document", 15 15 record: { 16 16 type: "object", 17 - required: ["pages", "author", "title", "publication"], 17 + required: ["pages", "author", "title"], 18 18 properties: { 19 19 title: { type: "string", maxLength: 1280, maxGraphemes: 128 }, 20 20 postRef: { type: "ref", ref: "com.atproto.repo.strongRef" }, ··· 22 22 publishedAt: { type: "string", format: "datetime" }, 23 23 publication: { type: "string", format: "at-uri" }, 24 24 author: { type: "string", format: "at-identifier" }, 25 + theme: { type: "ref", ref: "pub.leaflet.publication#theme" }, 25 26 pages: { 26 27 type: "array", 27 28 items: {
+50
src/utils/getPublicationMetadataFromLeafletData.ts
··· 1 + import { GetLeafletDataReturnType } from "app/api/rpc/[command]/get_leaflet_data"; 2 + import { Json } from "supabase/database.types"; 3 + 4 + export function getPublicationMetadataFromLeafletData( 5 + data?: GetLeafletDataReturnType["result"]["data"], 6 + ) { 7 + if (!data) return null; 8 + 9 + let pubData: 10 + | { 11 + description: string; 12 + title: string; 13 + leaflet: string; 14 + doc: string | null; 15 + publications: { 16 + identity_did: string; 17 + name: string; 18 + indexed_at: string; 19 + record: Json | null; 20 + uri: string; 21 + } | null; 22 + documents: { 23 + data: Json; 24 + indexed_at: string; 25 + uri: string; 26 + } | null; 27 + } 28 + | undefined 29 + | null = 30 + data?.leaflets_in_publications?.[0] || 31 + data?.permission_token_rights[0].entity_sets?.permission_tokens?.find( 32 + (p) => p.leaflets_in_publications.length, 33 + )?.leaflets_in_publications?.[0]; 34 + 35 + // If not found, check for standalone documents 36 + let standaloneDoc = 37 + data?.leaflets_to_documents?.[0] || 38 + data?.permission_token_rights[0].entity_sets?.permission_tokens.find( 39 + (p) => p.leaflets_to_documents.length, 40 + )?.leaflets_to_documents?.[0]; 41 + if (!pubData && standaloneDoc) { 42 + // Transform standalone document data to match the expected format 43 + pubData = { 44 + ...standaloneDoc, 45 + publications: null, // No publication for standalone docs 46 + doc: standaloneDoc.document, 47 + }; 48 + } 49 + return pubData; 50 + }