a tool for shared writing and social publishing

add subdomain to publications

+321 -98
+2 -6
actions/emailAuth.ts
··· 7 7 import { and, eq } from "drizzle-orm"; 8 8 import { cookies } from "next/headers"; 9 9 import { createIdentity } from "./createIdentity"; 10 + import { setAuthToken } from "src/auth"; 10 11 11 12 async function sendAuthCode(email: string, code: string) { 12 13 if (process.env.NODE_ENV === "development") { ··· 136 137 ) 137 138 .returning(); 138 139 139 - (await cookies()).set("auth_token", confirmedToken.id, { 140 - maxAge: 60 * 60 * 24 * 365, 141 - secure: process.env.NODE_ENV === "production", 142 - httpOnly: true, 143 - sameSite: "lax", 144 - }); 140 + await setAuthToken(confirmedToken.id); 145 141 146 142 client.end(); 147 143 return confirmedToken;
+5 -2
app/[leaflet_id]/Actions.tsx
··· 1 1 import { publishToPublication } from "actions/publishToPublication"; 2 + import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; 2 3 import { ActionButton } from "components/ActionBar/ActionButton"; 3 4 import { ArrowRightTiny } from "components/Icons/ArrowRightTiny"; 4 5 import { GoBackSmall } from "components/Icons/GoBackSmall"; ··· 11 12 import { useParams } from "next/navigation"; 12 13 import { useBlocks } from "src/hooks/queries/useBlocks"; 13 14 import { useEntity, useReplicache } from "src/replicache"; 15 + import { Json } from "supabase/database.types"; 14 16 export const BackToPubButton = (props: { 15 17 publication: { 16 18 identity_did: string; 17 19 indexed_at: string; 18 20 name: string; 21 + record: Json; 19 22 uri: string; 20 23 }; 21 24 }) => { ··· 25 28 let name = props.publication.name; 26 29 return ( 27 30 <Link 28 - href={`/lish/${handle}/${name}/dashboard`} 31 + href={`${getPublicationURL(props.publication)}/dashboard`} 29 32 className="hover:!no-underline" 30 33 > 31 34 <ActionButton ··· 64 67 <div> 65 68 {pub.doc ? "Updated! " : "Published! "} 66 69 <Link 67 - href={`/lish/${pub.publications.identity_did}/${pub.publications.uri}/${doc?.rkey}`} 70 + href={`${getPublicationURL(pub.publications)}/${doc?.rkey}`} 68 71 > 69 72 link 70 73 </Link>
+3 -8
app/api/oauth/[route]/route.ts
··· 6 6 import { NextRequest, NextResponse } from "next/server"; 7 7 import postgres from "postgres"; 8 8 import { createOauthClient } from "src/atproto-oauth"; 9 + import { setAuthToken } from "src/auth"; 9 10 10 11 import { supabaseServerClient } from "supabase/serverClient"; 11 12 ··· 14 15 }; 15 16 export async function GET( 16 17 req: NextRequest, 17 - props: { params: Promise<{ route: string; handle?: string }> } 18 + props: { params: Promise<{ route: string; handle?: string }> }, 18 19 ) { 19 20 const params = await props.params; 20 21 let client = await createOauthClient(); ··· 89 90 .select() 90 91 .single(); 91 92 92 - if (token) 93 - (await cookies()).set("auth_token", token.id, { 94 - maxAge: 60 * 60 * 24 * 365, 95 - secure: process.env.NODE_ENV === "production", 96 - httpOnly: true, 97 - sameSite: "lax", 98 - }); 93 + if (token) await setAuthToken(token.id); 99 94 100 95 // Process successful authentication here 101 96 console.log("authorize() was called with state:", state);
+11 -2
app/home/Publications.tsx
··· 6 6 import { theme } from "tailwind.config"; 7 7 import { BlueskyTiny } from "components/Icons/BlueskyTiny"; 8 8 import { AddTiny } from "components/Icons/AddTiny"; 9 + import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; 10 + import { Json } from "supabase/database.types"; 9 11 10 12 export const MyPublicationList = () => { 11 13 let { identity } = useIdentityData(); ··· 31 33 identity_did: string; 32 34 indexed_at: string; 33 35 name: string; 36 + record: Json; 34 37 uri: string; 35 38 }[]; 36 39 }) => { ··· 43 46 {...d} 44 47 key={d.uri} 45 48 handle={identity?.resolved_did?.alsoKnownAs?.[0].slice(5)!} 49 + record={d.record} 46 50 /> 47 51 ))} 48 52 </div> 49 53 ); 50 54 }; 51 55 52 - function Publication(props: { uri: string; name: string; handle: string }) { 56 + function Publication(props: { 57 + uri: string; 58 + name: string; 59 + handle: string; 60 + record: Json; 61 + }) { 53 62 return ( 54 63 <Link 55 64 className="pubListItem w-full p-3 opaque-container rounded-lg! text-secondary text-center hover:no-underline flex flex-col gap-1 place-items-center transparent-outline outline-2 outline-offset-1 hover:outline-border basis-0 grow min-w-0" 56 - href={`/lish/${props.handle}/${props.name}/dashboard`} 65 + href={`${getPublicationURL(props)}/dashboard`} 57 66 > 58 67 <div className="w-6 h-6 rounded-full bg-test" /> 59 68 <h4 className="font-bold w-full truncate">{props.name}</h4>
+12 -3
app/lish/PostList.tsx
··· 5 5 import { useIdentityData } from "components/IdentityProvider"; 6 6 import { useParams } from "next/navigation"; 7 7 import { AtUri } from "@atproto/syntax"; 8 + import { getPublicationURL } from "./createPub/getPublicationURL"; 8 9 9 10 export const PostList = (props: { 10 11 isFeed?: boolean; 12 + publication: { uri: string; record: Json; name: string }; 11 13 posts: { 12 14 documents: { 13 15 data: Json; ··· 38 40 let uri = new AtUri(post.documents?.uri!); 39 41 40 42 return ( 41 - <PostListItem {...p} key={index} isFeed={props.isFeed} uri={uri} /> 43 + <PostListItem 44 + {...p} 45 + publication_data={props.publication} 46 + key={index} 47 + isFeed={props.isFeed} 48 + uri={uri} 49 + /> 42 50 ); 43 51 })} 44 52 </div> ··· 47 55 48 56 const PostListItem = ( 49 57 props: { 58 + publication_data: { uri: string; record: Json; name: string }; 50 59 isFeed?: boolean; 51 60 uri: AtUri; 52 61 } & PubLeafletDocument.Record, ··· 57 66 <div className="pubPostListItem flex flex-col"> 58 67 {props.isFeed && ( 59 68 <Link 60 - href={`/lish/${identity?.resolved_did?.alsoKnownAs?.[0].slice(5)}/${props.publication}/`} 69 + href={getPublicationURL(props.publication_data)} 61 70 className="font-bold text-tertiary hover:no-underline text-sm " 62 71 > 63 72 {props.publication} ··· 65 74 )} 66 75 67 76 <Link 68 - href={`/lish/${params.handle}/${params.publication}/${props.uri.rkey}/`} 77 + href={`${getPublicationURL(props.publication_data)}/${props.uri.rkey}/`} 69 78 className="pubPostListContent flex flex-col hover:no-underline hover:text-accent-contrast" 70 79 > 71 80 <h4>{props.title}</h4>
+11 -9
app/lish/[handle]/[publication]/[rkey]/page.tsx app/lish/[did]/[publication]/[rkey]/page.tsx
··· 1 1 import Link from "next/link"; 2 - import { IdResolver } from "@atproto/identity"; 3 2 import { supabaseServerClient } from "supabase/serverClient"; 4 3 import { AtUri } from "@atproto/syntax"; 5 4 import { ids } from "lexicons/api/lexicons"; ··· 11 10 PubLeafletPagesLinearDocument, 12 11 } from "lexicons/api"; 13 12 import { Metadata } from "next"; 13 + import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; 14 14 15 - const idResolver = new IdResolver(); 16 15 export async function generateMetadata(props: { 17 - params: Promise<{ publication: string; handle: string; rkey: string }>; 16 + params: Promise<{ publication: string; did: string; rkey: string }>; 18 17 }): Promise<Metadata> { 19 - let did = await idResolver.handle.resolve((await props.params).handle); 18 + let did = decodeURIComponent((await props.params).did); 20 19 if (!did) return { title: "Publication 404" }; 21 20 22 21 let { data: document } = await supabaseServerClient ··· 38 37 }; 39 38 } 40 39 export default async function Post(props: { 41 - params: Promise<{ publication: string; handle: string; rkey: string }>; 40 + params: Promise<{ publication: string; did: string; rkey: string }>; 42 41 }) { 43 - let did = await idResolver.handle.resolve((await props.params).handle); 42 + let did = decodeURIComponent((await props.params).did); 44 43 if (!did) return <div> can't resolve handle</div>; 45 44 let { data: document } = await supabaseServerClient 46 45 .from("documents") 47 - .select("*") 46 + .select("*, documents_in_publications(publications(*))") 48 47 .eq( 49 48 "uri", 50 49 AtUri.make(did, ids.PubLeafletDocument, (await props.params).rkey), 51 50 ) 52 51 .single(); 53 - if (!document?.data) return <div>notfound</div>; 52 + if (!document?.data || !document.documents_in_publications[0].publications) 53 + return <div>notfound</div>; 54 54 let record = document.data as PubLeafletDocument.Record; 55 55 let firstPage = record.pages[0]; 56 56 let blocks: PubLeafletPagesLinearDocument.Block[] = []; ··· 64 64 <div className="flex flex-col pb-8"> 65 65 <Link 66 66 className="font-bold hover:no-underline text-accent-contrast" 67 - href={`/lish/${(await props.params).handle}/${(await props.params).publication}`} 67 + href={getPublicationURL( 68 + document.documents_in_publications[0].publications, 69 + )} 68 70 > 69 71 {decodeURIComponent((await props.params).publication)} 70 72 </Link>
app/lish/[handle]/[publication]/dashboard/Actions.tsx app/lish/[did]/[publication]/dashboard/Actions.tsx
app/lish/[handle]/[publication]/dashboard/DraftList.tsx app/lish/[did]/[publication]/dashboard/DraftList.tsx
app/lish/[handle]/[publication]/dashboard/NewDraftButton.tsx app/lish/[did]/[publication]/dashboard/NewDraftButton.tsx
app/lish/[handle]/[publication]/dashboard/PublicationDashboard.tsx app/lish/[did]/[publication]/dashboard/PublicationDashboard.tsx
app/lish/[handle]/[publication]/dashboard/PublicationSWRProvider.tsx app/lish/[did]/[publication]/dashboard/PublicationSWRProvider.tsx
+2 -1
app/lish/[handle]/[publication]/dashboard/PublishedPostsLists.tsx app/lish/[did]/[publication]/dashboard/PublishedPostsLists.tsx
··· 9 9 import { usePublicationData } from "./PublicationSWRProvider"; 10 10 import { Fragment } from "react"; 11 11 import { useParams } from "next/navigation"; 12 + import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; 12 13 13 14 export function PublishedPostsList() { 14 15 let publication = usePublicationData(); ··· 35 36 <div className="flex w-full "> 36 37 <Link 37 38 target="_blank" 38 - href={`/lish/${params.handle}/${params.publication}/${uri.rkey}`} 39 + href={`${getPublicationURL(publication)}/${uri.rkey}`} 39 40 className="publishedPost grow flex flex-col hover:!no-underline" 40 41 > 41 42 <h3 className="text-primary">{record.title}</h3>
+7 -7
app/lish/[handle]/[publication]/dashboard/page.tsx app/lish/[did]/[publication]/dashboard/page.tsx
··· 20 20 const idResolver = new IdResolver(); 21 21 22 22 export async function generateMetadata(props: { 23 - params: Promise<{ publication: string; handle: string }>; 23 + params: Promise<{ publication: string; did: string }>; 24 24 }): Promise<Metadata> { 25 - let did = await idResolver.handle.resolve((await props.params).handle); 25 + let did = decodeURIComponent((await props.params).did); 26 26 if (!did) return { title: "Publication 404" }; 27 27 28 28 let { result: publication } = await get_publication_data.handler( ··· 38 38 39 39 //This is the admin dashboard of the publication 40 40 export default async function Publication(props: { 41 - params: Promise<{ publication: string; handle: string }>; 41 + params: Promise<{ publication: string; did: string }>; 42 42 }) { 43 43 let params = await props.params; 44 44 let identity = await getIdentityData(); 45 - if (!identity || !identity.atp_did) return <PubNotFound />; 46 - let did = await idResolver.handle.resolve((await props.params).handle); 45 + if (!identity || !identity.atp_did) return <div>not logged in</div>; 46 + let did = decodeURIComponent(params.did); 47 47 if (!did) return <PubNotFound />; 48 48 let { result: publication } = await get_publication_data.handler( 49 49 { ··· 53 53 { supabase: supabaseServerClient }, 54 54 ); 55 55 56 - let record = publication?.record as PubLeafletPublication.Record; 56 + let record = publication?.record as PubLeafletPublication.Record | null; 57 57 if (!publication || identity.atp_did !== publication.identity_did) 58 58 return <PubNotFound />; 59 59 ··· 77 77 > 78 78 <PublicationDashboard 79 79 did={did} 80 - icon={record.icon ? record.icon : null} 80 + icon={record?.icon ? record.icon : null} 81 81 name={publication.name} 82 82 tabs={{ 83 83 Drafts: <DraftList />,
+10 -12
app/lish/[handle]/[publication]/page.tsx app/lish/[did]/[publication]/page.tsx
··· 1 - import { IdResolver } from "@atproto/identity"; 2 1 import { supabaseServerClient } from "supabase/serverClient"; 3 2 import { Metadata } from "next"; 4 3 ··· 8 7 import { AtUri } from "@atproto/syntax"; 9 8 import { PubLeafletDocument, PubLeafletPublication } from "lexicons/api"; 10 9 import Link from "next/link"; 11 - 12 - const idResolver = new IdResolver(); 10 + import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; 13 11 14 12 export async function generateMetadata(props: { 15 - params: Promise<{ publication: string; handle: string }>; 13 + params: Promise<{ publication: string; did: string }>; 16 14 }): Promise<Metadata> { 17 - let did = await idResolver.handle.resolve((await props.params).handle); 15 + let params = await props.params; 16 + let did = decodeURIComponent(params.did); 18 17 if (!did) return { title: "Publication 404" }; 19 18 20 19 let { result: publication } = await get_publication_data.handler( 21 20 { 22 21 did, 23 - publication_name: decodeURIComponent((await props.params).publication), 22 + publication_name: decodeURIComponent(params.publication), 24 23 }, 25 24 { supabase: supabaseServerClient }, 26 25 ); 27 26 if (!publication) return { title: "404 Publication" }; 28 - return { title: decodeURIComponent((await props.params).publication) }; 27 + return { title: decodeURIComponent(params.publication) }; 29 28 } 30 29 31 30 export default async function Publication(props: { 32 - params: Promise<{ publication: string; handle: string }>; 31 + params: Promise<{ publication: string; did: string }>; 33 32 }) { 34 33 let params = await props.params; 35 - let did = await idResolver.handle.resolve((await props.params).handle); 34 + let did = decodeURIComponent(params.did); 36 35 if (!did) return <PubNotFound />; 37 36 let { data: publication } = await supabaseServerClient 38 37 .from("publications") ··· 42 41 `, 43 42 ) 44 43 .eq("identity_did", did) 45 - .eq("name", decodeURIComponent((await props.params).publication)) 44 + .eq("name", decodeURIComponent(params.publication)) 46 45 .single(); 47 46 48 47 let record = publication?.record as PubLeafletPublication.Record; 49 48 50 49 if (!publication) return <PubNotFound />; 51 - console.log(record.icon); 52 50 try { 53 51 return ( 54 52 <ThemeProvider entityID={null}> ··· 93 91 <React.Fragment key={doc.documents?.uri}> 94 92 <div className="flex w-full "> 95 93 <Link 96 - href={`/lish/${params.handle}/${params.publication}/${uri.rkey}`} 94 + href={`${getPublicationURL(publication)}/${uri.rkey}`} 97 95 className="publishedPost grow flex flex-col hover:!no-underline" 98 96 > 99 97 <h3 className="text-primary">{record.title}</h3>
+58 -23
app/lish/createPub/CreatePubForm.tsx
··· 8 8 import { useRouter } from "next/navigation"; 9 9 import { useState, useRef, useEffect } from "react"; 10 10 import { useDebouncedEffect } from "src/hooks/useDebouncedEffect"; 11 - import { set } from "colorjs.io/fn"; 12 11 import { theme } from "tailwind.config"; 12 + import { getPublicationURL } from "./getPublicationURL"; 13 + import { string } from "zod"; 13 14 14 15 export const CreatePubForm = () => { 15 16 let [nameValue, setNameValue] = useState(""); ··· 20 21 let fileInputRef = useRef<HTMLInputElement>(null); 21 22 22 23 let router = useRouter(); 23 - let { identity } = useIdentityData(); 24 24 return ( 25 25 <form 26 26 className="flex flex-col gap-3" 27 27 onSubmit={async (e) => { 28 28 e.preventDefault(); 29 - // Note: You'll need to update the createPublication function to handle the logo file 30 - await createPublication({ 29 + if (!subdomainValidator.safeParse(domainValue).success) return; 30 + let data = await createPublication({ 31 31 name: nameValue, 32 32 description: descriptionValue, 33 33 iconFile: logoFile, 34 + subdomain: domainValue, 34 35 }); 35 - router.push( 36 - `/lish/${identity?.resolved_did?.alsoKnownAs?.[0].slice(5)}/${nameValue}/dashboard`, 37 - ); 36 + if (data?.publication) 37 + router.push(`${getPublicationURL(data.publication)}/dashboard`); 38 38 }} 39 39 > 40 40 <div className="flex flex-col items-center mb-4 gap-2"> ··· 103 103 ); 104 104 }; 105 105 106 + let subdomainValidator = string() 107 + .min(3) 108 + .max(63) 109 + .regex(/^[a-z0-9-]+$/); 106 110 function DomainInput(props: { 107 111 domain: string; 108 112 setDomain: (d: string) => void; 109 113 }) { 110 - let [state, setState] = useState<"empty" | "valid" | "invalid" | "pending">( 111 - "empty", 112 - ); 114 + type DomainState = 115 + | { status: "empty" } 116 + | { status: "valid" } 117 + | { status: "invalid" } 118 + | { status: "pending" } 119 + | { status: "error"; message: string }; 120 + 121 + let [state, setState] = useState<DomainState>({ status: "empty" }); 122 + 113 123 useEffect(() => { 114 124 if (!props.domain) { 115 - setState("empty"); 125 + setState({ status: "empty" }); 116 126 } else { 117 - setState("pending"); 127 + let valid = subdomainValidator.safeParse(props.domain); 128 + if (!valid.success) { 129 + let reason = valid.error.errors[0].code; 130 + setState({ 131 + status: "error", 132 + message: 133 + reason === "too_small" 134 + ? "Must be at least 3 characters long" 135 + : reason === "invalid_string" 136 + ? "Must contain only lowercase letters, numbers, and dashes" 137 + : "", 138 + }); 139 + return; 140 + } 141 + setState({ status: "pending" }); 118 142 } 119 143 }, [props.domain]); 144 + 120 145 useDebouncedEffect( 121 146 async () => { 122 - if (!props.domain) return setState("empty"); 147 + if (!props.domain) return setState({ status: "empty" }); 148 + 149 + let valid = subdomainValidator.safeParse(props.domain); 150 + if (!valid.success) { 151 + return; 152 + } 123 153 let status = await callRPC("get_leaflet_subdomain_status", { 124 154 domain: props.domain, 125 155 }); 126 156 console.log(status); 127 - if (status.error === "Not Found") setState("valid"); 128 - else setState("invalid"); 157 + if (status.error === "Not Found") setState({ status: "valid" }); 158 + else setState({ status: "invalid" }); 129 159 }, 130 160 500, 131 161 [props.domain], 132 162 ); 163 + 133 164 return ( 134 165 <div className="flex flex-col gap-1"> 135 166 <label className=" input-with-border flex flex-col text-sm text-tertiary font-bold italic leading-tight !py-1 !px-[6px]"> 136 167 <div>Domain</div> 137 168 <div className="flex flex-row items-center"> 138 169 <Input 170 + minLength={3} 171 + maxLength={63} 139 172 placeholder="domain" 140 173 className="appearance-none w-full font-normal bg-transparent text-base text-primary focus:outline-0 outline-none" 141 174 value={props.domain} ··· 147 180 <div 148 181 className={"text-sm italic "} 149 182 style={{ 150 - fontWeight: state === "valid" ? "bold" : "normal", 183 + fontWeight: state.status === "valid" ? "bold" : "normal", 151 184 color: 152 - state === "valid" 185 + state.status === "valid" 153 186 ? theme.colors["accent-contrast"] 154 187 : theme.colors.tertiary, 155 188 }} 156 189 > 157 - {state === "valid" 190 + {state.status === "valid" 158 191 ? "Available!" 159 - : state === "invalid" 160 - ? "Already Taken ):" 161 - : state === "pending" 162 - ? "Checking Availability..." 163 - : "Choose a domain!"} 192 + : state.status === "error" 193 + ? state.message 194 + : state.status === "invalid" 195 + ? "Already Taken ):" 196 + : state.status === "pending" 197 + ? "Checking Availability..." 198 + : "Choose a domain! Numbers, characters, and hyphens only!"} 164 199 </div> 165 200 </div> 166 201 );
+50 -7
app/lish/createPub/createPublication.ts
··· 6 6 import { supabaseServerClient } from "supabase/serverClient"; 7 7 import { Un$Typed } from "@atproto/api"; 8 8 import { Json } from "supabase/database.types"; 9 + import { Vercel } from "@vercel/sdk"; 10 + import { isProductionDomain } from "src/utils/isProductionDeployment"; 11 + import { string } from "zod"; 9 12 13 + const VERCEL_TOKEN = process.env.VERCEL_TOKEN; 14 + const vercel = new Vercel({ 15 + bearerToken: VERCEL_TOKEN, 16 + }); 17 + let subdomainValidator = string() 18 + .min(3) 19 + .max(63) 20 + .regex(/^[a-z0-9-]+$/); 10 21 export async function createPublication({ 11 22 name, 12 23 description, 13 24 iconFile, 25 + subdomain, 14 26 }: { 15 27 name: string; 16 28 description: string; 17 29 iconFile: File | null; 30 + subdomain: string; 18 31 }) { 32 + let isSubdomainValid = subdomainValidator.safeParse(subdomain); 33 + if (!isSubdomainValid.success) { 34 + return { success: false }; 35 + } 19 36 const oauthClient = await createOauthClient(); 20 37 let identity = await getIdentityData(); 21 38 if (!identity || !identity.atp_did) return; 39 + 40 + let domain = `${subdomain}.leaflet.pub`; 41 + 22 42 let credentialSession = await oauthClient.restore(identity.atp_did); 23 43 let agent = new AtpBaseClient( 24 44 credentialSession.fetchHandler.bind(credentialSession), 25 45 ); 26 46 let record: Un$Typed<PubLeafletPublication.Record> = { 27 47 name, 48 + base_path: domain, 28 49 }; 29 50 30 51 if (description) { ··· 50 71 ); 51 72 52 73 //optimistically write to our db! 53 - await supabaseServerClient.from("publications").upsert({ 54 - uri: result.uri, 55 - identity_did: credentialSession.did!, 56 - name: record.name, 57 - record: record as Json, 58 - }); 74 + let { data: publication } = await supabaseServerClient 75 + .from("publications") 76 + .upsert({ 77 + uri: result.uri, 78 + identity_did: credentialSession.did!, 79 + name: record.name, 80 + record: record as Json, 81 + }) 82 + .select() 83 + .single(); 59 84 60 - return { success: true, name }; 85 + // Create the custom domain 86 + if (isProductionDomain()) { 87 + console.log("Creating domain! " + domain); 88 + await vercel.projects.addProjectDomain({ 89 + idOrName: "prj_9jX4tmYCISnm176frFxk07fF74kG", 90 + teamId: "team_42xaJiZMTw9Sr7i0DcLTae9d", 91 + requestBody: { 92 + name: domain + ".leaflet.pub", 93 + }, 94 + }); 95 + } 96 + await supabaseServerClient 97 + .from("custom_domains") 98 + .insert({ domain, identity: identity.id, confirmed: true }); 99 + await supabaseServerClient 100 + .from("publication_domains") 101 + .insert({ domain, publication: result.uri }); 102 + 103 + return { success: true, publication }; 61 104 }
+17
app/lish/createPub/getPublicationURL.ts
··· 1 + import { AtUri } from "@atproto/syntax"; 2 + import { PubLeafletPublication } from "lexicons/api"; 3 + import { isProductionDomain } from "src/utils/isProductionDeployment"; 4 + import { Json } from "supabase/database.types"; 5 + 6 + export function getPublicationURL(pub: { 7 + uri: string; 8 + name: string; 9 + record: Json; 10 + }) { 11 + let record = pub.record as PubLeafletPublication.Record; 12 + if (isProductionDomain() && record?.base_path) { 13 + return new URL(record.base_path); 14 + } 15 + let aturi = new AtUri(pub.uri); 16 + return `/lish/${aturi.host}/${record?.name || pub.name}`; 17 + }
+11 -7
app/lish/createPub/page.tsx
··· 1 + import { ThemeProvider } from "components/ThemeManager/ThemeProvider"; 1 2 import { CreatePubForm } from "./CreatePubForm"; 2 3 3 4 export default async function CreatePub() { 4 5 return ( 5 - <div className="createPubPage relative w-full h-screen flex items-stretch bg-bg-leaflet p-4"> 6 - <div className="createPubContent h-full flex items-center max-w-sm w-full mx-auto "> 7 - <div className="createPubFormWrapper h-fit w-full flex flex-col gap-4"> 8 - <h2 className="text-center">Create Your Publication!</h2> 9 - <div className="container w-full p-3"> 10 - <CreatePubForm /> 6 + // Eventually this can pull from home theme? 7 + <ThemeProvider entityID={null}> 8 + <div className="createPubPage relative w-full h-screen flex items-stretch bg-bg-leaflet p-4"> 9 + <div className="createPubContent h-full flex items-center max-w-sm w-full mx-auto "> 10 + <div className="createPubFormWrapper h-fit w-full flex flex-col gap-4"> 11 + <h2 className="text-center">Create Your Publication!</h2> 12 + <div className="container w-full p-3"> 13 + <CreatePubForm /> 14 + </div> 11 15 </div> 12 16 </div> 13 17 </div> 14 - </div> 18 + </ThemeProvider> 15 19 ); 16 20 }
+4 -3
components/Pages/PublicationMetadata.tsx
··· 11 11 import { AtUri } from "@atproto/syntax"; 12 12 import { PubLeafletDocument } from "lexicons/api"; 13 13 import { publications } from "drizzle/schema"; 14 + import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; 14 15 export const PublicationMetadata = ({ 15 16 cardBorderHidden, 16 17 }: { ··· 24 25 let [descriptionState, setDescriptionState] = useState( 25 26 pub?.description || "", 26 27 ); 27 - let record = pub.documents?.data as PubLeafletDocument.Record | null; 28 + let record = pub?.documents?.data as PubLeafletDocument.Record | null; 28 29 let publishedAt = record?.publishedAt; 29 30 30 31 useEffect(() => { ··· 55 56 > 56 57 <div className="flex gap-2"> 57 58 <Link 58 - href={`/lish/${identity?.resolved_did?.alsoKnownAs?.[0].slice(5)}/${pub.publications.name}/dashboard`} 59 + href={`${getPublicationURL(pub.publications)}/dashboard`} 59 60 className="text-accent-contrast font-bold hover:no-underline" 60 61 > 61 62 {pub.publications?.name} ··· 98 99 <Link 99 100 target="_blank" 100 101 className="text-sm" 101 - href={`/lish/${identity?.resolved_did?.alsoKnownAs?.[0].slice(5)}/${pub.publications.name}/${new AtUri(pub.doc).rkey}`} 102 + href={`${getPublicationURL(pub.publications)}/${new AtUri(pub.doc).rkey}`} 102 103 > 103 104 View Post 104 105 </Link>
+1 -1
components/ShareOptions/index.tsx
··· 57 57 icon=<ShareSmall /> 58 58 primary={!!!pub} 59 59 secondary={!!pub} 60 - label={`Share ${pub && "Draft"}`} 60 + label={`Share ${pub ? "Draft" : ""}`} 61 61 /> 62 62 } 63 63 >
+19 -6
drizzle/relations.ts
··· 1 1 import { relations } from "drizzle-orm/relations"; 2 - import { entities, facts, entity_sets, permission_tokens, identities, email_subscriptions_to_entity, email_auth_tokens, phone_rsvps_to_entity, custom_domains, custom_domain_routes, poll_votes_on_entity, subscribers_to_publications, publications, permission_token_on_homepage, documents, documents_in_publications, leaflets_in_publications, permission_token_rights } from "./schema"; 2 + import { entities, facts, entity_sets, permission_tokens, identities, email_subscriptions_to_entity, email_auth_tokens, phone_rsvps_to_entity, custom_domains, custom_domain_routes, poll_votes_on_entity, publication_domains, publications, subscribers_to_publications, permission_token_on_homepage, documents, documents_in_publications, leaflets_in_publications, permission_token_rights } from "./schema"; 3 3 4 4 export const factsRelations = relations(facts, ({one}) => ({ 5 5 entity: one(entities, { ··· 107 107 fields: [custom_domains.identity], 108 108 references: [identities.email] 109 109 }), 110 + publication_domains: many(publication_domains), 110 111 })); 111 112 112 113 export const poll_votes_on_entityRelations = relations(poll_votes_on_entity, ({one}) => ({ ··· 122 123 }), 123 124 })); 124 125 125 - export const subscribers_to_publicationsRelations = relations(subscribers_to_publications, ({one}) => ({ 126 - identity: one(identities, { 127 - fields: [subscribers_to_publications.identity], 128 - references: [identities.email] 126 + export const publication_domainsRelations = relations(publication_domains, ({one}) => ({ 127 + custom_domain: one(custom_domains, { 128 + fields: [publication_domains.domain], 129 + references: [custom_domains.domain] 129 130 }), 130 131 publication: one(publications, { 131 - fields: [subscribers_to_publications.publication], 132 + fields: [publication_domains.publication], 132 133 references: [publications.uri] 133 134 }), 134 135 })); 135 136 136 137 export const publicationsRelations = relations(publications, ({many}) => ({ 138 + publication_domains: many(publication_domains), 137 139 subscribers_to_publications: many(subscribers_to_publications), 138 140 documents_in_publications: many(documents_in_publications), 139 141 leaflets_in_publications: many(leaflets_in_publications), 142 + })); 143 + 144 + export const subscribers_to_publicationsRelations = relations(subscribers_to_publications, ({one}) => ({ 145 + identity: one(identities, { 146 + fields: [subscribers_to_publications.identity], 147 + references: [identities.email] 148 + }), 149 + publication: one(publications, { 150 + fields: [subscribers_to_publications.publication], 151 + references: [publications.uri] 152 + }), 140 153 })); 141 154 142 155 export const permission_token_on_homepageRelations = relations(permission_token_on_homepage, ({one}) => ({
+11
drizzle/schema.ts
··· 160 160 voter_token: uuid("voter_token").notNull(), 161 161 }); 162 162 163 + export const publication_domains = pgTable("publication_domains", { 164 + publication: text("publication").notNull().references(() => publications.uri, { onDelete: "cascade" } ), 165 + domain: text("domain").notNull().references(() => custom_domains.domain, { onDelete: "cascade" } ), 166 + created_at: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), 167 + }, 168 + (table) => { 169 + return { 170 + publication_domains_pkey: primaryKey({ columns: [table.publication, table.domain], name: "publication_domains_pkey"}), 171 + } 172 + }); 173 + 163 174 export const subscribers_to_publications = pgTable("subscribers_to_publications", { 164 175 identity: text("identity").notNull().references(() => identities.email, { onUpdate: "cascade" } ), 165 176 publication: text("publication").notNull().references(() => publications.uri),
+1 -1
middleware.ts
··· 25 25 if (req.nextUrl.pathname === "/not-found") return; 26 26 let { data: routes } = await supabase 27 27 .from("custom_domains") 28 - .select("*, custom_domain_routes(*)") 28 + .select("*, custom_domain_routes(*), publication_domains(*)") 29 29 .eq("domain", hostname) 30 30 .single(); 31 31 if (routes) {
+13
src/auth.ts
··· 1 + import { cookies } from "next/headers"; 2 + import { isProductionDomain } from "./utils/isProductionDeployment"; 3 + 4 + export async function setAuthToken(tokenID: string) { 5 + let c = await cookies(); 6 + c.set("auth_token", tokenID, { 7 + maxAge: 60 * 60 * 24 * 365, 8 + secure: process.env.NODE_ENV === "production", 9 + domain: isProductionDomain() ? "leaflet.pub" : undefined, 10 + httpOnly: true, 11 + sameSite: "lax", 12 + }); 13 + }
+7
src/utils/isProductionDeployment.ts
··· 1 + export function isProductionDomain() { 2 + let url = 3 + process.env.NEXT_PUBLIC_VERCEL_URL || 4 + process.env.VERCEL_URL || 5 + "http://localhost:3000"; 6 + return process.env.NODE_ENV === "production" && url.includes("leaflet.pub"); 7 + }
+33
supabase/database.types.ts
··· 641 641 }, 642 642 ] 643 643 } 644 + publication_domains: { 645 + Row: { 646 + created_at: string 647 + domain: string 648 + publication: string 649 + } 650 + Insert: { 651 + created_at?: string 652 + domain: string 653 + publication: string 654 + } 655 + Update: { 656 + created_at?: string 657 + domain?: string 658 + publication?: string 659 + } 660 + Relationships: [ 661 + { 662 + foreignKeyName: "publication_domains_domain_fkey" 663 + columns: ["domain"] 664 + isOneToOne: false 665 + referencedRelation: "custom_domains" 666 + referencedColumns: ["domain"] 667 + }, 668 + { 669 + foreignKeyName: "publication_domains_publication_fkey" 670 + columns: ["publication"] 671 + isOneToOne: false 672 + referencedRelation: "publications" 673 + referencedColumns: ["uri"] 674 + }, 675 + ] 676 + } 644 677 publications: { 645 678 Row: { 646 679 identity_did: string
+33
supabase/migrations/20250520190442_add_publication_domains_table.sql
··· 1 + create table "public"."publication_domains" ( 2 + "publication" text not null, 3 + "domain" text not null, 4 + "created_at" timestamp with time zone not null default now() 5 + ); 6 + alter table "public"."publication_domains" enable row level security; 7 + CREATE UNIQUE INDEX publication_domains_pkey ON public.publication_domains USING btree (publication, domain); 8 + alter table "public"."publication_domains" add constraint "publication_domains_pkey" PRIMARY KEY using index "publication_domains_pkey"; 9 + alter table "public"."publication_domains" add constraint "publication_domains_domain_fkey" FOREIGN KEY (domain) REFERENCES custom_domains(domain) ON DELETE CASCADE not valid; 10 + alter table "public"."publication_domains" validate constraint "publication_domains_domain_fkey"; 11 + alter table "public"."publication_domains" add constraint "publication_domains_publication_fkey" FOREIGN KEY (publication) REFERENCES publications(uri) ON DELETE CASCADE not valid; 12 + alter table "public"."publication_domains" validate constraint "publication_domains_publication_fkey"; 13 + grant delete on table "public"."publication_domains" to "anon"; 14 + grant insert on table "public"."publication_domains" to "anon"; 15 + grant references on table "public"."publication_domains" to "anon"; 16 + grant select on table "public"."publication_domains" to "anon"; 17 + grant trigger on table "public"."publication_domains" to "anon"; 18 + grant truncate on table "public"."publication_domains" to "anon"; 19 + grant update on table "public"."publication_domains" to "anon"; 20 + grant delete on table "public"."publication_domains" to "authenticated"; 21 + grant insert on table "public"."publication_domains" to "authenticated"; 22 + grant references on table "public"."publication_domains" to "authenticated"; 23 + grant select on table "public"."publication_domains" to "authenticated"; 24 + grant trigger on table "public"."publication_domains" to "authenticated"; 25 + grant truncate on table "public"."publication_domains" to "authenticated"; 26 + grant update on table "public"."publication_domains" to "authenticated"; 27 + grant delete on table "public"."publication_domains" to "service_role"; 28 + grant insert on table "public"."publication_domains" to "service_role"; 29 + grant references on table "public"."publication_domains" to "service_role"; 30 + grant select on table "public"."publication_domains" to "service_role"; 31 + grant trigger on table "public"."publication_domains" to "service_role"; 32 + grant truncate on table "public"."publication_domains" to "service_role"; 33 + grant update on table "public"."publication_domains" to "service_role";