a tool for shared writing and social publishing
0

Configure Feed

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

add subdomain to publications

Jared Pereira (May 20, 2025, 3:05 PM EDT) 303000ca 0342e797

+1023 -800
+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) {
+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;
+20 -7
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 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] 130 + }), 131 + publication: one(publications, { 132 + fields: [publication_domains.publication], 133 + references: [publications.uri] 134 + }), 135 + })); 136 + 137 + export const publicationsRelations = relations(publications, ({many}) => ({ 138 + publication_domains: many(publication_domains), 139 + subscribers_to_publications: many(subscribers_to_publications), 140 + documents_in_publications: many(documents_in_publications), 141 + leaflets_in_publications: many(leaflets_in_publications), 142 + })); 143 + 125 144 export const subscribers_to_publicationsRelations = relations(subscribers_to_publications, ({one}) => ({ 126 145 identity: one(identities, { 127 146 fields: [subscribers_to_publications.identity], ··· 131 150 fields: [subscribers_to_publications.publication], 132 151 references: [publications.uri] 133 152 }), 134 - })); 135 - 136 - export const publicationsRelations = relations(publications, ({many}) => ({ 137 - subscribers_to_publications: many(subscribers_to_publications), 138 - documents_in_publications: many(documents_in_publications), 139 - leaflets_in_publications: many(leaflets_in_publications), 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),
+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 + }
+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
+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>
+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>
+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 >
+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/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";
+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 }
+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);
+131
app/lish/[did]/[publication]/page.tsx
··· 1 + import { supabaseServerClient } from "supabase/serverClient"; 2 + import { Metadata } from "next"; 3 + 4 + import { ThemeProvider } from "components/ThemeManager/ThemeProvider"; 5 + import React from "react"; 6 + import { get_publication_data } from "app/api/rpc/[command]/get_publication_data"; 7 + import { AtUri } from "@atproto/syntax"; 8 + import { PubLeafletDocument, PubLeafletPublication } from "lexicons/api"; 9 + import Link from "next/link"; 10 + import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; 11 + 12 + export async function generateMetadata(props: { 13 + params: Promise<{ publication: string; did: string }>; 14 + }): Promise<Metadata> { 15 + let params = await props.params; 16 + let did = decodeURIComponent(params.did); 17 + if (!did) return { title: "Publication 404" }; 18 + 19 + let { result: publication } = await get_publication_data.handler( 20 + { 21 + did, 22 + publication_name: decodeURIComponent(params.publication), 23 + }, 24 + { supabase: supabaseServerClient }, 25 + ); 26 + if (!publication) return { title: "404 Publication" }; 27 + return { title: decodeURIComponent(params.publication) }; 28 + } 29 + 30 + export default async function Publication(props: { 31 + params: Promise<{ publication: string; did: string }>; 32 + }) { 33 + let params = await props.params; 34 + let did = decodeURIComponent(params.did); 35 + if (!did) return <PubNotFound />; 36 + let { data: publication } = await supabaseServerClient 37 + .from("publications") 38 + .select( 39 + `*, 40 + documents_in_publications(documents(*)) 41 + `, 42 + ) 43 + .eq("identity_did", did) 44 + .eq("name", decodeURIComponent(params.publication)) 45 + .single(); 46 + 47 + let record = publication?.record as PubLeafletPublication.Record; 48 + 49 + if (!publication) return <PubNotFound />; 50 + try { 51 + return ( 52 + <ThemeProvider entityID={null}> 53 + <div className="publicationWrapper w-screen h-screen flex place-items-center bg-[#FDFCFA]"> 54 + <div className="publication max-w-prose w-full mx-auto h-full pt-8 px-2 pb-12 sm:pb-8"> 55 + <div className="flex flex-col pb-6 w-full text-center justify-center "> 56 + <div className="flex flex-row gap-3 justify-center"> 57 + {record.icon && ( 58 + <div 59 + className="shrink-0 w-8 h-8 rounded-full mt-1" 60 + style={{ 61 + backgroundImage: `url(https://bsky.social/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${(record.icon.ref as unknown as { $link: string })["$link"]})`, 62 + backgroundRepeat: "no-repeat", 63 + backgroundPosition: "center", 64 + backgroundSize: "cover", 65 + }} 66 + /> 67 + )} 68 + <h2 className="text-accent-contrast">{publication.name}</h2> 69 + </div> 70 + <p className="text-lg text-tertiary">{record.description} </p> 71 + </div> 72 + <div className="publicationPostList w-full flex flex-col gap-4 pb-6"> 73 + {publication.documents_in_publications 74 + .filter((d) => !!d?.documents) 75 + .sort((a, b) => { 76 + let aRecord = a.documents?.data! as PubLeafletDocument.Record; 77 + let bRecord = a.documents?.data! as PubLeafletDocument.Record; 78 + const aDate = aRecord.publishedAt 79 + ? new Date(aRecord.publishedAt) 80 + : new Date(0); 81 + const bDate = bRecord.publishedAt 82 + ? new Date(bRecord.publishedAt) 83 + : new Date(0); 84 + return bDate.getTime() - aDate.getTime(); // Sort by most recent first 85 + }) 86 + .map((doc) => { 87 + if (!doc.documents) return null; 88 + let uri = new AtUri(doc.documents.uri); 89 + let record = doc.documents.data as PubLeafletDocument.Record; 90 + return ( 91 + <React.Fragment key={doc.documents?.uri}> 92 + <div className="flex w-full "> 93 + <Link 94 + href={`${getPublicationURL(publication)}/${uri.rkey}`} 95 + className="publishedPost grow flex flex-col hover:!no-underline" 96 + > 97 + <h3 className="text-primary">{record.title}</h3> 98 + <p className="italic text-secondary"> 99 + {record.description} 100 + </p> 101 + <p className="text-sm text-tertiary pt-2"> 102 + {record.publishedAt && 103 + new Date(record.publishedAt).toLocaleDateString( 104 + undefined, 105 + { 106 + year: "numeric", 107 + month: "long", 108 + day: "2-digit", 109 + }, 110 + )}{" "} 111 + </p> 112 + </Link> 113 + </div> 114 + <hr className="last:hidden border-border-light" /> 115 + </React.Fragment> 116 + ); 117 + })} 118 + </div> 119 + </div> 120 + </div> 121 + </ThemeProvider> 122 + ); 123 + } catch (e) { 124 + console.log(e); 125 + return <pre>{JSON.stringify(e, undefined, 2)}</pre>; 126 + } 127 + } 128 + 129 + const PubNotFound = () => { 130 + return <div>ain't no pub here</div>; 131 + };
-133
app/lish/[handle]/[publication]/page.tsx
··· 1 - import { IdResolver } from "@atproto/identity"; 2 - import { supabaseServerClient } from "supabase/serverClient"; 3 - import { Metadata } from "next"; 4 - 5 - import { ThemeProvider } from "components/ThemeManager/ThemeProvider"; 6 - import React from "react"; 7 - import { get_publication_data } from "app/api/rpc/[command]/get_publication_data"; 8 - import { AtUri } from "@atproto/syntax"; 9 - import { PubLeafletDocument, PubLeafletPublication } from "lexicons/api"; 10 - import Link from "next/link"; 11 - 12 - const idResolver = new IdResolver(); 13 - 14 - export async function generateMetadata(props: { 15 - params: Promise<{ publication: string; handle: string }>; 16 - }): Promise<Metadata> { 17 - let did = await idResolver.handle.resolve((await props.params).handle); 18 - if (!did) return { title: "Publication 404" }; 19 - 20 - let { result: publication } = await get_publication_data.handler( 21 - { 22 - did, 23 - publication_name: decodeURIComponent((await props.params).publication), 24 - }, 25 - { supabase: supabaseServerClient }, 26 - ); 27 - if (!publication) return { title: "404 Publication" }; 28 - return { title: decodeURIComponent((await props.params).publication) }; 29 - } 30 - 31 - export default async function Publication(props: { 32 - params: Promise<{ publication: string; handle: string }>; 33 - }) { 34 - let params = await props.params; 35 - let did = await idResolver.handle.resolve((await props.params).handle); 36 - if (!did) return <PubNotFound />; 37 - let { data: publication } = await supabaseServerClient 38 - .from("publications") 39 - .select( 40 - `*, 41 - documents_in_publications(documents(*)) 42 - `, 43 - ) 44 - .eq("identity_did", did) 45 - .eq("name", decodeURIComponent((await props.params).publication)) 46 - .single(); 47 - 48 - let record = publication?.record as PubLeafletPublication.Record; 49 - 50 - if (!publication) return <PubNotFound />; 51 - console.log(record.icon); 52 - try { 53 - return ( 54 - <ThemeProvider entityID={null}> 55 - <div className="publicationWrapper w-screen h-screen flex place-items-center bg-[#FDFCFA]"> 56 - <div className="publication max-w-prose w-full mx-auto h-full pt-8 px-2 pb-12 sm:pb-8"> 57 - <div className="flex flex-col pb-6 w-full text-center justify-center "> 58 - <div className="flex flex-row gap-3 justify-center"> 59 - {record.icon && ( 60 - <div 61 - className="shrink-0 w-8 h-8 rounded-full mt-1" 62 - style={{ 63 - backgroundImage: `url(https://bsky.social/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${(record.icon.ref as unknown as { $link: string })["$link"]})`, 64 - backgroundRepeat: "no-repeat", 65 - backgroundPosition: "center", 66 - backgroundSize: "cover", 67 - }} 68 - /> 69 - )} 70 - <h2 className="text-accent-contrast">{publication.name}</h2> 71 - </div> 72 - <p className="text-lg text-tertiary">{record.description} </p> 73 - </div> 74 - <div className="publicationPostList w-full flex flex-col gap-4 pb-6"> 75 - {publication.documents_in_publications 76 - .filter((d) => !!d?.documents) 77 - .sort((a, b) => { 78 - let aRecord = a.documents?.data! as PubLeafletDocument.Record; 79 - let bRecord = a.documents?.data! as PubLeafletDocument.Record; 80 - const aDate = aRecord.publishedAt 81 - ? new Date(aRecord.publishedAt) 82 - : new Date(0); 83 - const bDate = bRecord.publishedAt 84 - ? new Date(bRecord.publishedAt) 85 - : new Date(0); 86 - return bDate.getTime() - aDate.getTime(); // Sort by most recent first 87 - }) 88 - .map((doc) => { 89 - if (!doc.documents) return null; 90 - let uri = new AtUri(doc.documents.uri); 91 - let record = doc.documents.data as PubLeafletDocument.Record; 92 - return ( 93 - <React.Fragment key={doc.documents?.uri}> 94 - <div className="flex w-full "> 95 - <Link 96 - href={`/lish/${params.handle}/${params.publication}/${uri.rkey}`} 97 - className="publishedPost grow flex flex-col hover:!no-underline" 98 - > 99 - <h3 className="text-primary">{record.title}</h3> 100 - <p className="italic text-secondary"> 101 - {record.description} 102 - </p> 103 - <p className="text-sm text-tertiary pt-2"> 104 - {record.publishedAt && 105 - new Date(record.publishedAt).toLocaleDateString( 106 - undefined, 107 - { 108 - year: "numeric", 109 - month: "long", 110 - day: "2-digit", 111 - }, 112 - )}{" "} 113 - </p> 114 - </Link> 115 - </div> 116 - <hr className="last:hidden border-border-light" /> 117 - </React.Fragment> 118 - ); 119 - })} 120 - </div> 121 - </div> 122 - </div> 123 - </ThemeProvider> 124 - ); 125 - } catch (e) { 126 - console.log(e); 127 - return <pre>{JSON.stringify(e, undefined, 2)}</pre>; 128 - } 129 - } 130 - 131 - const PubNotFound = () => { 132 - return <div>ain't no pub here</div>; 133 - };
+138
app/lish/[did]/[publication]/[rkey]/page.tsx
··· 1 + import Link from "next/link"; 2 + import { supabaseServerClient } from "supabase/serverClient"; 3 + import { AtUri } from "@atproto/syntax"; 4 + import { ids } from "lexicons/api/lexicons"; 5 + import { 6 + PubLeafletBlocksHeader, 7 + PubLeafletBlocksImage, 8 + PubLeafletBlocksText, 9 + PubLeafletDocument, 10 + PubLeafletPagesLinearDocument, 11 + } from "lexicons/api"; 12 + import { Metadata } from "next"; 13 + import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; 14 + 15 + export async function generateMetadata(props: { 16 + params: Promise<{ publication: string; did: string; rkey: string }>; 17 + }): Promise<Metadata> { 18 + let did = decodeURIComponent((await props.params).did); 19 + if (!did) return { title: "Publication 404" }; 20 + 21 + let { data: document } = await supabaseServerClient 22 + .from("documents") 23 + .select("*") 24 + .eq( 25 + "uri", 26 + AtUri.make(did, ids.PubLeafletDocument, (await props.params).rkey), 27 + ) 28 + .single(); 29 + 30 + if (!document) return { title: "404" }; 31 + let record = document.data as PubLeafletDocument.Record; 32 + return { 33 + title: 34 + record.title + 35 + " - " + 36 + decodeURIComponent((await props.params).publication), 37 + }; 38 + } 39 + export default async function Post(props: { 40 + params: Promise<{ publication: string; did: string; rkey: string }>; 41 + }) { 42 + let did = decodeURIComponent((await props.params).did); 43 + if (!did) return <div> can't resolve handle</div>; 44 + let { data: document } = await supabaseServerClient 45 + .from("documents") 46 + .select("*, documents_in_publications(publications(*))") 47 + .eq( 48 + "uri", 49 + AtUri.make(did, ids.PubLeafletDocument, (await props.params).rkey), 50 + ) 51 + .single(); 52 + if (!document?.data || !document.documents_in_publications[0].publications) 53 + return <div>notfound</div>; 54 + let record = document.data as PubLeafletDocument.Record; 55 + let firstPage = record.pages[0]; 56 + let blocks: PubLeafletPagesLinearDocument.Block[] = []; 57 + if (PubLeafletPagesLinearDocument.isMain(firstPage)) { 58 + blocks = firstPage.blocks || []; 59 + } 60 + return ( 61 + <div className="postPage w-full h-screen bg-[#FDFCFA] flex items-stretch"> 62 + <div className="pubWrapper flex flex-col w-full "> 63 + <div className="pubContent flex flex-col px-3 sm:px-4 py-3 sm:py-9 mx-auto max-w-prose h-full w-full overflow-auto"> 64 + <div className="flex flex-col pb-8"> 65 + <Link 66 + className="font-bold hover:no-underline text-accent-contrast" 67 + href={getPublicationURL( 68 + document.documents_in_publications[0].publications, 69 + )} 70 + > 71 + {decodeURIComponent((await props.params).publication)} 72 + </Link> 73 + <h2 className="">{record.title}</h2> 74 + {record.description ? ( 75 + <p className="italic text-secondary">{record.description}</p> 76 + ) : null} 77 + {record.publishedAt ? ( 78 + <p className="text-sm text-tertiary pt-3"> 79 + Published{" "} 80 + {new Date(record.publishedAt).toLocaleDateString(undefined, { 81 + year: "numeric", 82 + month: "long", 83 + day: "2-digit", 84 + })} 85 + </p> 86 + ) : null} 87 + </div> 88 + {blocks.map((b, index) => { 89 + switch (true) { 90 + case PubLeafletBlocksImage.isMain(b.block): { 91 + return ( 92 + <img 93 + key={index} 94 + height={b.block.aspectRatio?.height} 95 + width={b.block.aspectRatio?.width} 96 + className="pb-2 sm:pb-3" 97 + src={`https://bsky.social/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${(b.block.image.ref as unknown as { $link: string })["$link"]}`} 98 + /> 99 + ); 100 + } 101 + case PubLeafletBlocksText.isMain(b.block): 102 + return ( 103 + <p key={index} className="pt-0 pb-2 sm:pb-3"> 104 + {b.block.plaintext} 105 + </p> 106 + ); 107 + case PubLeafletBlocksHeader.isMain(b.block): { 108 + if (b.block.level === 1) 109 + return ( 110 + <h1 key={index} className="pb-0 pt-2 sm:pt-3"> 111 + {b.block.plaintext} 112 + </h1> 113 + ); 114 + if (b.block.level === 2) 115 + return ( 116 + <h3 key={index} className="pb-0 pt-2 sm:pt-3"> 117 + {b.block.plaintext} 118 + </h3> 119 + ); 120 + if (b.block.level === 3) 121 + return ( 122 + <h4 key={index} className="pb-0 pt-2 sm:pt-3"> 123 + {b.block.plaintext} 124 + </h4> 125 + ); 126 + // if (b.block.level === 4) return <h4>{b.block.plaintext}</h4>; 127 + // if (b.block.level === 5) return <h5>{b.block.plaintext}</h5>; 128 + return <h6 key={index}>{b.block.plaintext}</h6>; 129 + } 130 + default: 131 + return null; 132 + } 133 + })} 134 + </div> 135 + </div> 136 + </div> 137 + ); 138 + }
+97
app/lish/[did]/[publication]/dashboard/Actions.tsx
··· 1 + "use client"; 2 + 3 + import { Media } from "components/Media"; 4 + import { NewDraftActionButton } from "./NewDraftButton"; 5 + import { ActionButton } from "components/ActionBar/ActionButton"; 6 + import { useRouter } from "next/navigation"; 7 + import { Popover } from "components/Popover"; 8 + import { SettingsSmall } from "components/Icons/SettingsSmall"; 9 + import { CreatePubForm } from "app/lish/createPub/CreatePubForm"; 10 + import { ShareSmall } from "components/Icons/ShareSmall"; 11 + import { Menu } from "components/Layout"; 12 + import { MenuItem } from "components/Layout"; 13 + import Link from "next/link"; 14 + import { HomeSmall } from "components/Icons/HomeSmall"; 15 + 16 + export const Actions = (props: { publication: string }) => { 17 + return ( 18 + <> 19 + <Media mobile> 20 + <Link 21 + href="/home" 22 + prefetch 23 + className="hover:no-underline" 24 + style={{ textDecorationLine: "none !important" }} 25 + > 26 + <ActionButton icon={<HomeSmall />} label="Go Home" /> 27 + </Link> 28 + </Media> 29 + <NewDraftActionButton publication={props.publication} /> 30 + <PublicationShareButton /> 31 + <PublicationSettingsButton publication={props.publication} /> 32 + <hr className="border-border-light" /> 33 + <Link 34 + href="/home" 35 + prefetch 36 + className="hover:no-underline" 37 + style={{ textDecorationLine: "none !important" }} 38 + > 39 + <ActionButton icon={<HomeSmall />} label="Go Home" /> 40 + </Link> 41 + </> 42 + ); 43 + }; 44 + 45 + function PublicationShareButton() { 46 + return ( 47 + <Menu 48 + className="max-w-xs" 49 + asChild 50 + trigger={ 51 + <ActionButton 52 + id="pub-share-button" 53 + icon=<ShareSmall /> 54 + secondary 55 + label="Share" 56 + onClick={() => {}} 57 + /> 58 + } 59 + > 60 + <MenuItem onSelect={() => {}}> 61 + <Link href={"/"} className="text-secondary hover:no-underline"> 62 + <div>Viewer Mode</div> 63 + <div className="font-normal text-tertiary text-sm"> 64 + View your publication as a reader 65 + </div> 66 + </Link> 67 + </MenuItem> 68 + <MenuItem onSelect={() => {}}> 69 + <div> 70 + <div>Share Your Publication</div> 71 + <div className="font-normal text-tertiary text-sm"> 72 + Copy link for the published site 73 + </div> 74 + </div> 75 + </MenuItem> 76 + </Menu> 77 + ); 78 + } 79 + 80 + function PublicationSettingsButton(props: { publication: string }) { 81 + let router = useRouter(); 82 + 83 + return ( 84 + <Popover 85 + asChild 86 + trigger={ 87 + <ActionButton 88 + id="pub-settings-button" 89 + icon=<SettingsSmall /> 90 + label="Settings" 91 + /> 92 + } 93 + > 94 + <CreatePubForm /> 95 + </Popover> 96 + ); 97 + }
+43
app/lish/[did]/[publication]/dashboard/DraftList.tsx
··· 1 + "use client"; 2 + 3 + import Link from "next/link"; 4 + import { NewDraftSecondaryButton } from "./NewDraftButton"; 5 + import React from "react"; 6 + import { usePublicationData } from "./PublicationSWRProvider"; 7 + 8 + export function DraftList() { 9 + let pub_data = usePublicationData(); 10 + if (!pub_data) return null; 11 + return ( 12 + <div className="flex flex-col gap-4 pb-8 sm:pb-12"> 13 + <NewDraftSecondaryButton fullWidth publication={pub_data?.uri} /> 14 + {pub_data.leaflets_in_publications.map((d) => { 15 + return ( 16 + <React.Fragment key={d.leaflet}> 17 + <Draft id={d.leaflet} {...d} /> 18 + <hr className="last:hidden border-border-light" /> 19 + </React.Fragment> 20 + ); 21 + })} 22 + </div> 23 + ); 24 + } 25 + 26 + function Draft(props: { id: string; title: string; description: string }) { 27 + return ( 28 + <div className="flex flex-row gap-2 items-start"> 29 + <Link 30 + key={props.id} 31 + href={`/${props.id}`} 32 + className="flex flex-col gap-0 hover:!no-underline grow" 33 + > 34 + {props.title ? ( 35 + <h3 className="text-primary">{props.title}</h3> 36 + ) : ( 37 + <h3 className="text-tertiary italic">Untitled</h3> 38 + )} 39 + <div className="text-secondary italic">{props.description}</div> 40 + </Link> 41 + </div> 42 + ); 43 + }
+44
app/lish/[did]/[publication]/dashboard/NewDraftButton.tsx
··· 1 + "use client"; 2 + import { createPublicationDraft } from "actions/createPublicationDraft"; 3 + import { ActionButton } from "components/ActionBar/ActionButton"; 4 + import { ButtonSecondary } from "components/Buttons"; 5 + import { AddTiny } from "components/Icons/AddTiny"; 6 + import { useRouter } from "next/navigation"; 7 + 8 + export function NewDraftActionButton(props: { publication: string }) { 9 + let router = useRouter(); 10 + 11 + return ( 12 + <ActionButton 13 + id="new-leaflet-button" 14 + primary 15 + onClick={async () => { 16 + let newLeaflet = await createPublicationDraft(props.publication); 17 + router.push(`/${newLeaflet}`); 18 + }} 19 + icon=<AddTiny className="m-1 shrink-0" /> 20 + label="New Draft" 21 + /> 22 + ); 23 + } 24 + 25 + export function NewDraftSecondaryButton(props: { 26 + publication: string; 27 + fullWidth?: boolean; 28 + }) { 29 + let router = useRouter(); 30 + 31 + return ( 32 + <ButtonSecondary 33 + fullWidth={props.fullWidth} 34 + id="new-leaflet-button" 35 + onClick={async () => { 36 + let newLeaflet = await createPublicationDraft(props.publication); 37 + router.push(`/${newLeaflet}`); 38 + }} 39 + > 40 + <AddTiny className="m-1 shrink-0" /> 41 + <span>New Draft</span> 42 + </ButtonSecondary> 43 + ); 44 + }
+58
app/lish/[did]/[publication]/dashboard/PublicationDashboard.tsx
··· 1 + "use client"; 2 + import { BlobRef } from "@atproto/lexicon"; 3 + import { useState } from "react"; 4 + 5 + type Tabs = { [tabName: string]: React.ReactNode }; 6 + export function PublicationDashboard<T extends Tabs>(props: { 7 + name: string; 8 + tabs: T; 9 + defaultTab: keyof T; 10 + icon: BlobRef | null; 11 + did: string; 12 + }) { 13 + let [tab, setTab] = useState(props.defaultTab); 14 + let content = props.tabs[tab]; 15 + 16 + return ( 17 + <div className="pubDashWrapper w-full flex flex-col items-stretch px-3"> 18 + <div className="pubDashTabWrapper flex flex-row gap-2 w-full justify-between border-b border-border text-secondary items-center"> 19 + {props.icon && ( 20 + <div 21 + className="shrink-0 w-5 h-5 rounded-full" 22 + style={{ 23 + backgroundImage: `url(https://bsky.social/xrpc/com.atproto.sync.getBlob?did=${props.did}&cid=${(props.icon.ref as unknown as { $link: string })["$link"]})`, 24 + backgroundRepeat: "no-repeat", 25 + backgroundPosition: "center", 26 + backgroundSize: "cover", 27 + }} 28 + /> 29 + )}{" "} 30 + <div className="font-bold grow text-tertiary max-w-full truncate pr-2"> 31 + {props.name} 32 + </div> 33 + <div className="pubDashTabs flex flex-row gap-2"> 34 + {Object.keys(props.tabs).map((t) => ( 35 + <Tab 36 + key={t} 37 + name={t} 38 + selected={t === tab} 39 + onSelect={() => setTab(t)} 40 + /> 41 + ))} 42 + </div> 43 + </div> 44 + <div className="pubDashContent pt-4">{content}</div> 45 + </div> 46 + ); 47 + } 48 + 49 + function Tab(props: { name: string; selected: boolean; onSelect: () => void }) { 50 + return ( 51 + <div 52 + className={`pubTabs border bg-[#FDFCFA] border-b-0 px-2 pt-1 pb-0.5 rounded-t-md border-border hover:cursor-pointer ${props.selected ? "text-accent-1 font-bold -mb-[1px]" : ""}`} 53 + onClick={() => props.onSelect()} 54 + > 55 + {props.name} 56 + </div> 57 + ); 58 + }
+41
app/lish/[did]/[publication]/dashboard/PublicationSWRProvider.tsx
··· 1 + "use client"; 2 + 3 + import type { GetPublicationDataReturnType } from "app/api/rpc/[command]/get_publication_data"; 4 + import { callRPC } from "app/api/rpc/client"; 5 + import { createContext, useContext } from "react"; 6 + import useSWR, { SWRConfig } from "swr"; 7 + 8 + const PublicationContext = createContext({ name: "", did: "" }); 9 + export function PublicationSWRDataProvider(props: { 10 + publication_name: string; 11 + publication_did: string; 12 + publication_data: GetPublicationDataReturnType["result"]; 13 + children: React.ReactNode; 14 + }) { 15 + return ( 16 + <PublicationContext 17 + value={{ name: props.publication_name, did: props.publication_did }} 18 + > 19 + <SWRConfig 20 + value={{ 21 + fallback: { 22 + "publication-data": props.publication_data, 23 + }, 24 + }} 25 + > 26 + {props.children} 27 + </SWRConfig> 28 + </PublicationContext> 29 + ); 30 + } 31 + 32 + export function usePublicationData() { 33 + let { name, did } = useContext(PublicationContext); 34 + let { data } = useSWR( 35 + "publication-data", 36 + async () => 37 + (await callRPC("get_publication_data", { publication_name: name, did })) 38 + ?.result, 39 + ); 40 + return data; 41 + }
+72
app/lish/[did]/[publication]/dashboard/PublishedPostsLists.tsx
··· 1 + "use client"; 2 + import Link from "next/link"; 3 + import { AtUri } from "@atproto/syntax"; 4 + import { PubLeafletDocument } from "lexicons/api"; 5 + import { EditTiny } from "components/Icons/EditTiny"; 6 + import { MoreOptionsVerticalTiny } from "components/Icons/MoreOptionsVerticalTiny"; 7 + import { Menu, MenuItem } from "components/Layout"; 8 + 9 + import { usePublicationData } from "./PublicationSWRProvider"; 10 + import { Fragment } from "react"; 11 + import { useParams } from "next/navigation"; 12 + import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; 13 + 14 + export function PublishedPostsList() { 15 + let publication = usePublicationData(); 16 + let params = useParams(); 17 + if (!publication) return null; 18 + if (publication.documents_in_publications.length === 0) 19 + return ( 20 + <div className="italic text-tertiary w-full container text-center place-items-center flex flex-col gap-3 p-3"> 21 + Nothing's been published yet... 22 + </div> 23 + ); 24 + return ( 25 + <div className="publishedList w-full flex flex-col gap-4 pb-8 sm:pb-12"> 26 + {publication.documents_in_publications.map((doc) => { 27 + if (!doc.documents) return null; 28 + let leaflet = publication.leaflets_in_publications.find( 29 + (l) => doc.documents && l.doc === doc.documents.uri, 30 + ); 31 + let uri = new AtUri(doc.documents.uri); 32 + let record = doc.documents.data as PubLeafletDocument.Record; 33 + 34 + return ( 35 + <Fragment key={doc.documents?.uri}> 36 + <div className="flex w-full "> 37 + <Link 38 + target="_blank" 39 + href={`${getPublicationURL(publication)}/${uri.rkey}`} 40 + className="publishedPost grow flex flex-col hover:!no-underline" 41 + > 42 + <h3 className="text-primary">{record.title}</h3> 43 + {record.description ? ( 44 + <p className="italic text-secondary">{record.description}</p> 45 + ) : null} 46 + {record.publishedAt ? ( 47 + <p className="text-sm text-tertiary pt-3"> 48 + Published{" "} 49 + {new Date(record.publishedAt).toLocaleDateString( 50 + undefined, 51 + { 52 + year: "numeric", 53 + month: "long", 54 + day: "2-digit", 55 + }, 56 + )} 57 + </p> 58 + ) : null} 59 + </Link> 60 + {leaflet && ( 61 + <Link className="pt-[6px]" href={`/${leaflet.leaflet}`}> 62 + <EditTiny /> 63 + </Link> 64 + )} 65 + </div> 66 + <hr className="last:hidden border-border-light" /> 67 + </Fragment> 68 + ); 69 + })} 70 + </div> 71 + ); 72 + }
+107
app/lish/[did]/[publication]/dashboard/page.tsx
··· 1 + import { IdResolver } from "@atproto/identity"; 2 + import { supabaseServerClient } from "supabase/serverClient"; 3 + import { Metadata } from "next"; 4 + 5 + import { Sidebar } from "components/ActionBar/Sidebar"; 6 + 7 + import { Media } from "components/Media"; 8 + import { Footer } from "components/ActionBar/Footer"; 9 + import { PublicationDashboard } from "./PublicationDashboard"; 10 + import { DraftList } from "./DraftList"; 11 + import { getIdentityData } from "actions/getIdentityData"; 12 + import { ThemeProvider } from "components/ThemeManager/ThemeProvider"; 13 + import { Actions } from "./Actions"; 14 + import React from "react"; 15 + import { get_publication_data } from "app/api/rpc/[command]/get_publication_data"; 16 + import { PublicationSWRDataProvider } from "./PublicationSWRProvider"; 17 + import { PublishedPostsList } from "./PublishedPostsLists"; 18 + import { PubLeafletPublication } from "lexicons/api"; 19 + 20 + const idResolver = new IdResolver(); 21 + 22 + export async function generateMetadata(props: { 23 + params: Promise<{ publication: string; did: string }>; 24 + }): Promise<Metadata> { 25 + let did = decodeURIComponent((await props.params).did); 26 + if (!did) return { title: "Publication 404" }; 27 + 28 + let { result: publication } = await get_publication_data.handler( 29 + { 30 + did, 31 + publication_name: decodeURIComponent((await props.params).publication), 32 + }, 33 + { supabase: supabaseServerClient }, 34 + ); 35 + if (!publication) return { title: "404 Publication" }; 36 + return { title: decodeURIComponent((await props.params).publication) }; 37 + } 38 + 39 + //This is the admin dashboard of the publication 40 + export default async function Publication(props: { 41 + params: Promise<{ publication: string; did: string }>; 42 + }) { 43 + let params = await props.params; 44 + let identity = await getIdentityData(); 45 + if (!identity || !identity.atp_did) return <div>not logged in</div>; 46 + let did = decodeURIComponent(params.did); 47 + if (!did) return <PubNotFound />; 48 + let { result: publication } = await get_publication_data.handler( 49 + { 50 + did, 51 + publication_name: decodeURIComponent((await props.params).publication), 52 + }, 53 + { supabase: supabaseServerClient }, 54 + ); 55 + 56 + let record = publication?.record as PubLeafletPublication.Record | null; 57 + if (!publication || identity.atp_did !== publication.identity_did) 58 + return <PubNotFound />; 59 + 60 + try { 61 + return ( 62 + <PublicationSWRDataProvider 63 + publication_did={did} 64 + publication_name={publication.name} 65 + publication_data={publication} 66 + > 67 + <ThemeProvider entityID={null}> 68 + <div className="w-screen h-screen flex place-items-center bg-[#FDFCFA]"> 69 + <div className="relative max-w-prose w-full h-full mx-auto flex sm:flex-row flex-col sm:items-stretch sm:px-6"> 70 + <div className="w-12 relative"> 71 + <Sidebar className="mt-6 p-2"> 72 + <Actions publication={publication.uri} /> 73 + </Sidebar> 74 + </div> 75 + <div 76 + className={`h-full overflow-y-scroll pt-4 sm:pl-5 sm:pt-8 w-full`} 77 + > 78 + <PublicationDashboard 79 + did={did} 80 + icon={record?.icon ? record.icon : null} 81 + name={publication.name} 82 + tabs={{ 83 + Drafts: <DraftList />, 84 + Published: <PublishedPostsList />, 85 + }} 86 + defaultTab={"Drafts"} 87 + /> 88 + </div> 89 + <Media mobile> 90 + <Footer> 91 + <Actions publication={publication.uri} /> 92 + </Footer> 93 + </Media> 94 + </div> 95 + </div> 96 + </ThemeProvider> 97 + </PublicationSWRDataProvider> 98 + ); 99 + } catch (e) { 100 + console.log(e); 101 + return <pre>{JSON.stringify(e, undefined, 2)}</pre>; 102 + } 103 + } 104 + 105 + const PubNotFound = () => { 106 + return <div>ain't no pub here</div>; 107 + };
-136
app/lish/[handle]/[publication]/[rkey]/page.tsx
··· 1 - import Link from "next/link"; 2 - import { IdResolver } from "@atproto/identity"; 3 - import { supabaseServerClient } from "supabase/serverClient"; 4 - import { AtUri } from "@atproto/syntax"; 5 - import { ids } from "lexicons/api/lexicons"; 6 - import { 7 - PubLeafletBlocksHeader, 8 - PubLeafletBlocksImage, 9 - PubLeafletBlocksText, 10 - PubLeafletDocument, 11 - PubLeafletPagesLinearDocument, 12 - } from "lexicons/api"; 13 - import { Metadata } from "next"; 14 - 15 - const idResolver = new IdResolver(); 16 - export async function generateMetadata(props: { 17 - params: Promise<{ publication: string; handle: string; rkey: string }>; 18 - }): Promise<Metadata> { 19 - let did = await idResolver.handle.resolve((await props.params).handle); 20 - if (!did) return { title: "Publication 404" }; 21 - 22 - let { data: document } = await supabaseServerClient 23 - .from("documents") 24 - .select("*") 25 - .eq( 26 - "uri", 27 - AtUri.make(did, ids.PubLeafletDocument, (await props.params).rkey), 28 - ) 29 - .single(); 30 - 31 - if (!document) return { title: "404" }; 32 - let record = document.data as PubLeafletDocument.Record; 33 - return { 34 - title: 35 - record.title + 36 - " - " + 37 - decodeURIComponent((await props.params).publication), 38 - }; 39 - } 40 - export default async function Post(props: { 41 - params: Promise<{ publication: string; handle: string; rkey: string }>; 42 - }) { 43 - let did = await idResolver.handle.resolve((await props.params).handle); 44 - if (!did) return <div> can't resolve handle</div>; 45 - let { data: document } = await supabaseServerClient 46 - .from("documents") 47 - .select("*") 48 - .eq( 49 - "uri", 50 - AtUri.make(did, ids.PubLeafletDocument, (await props.params).rkey), 51 - ) 52 - .single(); 53 - if (!document?.data) return <div>notfound</div>; 54 - let record = document.data as PubLeafletDocument.Record; 55 - let firstPage = record.pages[0]; 56 - let blocks: PubLeafletPagesLinearDocument.Block[] = []; 57 - if (PubLeafletPagesLinearDocument.isMain(firstPage)) { 58 - blocks = firstPage.blocks || []; 59 - } 60 - return ( 61 - <div className="postPage w-full h-screen bg-[#FDFCFA] flex items-stretch"> 62 - <div className="pubWrapper flex flex-col w-full "> 63 - <div className="pubContent flex flex-col px-3 sm:px-4 py-3 sm:py-9 mx-auto max-w-prose h-full w-full overflow-auto"> 64 - <div className="flex flex-col pb-8"> 65 - <Link 66 - className="font-bold hover:no-underline text-accent-contrast" 67 - href={`/lish/${(await props.params).handle}/${(await props.params).publication}`} 68 - > 69 - {decodeURIComponent((await props.params).publication)} 70 - </Link> 71 - <h2 className="">{record.title}</h2> 72 - {record.description ? ( 73 - <p className="italic text-secondary">{record.description}</p> 74 - ) : null} 75 - {record.publishedAt ? ( 76 - <p className="text-sm text-tertiary pt-3"> 77 - Published{" "} 78 - {new Date(record.publishedAt).toLocaleDateString(undefined, { 79 - year: "numeric", 80 - month: "long", 81 - day: "2-digit", 82 - })} 83 - </p> 84 - ) : null} 85 - </div> 86 - {blocks.map((b, index) => { 87 - switch (true) { 88 - case PubLeafletBlocksImage.isMain(b.block): { 89 - return ( 90 - <img 91 - key={index} 92 - height={b.block.aspectRatio?.height} 93 - width={b.block.aspectRatio?.width} 94 - className="pb-2 sm:pb-3" 95 - src={`https://bsky.social/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${(b.block.image.ref as unknown as { $link: string })["$link"]}`} 96 - /> 97 - ); 98 - } 99 - case PubLeafletBlocksText.isMain(b.block): 100 - return ( 101 - <p key={index} className="pt-0 pb-2 sm:pb-3"> 102 - {b.block.plaintext} 103 - </p> 104 - ); 105 - case PubLeafletBlocksHeader.isMain(b.block): { 106 - if (b.block.level === 1) 107 - return ( 108 - <h1 key={index} className="pb-0 pt-2 sm:pt-3"> 109 - {b.block.plaintext} 110 - </h1> 111 - ); 112 - if (b.block.level === 2) 113 - return ( 114 - <h3 key={index} className="pb-0 pt-2 sm:pt-3"> 115 - {b.block.plaintext} 116 - </h3> 117 - ); 118 - if (b.block.level === 3) 119 - return ( 120 - <h4 key={index} className="pb-0 pt-2 sm:pt-3"> 121 - {b.block.plaintext} 122 - </h4> 123 - ); 124 - // if (b.block.level === 4) return <h4>{b.block.plaintext}</h4>; 125 - // if (b.block.level === 5) return <h5>{b.block.plaintext}</h5>; 126 - return <h6 key={index}>{b.block.plaintext}</h6>; 127 - } 128 - default: 129 - return null; 130 - } 131 - })} 132 - </div> 133 - </div> 134 - </div> 135 - ); 136 - }
-97
app/lish/[handle]/[publication]/dashboard/Actions.tsx
··· 1 - "use client"; 2 - 3 - import { Media } from "components/Media"; 4 - import { NewDraftActionButton } from "./NewDraftButton"; 5 - import { ActionButton } from "components/ActionBar/ActionButton"; 6 - import { useRouter } from "next/navigation"; 7 - import { Popover } from "components/Popover"; 8 - import { SettingsSmall } from "components/Icons/SettingsSmall"; 9 - import { CreatePubForm } from "app/lish/createPub/CreatePubForm"; 10 - import { ShareSmall } from "components/Icons/ShareSmall"; 11 - import { Menu } from "components/Layout"; 12 - import { MenuItem } from "components/Layout"; 13 - import Link from "next/link"; 14 - import { HomeSmall } from "components/Icons/HomeSmall"; 15 - 16 - export const Actions = (props: { publication: string }) => { 17 - return ( 18 - <> 19 - <Media mobile> 20 - <Link 21 - href="/home" 22 - prefetch 23 - className="hover:no-underline" 24 - style={{ textDecorationLine: "none !important" }} 25 - > 26 - <ActionButton icon={<HomeSmall />} label="Go Home" /> 27 - </Link> 28 - </Media> 29 - <NewDraftActionButton publication={props.publication} /> 30 - <PublicationShareButton /> 31 - <PublicationSettingsButton publication={props.publication} /> 32 - <hr className="border-border-light" /> 33 - <Link 34 - href="/home" 35 - prefetch 36 - className="hover:no-underline" 37 - style={{ textDecorationLine: "none !important" }} 38 - > 39 - <ActionButton icon={<HomeSmall />} label="Go Home" /> 40 - </Link> 41 - </> 42 - ); 43 - }; 44 - 45 - function PublicationShareButton() { 46 - return ( 47 - <Menu 48 - className="max-w-xs" 49 - asChild 50 - trigger={ 51 - <ActionButton 52 - id="pub-share-button" 53 - icon=<ShareSmall /> 54 - secondary 55 - label="Share" 56 - onClick={() => {}} 57 - /> 58 - } 59 - > 60 - <MenuItem onSelect={() => {}}> 61 - <Link href={"/"} className="text-secondary hover:no-underline"> 62 - <div>Viewer Mode</div> 63 - <div className="font-normal text-tertiary text-sm"> 64 - View your publication as a reader 65 - </div> 66 - </Link> 67 - </MenuItem> 68 - <MenuItem onSelect={() => {}}> 69 - <div> 70 - <div>Share Your Publication</div> 71 - <div className="font-normal text-tertiary text-sm"> 72 - Copy link for the published site 73 - </div> 74 - </div> 75 - </MenuItem> 76 - </Menu> 77 - ); 78 - } 79 - 80 - function PublicationSettingsButton(props: { publication: string }) { 81 - let router = useRouter(); 82 - 83 - return ( 84 - <Popover 85 - asChild 86 - trigger={ 87 - <ActionButton 88 - id="pub-settings-button" 89 - icon=<SettingsSmall /> 90 - label="Settings" 91 - /> 92 - } 93 - > 94 - <CreatePubForm /> 95 - </Popover> 96 - ); 97 - }
-43
app/lish/[handle]/[publication]/dashboard/DraftList.tsx
··· 1 - "use client"; 2 - 3 - import Link from "next/link"; 4 - import { NewDraftSecondaryButton } from "./NewDraftButton"; 5 - import React from "react"; 6 - import { usePublicationData } from "./PublicationSWRProvider"; 7 - 8 - export function DraftList() { 9 - let pub_data = usePublicationData(); 10 - if (!pub_data) return null; 11 - return ( 12 - <div className="flex flex-col gap-4 pb-8 sm:pb-12"> 13 - <NewDraftSecondaryButton fullWidth publication={pub_data?.uri} /> 14 - {pub_data.leaflets_in_publications.map((d) => { 15 - return ( 16 - <React.Fragment key={d.leaflet}> 17 - <Draft id={d.leaflet} {...d} /> 18 - <hr className="last:hidden border-border-light" /> 19 - </React.Fragment> 20 - ); 21 - })} 22 - </div> 23 - ); 24 - } 25 - 26 - function Draft(props: { id: string; title: string; description: string }) { 27 - return ( 28 - <div className="flex flex-row gap-2 items-start"> 29 - <Link 30 - key={props.id} 31 - href={`/${props.id}`} 32 - className="flex flex-col gap-0 hover:!no-underline grow" 33 - > 34 - {props.title ? ( 35 - <h3 className="text-primary">{props.title}</h3> 36 - ) : ( 37 - <h3 className="text-tertiary italic">Untitled</h3> 38 - )} 39 - <div className="text-secondary italic">{props.description}</div> 40 - </Link> 41 - </div> 42 - ); 43 - }
-44
app/lish/[handle]/[publication]/dashboard/NewDraftButton.tsx
··· 1 - "use client"; 2 - import { createPublicationDraft } from "actions/createPublicationDraft"; 3 - import { ActionButton } from "components/ActionBar/ActionButton"; 4 - import { ButtonSecondary } from "components/Buttons"; 5 - import { AddTiny } from "components/Icons/AddTiny"; 6 - import { useRouter } from "next/navigation"; 7 - 8 - export function NewDraftActionButton(props: { publication: string }) { 9 - let router = useRouter(); 10 - 11 - return ( 12 - <ActionButton 13 - id="new-leaflet-button" 14 - primary 15 - onClick={async () => { 16 - let newLeaflet = await createPublicationDraft(props.publication); 17 - router.push(`/${newLeaflet}`); 18 - }} 19 - icon=<AddTiny className="m-1 shrink-0" /> 20 - label="New Draft" 21 - /> 22 - ); 23 - } 24 - 25 - export function NewDraftSecondaryButton(props: { 26 - publication: string; 27 - fullWidth?: boolean; 28 - }) { 29 - let router = useRouter(); 30 - 31 - return ( 32 - <ButtonSecondary 33 - fullWidth={props.fullWidth} 34 - id="new-leaflet-button" 35 - onClick={async () => { 36 - let newLeaflet = await createPublicationDraft(props.publication); 37 - router.push(`/${newLeaflet}`); 38 - }} 39 - > 40 - <AddTiny className="m-1 shrink-0" /> 41 - <span>New Draft</span> 42 - </ButtonSecondary> 43 - ); 44 - }
-58
app/lish/[handle]/[publication]/dashboard/PublicationDashboard.tsx
··· 1 - "use client"; 2 - import { BlobRef } from "@atproto/lexicon"; 3 - import { useState } from "react"; 4 - 5 - type Tabs = { [tabName: string]: React.ReactNode }; 6 - export function PublicationDashboard<T extends Tabs>(props: { 7 - name: string; 8 - tabs: T; 9 - defaultTab: keyof T; 10 - icon: BlobRef | null; 11 - did: string; 12 - }) { 13 - let [tab, setTab] = useState(props.defaultTab); 14 - let content = props.tabs[tab]; 15 - 16 - return ( 17 - <div className="pubDashWrapper w-full flex flex-col items-stretch px-3"> 18 - <div className="pubDashTabWrapper flex flex-row gap-2 w-full justify-between border-b border-border text-secondary items-center"> 19 - {props.icon && ( 20 - <div 21 - className="shrink-0 w-5 h-5 rounded-full" 22 - style={{ 23 - backgroundImage: `url(https://bsky.social/xrpc/com.atproto.sync.getBlob?did=${props.did}&cid=${(props.icon.ref as unknown as { $link: string })["$link"]})`, 24 - backgroundRepeat: "no-repeat", 25 - backgroundPosition: "center", 26 - backgroundSize: "cover", 27 - }} 28 - /> 29 - )}{" "} 30 - <div className="font-bold grow text-tertiary max-w-full truncate pr-2"> 31 - {props.name} 32 - </div> 33 - <div className="pubDashTabs flex flex-row gap-2"> 34 - {Object.keys(props.tabs).map((t) => ( 35 - <Tab 36 - key={t} 37 - name={t} 38 - selected={t === tab} 39 - onSelect={() => setTab(t)} 40 - /> 41 - ))} 42 - </div> 43 - </div> 44 - <div className="pubDashContent pt-4">{content}</div> 45 - </div> 46 - ); 47 - } 48 - 49 - function Tab(props: { name: string; selected: boolean; onSelect: () => void }) { 50 - return ( 51 - <div 52 - className={`pubTabs border bg-[#FDFCFA] border-b-0 px-2 pt-1 pb-0.5 rounded-t-md border-border hover:cursor-pointer ${props.selected ? "text-accent-1 font-bold -mb-[1px]" : ""}`} 53 - onClick={() => props.onSelect()} 54 - > 55 - {props.name} 56 - </div> 57 - ); 58 - }
-41
app/lish/[handle]/[publication]/dashboard/PublicationSWRProvider.tsx
··· 1 - "use client"; 2 - 3 - import type { GetPublicationDataReturnType } from "app/api/rpc/[command]/get_publication_data"; 4 - import { callRPC } from "app/api/rpc/client"; 5 - import { createContext, useContext } from "react"; 6 - import useSWR, { SWRConfig } from "swr"; 7 - 8 - const PublicationContext = createContext({ name: "", did: "" }); 9 - export function PublicationSWRDataProvider(props: { 10 - publication_name: string; 11 - publication_did: string; 12 - publication_data: GetPublicationDataReturnType["result"]; 13 - children: React.ReactNode; 14 - }) { 15 - return ( 16 - <PublicationContext 17 - value={{ name: props.publication_name, did: props.publication_did }} 18 - > 19 - <SWRConfig 20 - value={{ 21 - fallback: { 22 - "publication-data": props.publication_data, 23 - }, 24 - }} 25 - > 26 - {props.children} 27 - </SWRConfig> 28 - </PublicationContext> 29 - ); 30 - } 31 - 32 - export function usePublicationData() { 33 - let { name, did } = useContext(PublicationContext); 34 - let { data } = useSWR( 35 - "publication-data", 36 - async () => 37 - (await callRPC("get_publication_data", { publication_name: name, did })) 38 - ?.result, 39 - ); 40 - return data; 41 - }
-71
app/lish/[handle]/[publication]/dashboard/PublishedPostsLists.tsx
··· 1 - "use client"; 2 - import Link from "next/link"; 3 - import { AtUri } from "@atproto/syntax"; 4 - import { PubLeafletDocument } from "lexicons/api"; 5 - import { EditTiny } from "components/Icons/EditTiny"; 6 - import { MoreOptionsVerticalTiny } from "components/Icons/MoreOptionsVerticalTiny"; 7 - import { Menu, MenuItem } from "components/Layout"; 8 - 9 - import { usePublicationData } from "./PublicationSWRProvider"; 10 - import { Fragment } from "react"; 11 - import { useParams } from "next/navigation"; 12 - 13 - export function PublishedPostsList() { 14 - let publication = usePublicationData(); 15 - let params = useParams(); 16 - if (!publication) return null; 17 - if (publication.documents_in_publications.length === 0) 18 - return ( 19 - <div className="italic text-tertiary w-full container text-center place-items-center flex flex-col gap-3 p-3"> 20 - Nothing's been published yet... 21 - </div> 22 - ); 23 - return ( 24 - <div className="publishedList w-full flex flex-col gap-4 pb-8 sm:pb-12"> 25 - {publication.documents_in_publications.map((doc) => { 26 - if (!doc.documents) return null; 27 - let leaflet = publication.leaflets_in_publications.find( 28 - (l) => doc.documents && l.doc === doc.documents.uri, 29 - ); 30 - let uri = new AtUri(doc.documents.uri); 31 - let record = doc.documents.data as PubLeafletDocument.Record; 32 - 33 - return ( 34 - <Fragment key={doc.documents?.uri}> 35 - <div className="flex w-full "> 36 - <Link 37 - target="_blank" 38 - href={`/lish/${params.handle}/${params.publication}/${uri.rkey}`} 39 - className="publishedPost grow flex flex-col hover:!no-underline" 40 - > 41 - <h3 className="text-primary">{record.title}</h3> 42 - {record.description ? ( 43 - <p className="italic text-secondary">{record.description}</p> 44 - ) : null} 45 - {record.publishedAt ? ( 46 - <p className="text-sm text-tertiary pt-3"> 47 - Published{" "} 48 - {new Date(record.publishedAt).toLocaleDateString( 49 - undefined, 50 - { 51 - year: "numeric", 52 - month: "long", 53 - day: "2-digit", 54 - }, 55 - )} 56 - </p> 57 - ) : null} 58 - </Link> 59 - {leaflet && ( 60 - <Link className="pt-[6px]" href={`/${leaflet.leaflet}`}> 61 - <EditTiny /> 62 - </Link> 63 - )} 64 - </div> 65 - <hr className="last:hidden border-border-light" /> 66 - </Fragment> 67 - ); 68 - })} 69 - </div> 70 - ); 71 - }
-107
app/lish/[handle]/[publication]/dashboard/page.tsx
··· 1 - import { IdResolver } from "@atproto/identity"; 2 - import { supabaseServerClient } from "supabase/serverClient"; 3 - import { Metadata } from "next"; 4 - 5 - import { Sidebar } from "components/ActionBar/Sidebar"; 6 - 7 - import { Media } from "components/Media"; 8 - import { Footer } from "components/ActionBar/Footer"; 9 - import { PublicationDashboard } from "./PublicationDashboard"; 10 - import { DraftList } from "./DraftList"; 11 - import { getIdentityData } from "actions/getIdentityData"; 12 - import { ThemeProvider } from "components/ThemeManager/ThemeProvider"; 13 - import { Actions } from "./Actions"; 14 - import React from "react"; 15 - import { get_publication_data } from "app/api/rpc/[command]/get_publication_data"; 16 - import { PublicationSWRDataProvider } from "./PublicationSWRProvider"; 17 - import { PublishedPostsList } from "./PublishedPostsLists"; 18 - import { PubLeafletPublication } from "lexicons/api"; 19 - 20 - const idResolver = new IdResolver(); 21 - 22 - export async function generateMetadata(props: { 23 - params: Promise<{ publication: string; handle: string }>; 24 - }): Promise<Metadata> { 25 - let did = await idResolver.handle.resolve((await props.params).handle); 26 - if (!did) return { title: "Publication 404" }; 27 - 28 - let { result: publication } = await get_publication_data.handler( 29 - { 30 - did, 31 - publication_name: decodeURIComponent((await props.params).publication), 32 - }, 33 - { supabase: supabaseServerClient }, 34 - ); 35 - if (!publication) return { title: "404 Publication" }; 36 - return { title: decodeURIComponent((await props.params).publication) }; 37 - } 38 - 39 - //This is the admin dashboard of the publication 40 - export default async function Publication(props: { 41 - params: Promise<{ publication: string; handle: string }>; 42 - }) { 43 - let params = await props.params; 44 - let identity = await getIdentityData(); 45 - if (!identity || !identity.atp_did) return <PubNotFound />; 46 - let did = await idResolver.handle.resolve((await props.params).handle); 47 - if (!did) return <PubNotFound />; 48 - let { result: publication } = await get_publication_data.handler( 49 - { 50 - did, 51 - publication_name: decodeURIComponent((await props.params).publication), 52 - }, 53 - { supabase: supabaseServerClient }, 54 - ); 55 - 56 - let record = publication?.record as PubLeafletPublication.Record; 57 - if (!publication || identity.atp_did !== publication.identity_did) 58 - return <PubNotFound />; 59 - 60 - try { 61 - return ( 62 - <PublicationSWRDataProvider 63 - publication_did={did} 64 - publication_name={publication.name} 65 - publication_data={publication} 66 - > 67 - <ThemeProvider entityID={null}> 68 - <div className="w-screen h-screen flex place-items-center bg-[#FDFCFA]"> 69 - <div className="relative max-w-prose w-full h-full mx-auto flex sm:flex-row flex-col sm:items-stretch sm:px-6"> 70 - <div className="w-12 relative"> 71 - <Sidebar className="mt-6 p-2"> 72 - <Actions publication={publication.uri} /> 73 - </Sidebar> 74 - </div> 75 - <div 76 - className={`h-full overflow-y-scroll pt-4 sm:pl-5 sm:pt-8 w-full`} 77 - > 78 - <PublicationDashboard 79 - did={did} 80 - icon={record.icon ? record.icon : null} 81 - name={publication.name} 82 - tabs={{ 83 - Drafts: <DraftList />, 84 - Published: <PublishedPostsList />, 85 - }} 86 - defaultTab={"Drafts"} 87 - /> 88 - </div> 89 - <Media mobile> 90 - <Footer> 91 - <Actions publication={publication.uri} /> 92 - </Footer> 93 - </Media> 94 - </div> 95 - </div> 96 - </ThemeProvider> 97 - </PublicationSWRDataProvider> 98 - ); 99 - } catch (e) { 100 - console.log(e); 101 - return <pre>{JSON.stringify(e, undefined, 2)}</pre>; 102 - } 103 - } 104 - 105 - const PubNotFound = () => { 106 - return <div>ain't no pub here</div>; 107 - };