From 775a245be1401b1ee15b8527f7fd90bc8417c752 Mon Sep 17 00:00:00 2001 From: Jared Pereira Date: Fri, 1 May 2026 16:29:21 -0400 Subject: [PATCH 01/47] remove newsletter mode flag --- .../[publication]/dashboard/settings/SettingsContent.tsx | 9 ++------- src/hooks/useEntitlement.ts | 4 ---- 2 files changed, 2 insertions(+), 11 deletions(-) diff --git a/app/lish/[did]/[publication]/dashboard/settings/SettingsContent.tsx b/app/lish/[did]/[publication]/dashboard/settings/SettingsContent.tsx index b7eb9ece..a8f432a7 100644 --- a/app/lish/[did]/[publication]/dashboard/settings/SettingsContent.tsx +++ b/app/lish/[did]/[publication]/dashboard/settings/SettingsContent.tsx @@ -16,11 +16,7 @@ import { PostSettings } from "./PostSettings"; import { ThemeSettings } from "./ThemeSettings"; import { useCardBorderHidden } from "components/Pages/useCardBorderHidden"; import { ManageProSubscription, NewsletterSettings } from "./ProSettings"; -import { - useIsPro, - useCanSeePro, - useCanSeeNewsletterMode, -} from "src/hooks/useEntitlement"; +import { useIsPro, useCanSeePro } from "src/hooks/useEntitlement"; import { InlineUpgradeToPro, UpgradeToProButton } from "../../UpgradeModal"; import { Modal } from "components/Modal"; import { Input } from "components/Input"; @@ -35,7 +31,6 @@ export function SettingsContent(props: { showPageBackground: boolean }) { let { publication: pubData } = data || {}; let isPro = useIsPro(); let canSeePro = useCanSeePro(); - let canSeeNewsletterMode = useCanSeeNewsletterMode(); let record = useNormalizedPublicationRecord(); let [loading, setLoading] = useState(false); let toast = useToaster(); @@ -204,7 +199,7 @@ export function SettingsContent(props: { showPageBackground: boolean }) { - {canSeeNewsletterMode && } + )}
diff --git a/src/hooks/useEntitlement.ts b/src/hooks/useEntitlement.ts index 9f62c33e..07b313ef 100644 --- a/src/hooks/useEntitlement.ts +++ b/src/hooks/useEntitlement.ts @@ -13,7 +13,3 @@ export function useIsPro(): boolean { export function useCanSeePro(): boolean { return true; } - -export function useCanSeeNewsletterMode(): boolean { - return useHasEntitlement("can_see_newsletter_mode"); -} -- 2.51.2 From 1f41d5b83cb8f1c67de400c64ada564bacfdf211 Mon Sep 17 00:00:00 2001 From: Jared Pereira Date: Fri, 1 May 2026 16:30:36 -0400 Subject: [PATCH 02/47] wire close button for pro subscription --- components/SubscriptionSuccessModal.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/components/SubscriptionSuccessModal.tsx b/components/SubscriptionSuccessModal.tsx index a81db0d7..3df5067e 100644 --- a/components/SubscriptionSuccessModal.tsx +++ b/components/SubscriptionSuccessModal.tsx @@ -62,7 +62,10 @@ export function SubscriptionSuccessModal() { We're looking forward to bringing you even cooler stuff in the near future!

- + handleOpenChange(false)} + > Got it, Thanks!
-- 2.51.2 From 184908310a082b9a1c97aeeed768a18edb09f607 Mon Sep 17 00:00:00 2001 From: Jared Pereira Date: Fri, 1 May 2026 16:41:23 -0400 Subject: [PATCH 03/47] add email to upgrade modal --- app/lish/[did]/[publication]/UpgradeModal.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/app/lish/[did]/[publication]/UpgradeModal.tsx b/app/lish/[did]/[publication]/UpgradeModal.tsx index daa12094..6469c855 100644 --- a/app/lish/[did]/[publication]/UpgradeModal.tsx +++ b/app/lish/[did]/[publication]/UpgradeModal.tsx @@ -32,8 +32,12 @@ export const UpgradeContent = () => {
Subscriber Counts
Top Referrers

+
Emails
+
+ First 1,000 subscribers included +
+
Coming ASAP
-
Emails
Paid Membership
-- 2.51.2 From c42d106c44fbf4f3c74b6fb2e274a3006844df57 Mon Sep 17 00:00:00 2001 From: celine Date: Fri, 1 May 2026 19:40:48 -0400 Subject: [PATCH 04/47] fix footnote alignment --- components/Footnotes/FootnoteItemLayout.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/components/Footnotes/FootnoteItemLayout.tsx b/components/Footnotes/FootnoteItemLayout.tsx index 72c60528..0abfdf7b 100644 --- a/components/Footnotes/FootnoteItemLayout.tsx +++ b/components/Footnotes/FootnoteItemLayout.tsx @@ -10,14 +10,14 @@ export function FootnoteItemLayout(props: { className?: string; }) { let indexClassName = - "text-tertiary font-medium shrink-0 text-sm leading-normal no-underline hover:underline cursor-pointer"; + "text-tertiary font-medium shrink-0 text-sm leading-normal no-underline hover:underline cursor-pointer w-7 text-right"; let indexContent = <>{props.index}.; return (
{props.indexHref ? ( -- 2.51.2 From 42b2f2f403e1d2d034cf1c0a1bd8cc6e277cc04f Mon Sep 17 00:00:00 2001 From: Jared Pereira Date: Fri, 1 May 2026 21:09:31 -0400 Subject: [PATCH 05/47] show oauth session error --- actions/publications/subscribeEmail.tsx | 10 ++++++++-- components/Subscribe/ManageSubscribe.tsx | 7 +++++-- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/actions/publications/subscribeEmail.tsx b/actions/publications/subscribeEmail.tsx index 554a84cb..3b63096f 100644 --- a/actions/publications/subscribeEmail.tsx +++ b/actions/publications/subscribeEmail.tsx @@ -22,6 +22,7 @@ import { publishAtprotoSubscriptionForDid, unsubscribeToPublication, } from "app/lish/subscribeToPublication"; +import type { OAuthSessionError } from "src/atproto-oauth"; import { normalizePublicationRecord } from "src/utils/normalizeRecords"; type RequestError = @@ -36,7 +37,11 @@ type ConfirmError = | "link_invalid_state" | "email_belongs_to_other_account" | ConfirmationError; -type UnsubscribeError = "unauthorized" | "not_subscribed" | "database_error"; +type UnsubscribeError = + | "unauthorized" + | "not_subscribed" + | "database_error" + | OAuthSessionError; export async function requestPublicationEmailSubscription( publicationUri: string, @@ -315,7 +320,8 @@ export async function unsubscribeFromPublication( } if (atprotoSub) { - await unsubscribeToPublication(publicationUri); + const atprotoResult = await unsubscribeToPublication(publicationUri); + if (!atprotoResult.success) return Err(atprotoResult.error); } // NOTE: Postmark Suppressions API is deliberately NOT called here. Per spec, diff --git a/components/Subscribe/ManageSubscribe.tsx b/components/Subscribe/ManageSubscribe.tsx index 003481e4..fcf34569 100644 --- a/components/Subscribe/ManageSubscribe.tsx +++ b/components/Subscribe/ManageSubscribe.tsx @@ -6,6 +6,7 @@ import { CheckTiny } from "components/Icons/CheckTiny"; import { GoToArrow } from "components/Icons/GoToArrow"; import { Modal } from "components/Modal"; import { useToaster } from "components/Toast"; +import { isOAuthSessionError, OAuthErrorMessage } from "components/OAuthError"; import { LinkHandle } from "./HandleSubscribe"; import { EmailInput, EmailConfirm } from "./EmailSubscribe"; import type { ViewerUser } from "./viewerSubscription"; @@ -102,9 +103,11 @@ export const ManageSubscription = (props: { if (!res.ok) { toaster({ type: "error", - content: ( + content: isOAuthSessionError(res.error) ? ( + + ) : (
- {UNSUBSCRIBE_ERROR_MESSAGES[res.error]} + {UNSUBSCRIBE_ERROR_MESSAGES[res.error] ?? "Couldn't unsubscribe."}
), }); -- 2.51.2 From 0b9840277f891bc205c299654f56e646ca566dd0 Mon Sep 17 00:00:00 2001 From: Jared Pereira Date: Fri, 1 May 2026 23:20:39 -0400 Subject: [PATCH 06/47] add price to upgrade modal --- app/lish/[did]/[publication]/UpgradeModal.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/lish/[did]/[publication]/UpgradeModal.tsx b/app/lish/[did]/[publication]/UpgradeModal.tsx index 6469c855..c5be75e5 100644 --- a/app/lish/[did]/[publication]/UpgradeModal.tsx +++ b/app/lish/[did]/[publication]/UpgradeModal.tsx @@ -34,7 +34,7 @@ export const UpgradeContent = () => {
Emails
- First 1,000 subscribers included + First 1,000 subscribers included, $5/1K after that

Coming ASAP
-- 2.51.2 From 8f88e7418a222757ae5edf6610167f2a5b1e1ca5 Mon Sep 17 00:00:00 2001 From: Jared Pereira Date: Sun, 3 May 2026 14:15:09 -0400 Subject: [PATCH 07/47] fix focus input when in email type input --- src/utils/focusElement.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/utils/focusElement.ts b/src/utils/focusElement.ts index b48327ed..de2c404a 100644 --- a/src/utils/focusElement.ts +++ b/src/utils/focusElement.ts @@ -1,5 +1,14 @@ import { isIOS } from "src/utils/isDevice"; +// setSelectionRange throws InvalidStateError on input types other than +// text/search/url/tel/password (e.g. email, number). +const supportsSelection = ( + el: HTMLInputElement | HTMLTextAreaElement, +): boolean => { + if (el instanceof HTMLTextAreaElement) return true; + return ["text", "search", "url", "tel", "password"].includes(el.type); +}; + export const focusElement = ( el?: HTMLInputElement | HTMLTextAreaElement | null, ) => { @@ -22,7 +31,7 @@ export const focusElement = ( el?.focus(); fakeInput.remove(); el.value = " "; - el.setSelectionRange(1, 1); + if (supportsSelection(el)) el.setSelectionRange(1, 1); requestAnimationFrame(() => { if (el) { el.style.transform = ""; @@ -31,7 +40,7 @@ export const focusElement = ( setTimeout(() => { if (!el) return; el.value = ""; - el.setSelectionRange(0, 0); + if (supportsSelection(el)) el.setSelectionRange(0, 0); }, 50); }, 20); }; -- 2.51.2 From 190c009a81c7ea590e0ffb5c5dc499f268348f8a Mon Sep 17 00:00:00 2001 From: Jared Pereira Date: Sun, 3 May 2026 14:19:56 -0400 Subject: [PATCH 08/47] fix theme in post emails --- emails/fromPublication.ts | 1 + emails/shared.tsx | 28 +++++++++++++++++++++------- 2 files changed, 22 insertions(+), 7 deletions(-) diff --git a/emails/fromPublication.ts b/emails/fromPublication.ts index 555ecb68..5000cca6 100644 --- a/emails/fromPublication.ts +++ b/emails/fromPublication.ts @@ -41,6 +41,7 @@ export const resolveEmailTheme = ( headingFont: getFontFamilyValue(getFontConfig(theme.headingFont)), bodyFont: getFontFamilyValue(getFontConfig(theme.bodyFont)), pageWidth: theme.pageWidth ?? defaultEmailTheme.pageWidth, + showPageBackground: theme.showPageBackground ?? false, }; }; diff --git a/emails/shared.tsx b/emails/shared.tsx index 1d4e78cd..0da0c3c9 100644 --- a/emails/shared.tsx +++ b/emails/shared.tsx @@ -13,6 +13,7 @@ export type EmailTheme = { // post at the same column width in their inbox as on the live page. // Default 624 mirrors `ThemeProvider.tsx`'s fallback. pageWidth: number; + showPageBackground: boolean; }; export const defaultEmailTheme: EmailTheme = { @@ -24,6 +25,7 @@ export const defaultEmailTheme: EmailTheme = { headingFont: "Georgia, serif", bodyFont: "Verdana, sans-serif", pageWidth: 624, + showPageBackground: true, }; // Parse rgb()/rgba()/#hex into [r, g, b]. Returns black on parse failure — @@ -76,13 +78,25 @@ export type ResolvedColors = { borderLight: string; }; -export const resolveColors = (theme: EmailTheme): ResolvedColors => ({ - primary: theme.primary, - secondary: mixRgb(theme.primary, theme.pageBackground, 25), - tertiary: mixRgb(theme.primary, theme.pageBackground, 55), - border: mixRgb(theme.primary, theme.pageBackground, 75), - borderLight: mixRgb(theme.primary, theme.pageBackground, 85), -}); +export const resolveColors = (theme: EmailTheme): ResolvedColors => { + // When the card background is hidden, the surface visible behind every + // block is the outer page bg, not the publication's pageBackground. + // Mix tints against that visible surface so block fills (e.g. the + // unsupported-block fallback) sit on a tone derived from what's + // actually behind them — mirroring `BaseThemeProvider`'s web behavior + // where `bgPage` collapses to `bgLeaflet` when showPageBackground is + // false. + const effectiveBg = theme.showPageBackground + ? theme.pageBackground + : theme.backgroundColor; + return { + primary: theme.primary, + secondary: mixRgb(theme.primary, effectiveBg, 25), + tertiary: mixRgb(theme.primary, effectiveBg, 55), + border: mixRgb(theme.primary, effectiveBg, 75), + borderLight: mixRgb(theme.primary, effectiveBg, 85), + }; +}; // Postmark fetches `` and link `href` values verbatim from the // rendered HTML — relative paths break. Templates accept an `assetsBaseUrl` -- 2.51.2 From cfc662836bbcf4c7b4e053189e621ce4b3c56fc7 Mon Sep 17 00:00:00 2001 From: Jared Pereira Date: Tue, 5 May 2026 12:53:53 -0400 Subject: [PATCH 09/47] update post template again --- emails/post.tsx | 470 ++++++++++++++++++++++++++---------------------- 1 file changed, 256 insertions(+), 214 deletions(-) diff --git a/emails/post.tsx b/emails/post.tsx index 51bec99f..25c7b640 100644 --- a/emails/post.tsx +++ b/emails/post.tsx @@ -28,6 +28,9 @@ import { PubLeafletPagesLinearDocument, } from "lexicons/api"; import { blobRefToSrc } from "src/utils/blobRefToSrc"; +import { normalizePublicationRecord } from "src/utils/normalizeRecords"; +import { supabaseServerClient } from "supabase/serverClient"; +import { emailPropsFromPublication } from "./fromPublication"; import { bgcolorAttr, defaultEmailTheme, @@ -89,8 +92,7 @@ const defaultProps: PostEmailProps = { publicationName: "Publication", publicationUrl: "https://leaflet.pub", postTitle: "Post Title Here", - postDescription: - "Hello this is a description of everything that is to come", + postDescription: "Hello this is a description of everything that is to come", postUrl: "https://leaflet.pub", authorName: "author", publishedAtLabel: "Jan 1, 2026", @@ -298,236 +300,246 @@ export const PostEmail = (props: Partial = {}) => { - - {p.publicationName} - - - - - {p.postTitle} - - - - {p.postDescription ? ( - - {p.postDescription} - - ) : null} - - {byline ? ( -
- - - - {byline} - - - - - - See quotes - - - - - - See comments + {p.publicationName} - - -
- ) : null} - - {p.blocks.map((b, i) => ( - - ))} - {/* Footer: Gmail won't reliably cascade `text-align` from a - wrapping , so each centered row is its own @@ -540,7 +552,38 @@ export const PostEmail = (props: Partial = {}) => { ); }; -export default PostEmail; +// `npm run email:dev` renders this default export. Set +// `PREVIEW_PUBLICATION_URI` below to an `at://did:plc:.../pub.leaflet.publication/` +// to preview the post template with that publication's name, url, and theme — +// matching what `send_post_broadcast` and `sendPostPreview` resolve at send time. +// Leave it null to render with the static default props. +const PREVIEW_PUBLICATION_URI: string | null = + "at://did:plc:x2xmijn2egk5g67u3cwkddzy/site.standard.publication/3m2avdoogvs2f"; + +const PostEmailPreview = async () => { + if (!PREVIEW_PUBLICATION_URI) return ; + + const { data: publication, error } = await supabaseServerClient + .from("publications") + .select("record") + .eq("uri", PREVIEW_PUBLICATION_URI) + .maybeSingle(); + + if (error || !publication) { + console.warn( + `[email preview] could not load publication ${PREVIEW_PUBLICATION_URI}:`, + error?.message ?? "not found", + ); + return ; + } + + const pubProps = emailPropsFromPublication( + normalizePublicationRecord(publication.record), + ); + return ; +}; + +export default PostEmailPreview; // Map the lexicon's alignment token to the simple left/center/right values // usable as HTML `align` attributes. `justify` falls through to `left` to @@ -592,10 +635,10 @@ const BlockRenderer = ({ ); } if (PubLeafletBlocksHeader.isMain(block)) { - const level = Math.min( - 3, - Math.max(1, Math.floor(block.level ?? 1)), - ) as 1 | 2 | 3; + const level = Math.min(3, Math.max(1, Math.floor(block.level ?? 1))) as + | 1 + | 2 + | 3; return ( { - const fontSize = - props.as === "h1" ? 26 : props.as === "h2" ? 18 : 16; + const fontSize = props.as === "h1" ? 26 : props.as === "h2" ? 18 : 16; return ( Date: Wed, 6 May 2026 18:20:35 -0400 Subject: [PATCH 10/47] link loose email subscriptions --- actions/emailAuth.tsx | 3 +++ actions/login.ts | 8 ++++++++ actions/publications/subscribeEmail.tsx | 6 ++++++ src/utils/linkOrphanedEmailSubscribers.ts | 25 +++++++++++++++++++++++ 4 files changed, 42 insertions(+) create mode 100644 src/utils/linkOrphanedEmailSubscribers.ts diff --git a/actions/emailAuth.tsx b/actions/emailAuth.tsx index da48b984..806e0887 100644 --- a/actions/emailAuth.tsx +++ b/actions/emailAuth.tsx @@ -11,6 +11,7 @@ import { pool } from "supabase/pool"; import { supabaseServerClient } from "supabase/serverClient"; import { LeafletConfirmEmail } from "emails/leafletConfirmEmail"; import { sendConfirmationEmail } from "src/utils/confirmationEmail"; +import { linkOrphanedEmailSubscribers } from "src/utils/linkOrphanedEmailSubscribers"; async function sendAuthCode(email: string, code: string) { await sendConfirmationEmail({ @@ -113,6 +114,8 @@ export async function confirmEmailAuthToken(tokenId: string, code: string) { identityID = identity.id; } + await linkOrphanedEmailSubscribers(identityID, token.email); + const [confirmedToken] = await db .update(email_auth_tokens) .set({ diff --git a/actions/login.ts b/actions/login.ts index 52c493c5..ff909cf8 100644 --- a/actions/login.ts +++ b/actions/login.ts @@ -11,6 +11,7 @@ import { cookies } from "next/headers"; import { redirect } from "next/navigation"; import { pool } from "supabase/pool"; import { supabaseServerClient } from "supabase/serverClient"; +import { linkOrphanedEmailSubscribers } from "src/utils/linkOrphanedEmailSubscribers"; export async function loginWithEmailToken( localLeaflets: { token: { id: string }; added_at: string }[], @@ -51,6 +52,7 @@ export async function loginWithEmailToken( .where(eq(identities.email, token.email)); let identity = existingIdentity; + let newlyOwnedEmail = false; if (!existingIdentity) { let identityCookie = (await cookies()).get("identity"); if (identityCookie) { @@ -69,6 +71,7 @@ export async function loginWithEmailToken( .set({ email: token.email }) .where(eq(identities.id, existingIdentityFromCookie.id)); identity = existingIdentityFromCookie; + newlyOwnedEmail = true; } } else { const { data: newIdentity } = await supabaseServerClient @@ -77,9 +80,14 @@ export async function loginWithEmailToken( .select() .single(); identity = newIdentity!; + newlyOwnedEmail = true; } } + if (newlyOwnedEmail && identity) { + await linkOrphanedEmailSubscribers(identity.id, token.email); + } + await tx .update(email_auth_tokens) .set({ identity: identity.id }) diff --git a/actions/publications/subscribeEmail.tsx b/actions/publications/subscribeEmail.tsx index 3b63096f..b5b5037f 100644 --- a/actions/publications/subscribeEmail.tsx +++ b/actions/publications/subscribeEmail.tsx @@ -24,6 +24,7 @@ import { } from "app/lish/subscribeToPublication"; import type { OAuthSessionError } from "src/atproto-oauth"; import { normalizePublicationRecord } from "src/utils/normalizeRecords"; +import { linkOrphanedEmailSubscribers } from "src/utils/linkOrphanedEmailSubscribers"; type RequestError = | "invalid_email" @@ -367,6 +368,7 @@ async function linkEmailToCurrentIdentity( console.error("[subscribeEmail] attach email failed:", error); return Err("database_error"); } + await linkOrphanedEmailSubscribers(current.id, email); await backfillAtprotoSubscriptionsForIdentity(current.id, current.atp_did); return Ok(current.id); } @@ -398,6 +400,10 @@ async function ensureAuthTokenForEmail(email: string): Promise { return null; } + // Cover any sibling subscriber rows (e.g. CSV-imported entries on other + // publications) so this confirmation also adopts them under the new identity. + await linkOrphanedEmailSubscribers(identity.id, email); + const { data: token, error: tokenError } = await supabaseServerClient .from("email_auth_tokens") .insert({ diff --git a/src/utils/linkOrphanedEmailSubscribers.ts b/src/utils/linkOrphanedEmailSubscribers.ts new file mode 100644 index 00000000..704ce902 --- /dev/null +++ b/src/utils/linkOrphanedEmailSubscribers.ts @@ -0,0 +1,25 @@ +import { supabaseServerClient } from "supabase/serverClient"; + +// Link any pre-existing email-subscriber rows for `email` to `identityId` if +// they're not already linked. Call this whenever an identity newly comes to +// own an email so that imported / pre-confirmation-flow subscribers become +// visible to their owner. Idempotent — safe to call on every signup/login. +export async function linkOrphanedEmailSubscribers( + identityId: string, + email: string, +) { + const normalized = email.trim().toLowerCase(); + if (!normalized) return; + const { error } = await supabaseServerClient + .from("publication_email_subscribers") + .update({ identity_id: identityId }) + .eq("email", normalized) + .is("identity_id", null); + if (error) { + console.error( + "[linkOrphanedEmailSubscribers] failed:", + error.message, + { identityId, email: normalized }, + ); + } +} -- 2.51.2 From e91aa069bc9b40d601e763bdcaab14b7fa20271e Mon Sep 17 00:00:00 2001 From: Jared Pereira Date: Thu, 7 May 2026 15:27:42 -0400 Subject: [PATCH 11/47] handle publishng newsletters programatically --- app/api/inngest/client.ts | 2 +- .../functions/sync_document_metadata.ts | 51 +++++++++++++++++++ 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/app/api/inngest/client.ts b/app/api/inngest/client.ts index dcd25ce5..0cc7ff97 100644 --- a/app/api/inngest/client.ts +++ b/app/api/inngest/client.ts @@ -87,7 +87,7 @@ export const events = { schema: staticSchema<{ publication_uri: string; document_uri: string; - root_entity: string; + root_entity?: string; }>(), }), }; diff --git a/app/api/inngest/functions/sync_document_metadata.ts b/app/api/inngest/functions/sync_document_metadata.ts index 41faf3f2..8a0348bd 100644 --- a/app/api/inngest/functions/sync_document_metadata.ts +++ b/app/api/inngest/functions/sync_document_metadata.ts @@ -57,6 +57,57 @@ export const sync_document_metadata = inngest.createFunction( .select(); }); + const broadcast = await step.run( + "maybe-claim-newsletter-broadcast", + async () => { + const { data: docInPub } = await supabaseServerClient + .from("documents_in_publications") + .select("publication") + .eq("document", document_uri) + .maybeSingle(); + const publication_uri = docInPub?.publication; + if (!publication_uri) return { skipped: "no_publication" as const }; + + const { data: settings } = await supabaseServerClient + .from("publication_newsletter_settings") + .select("enabled") + .eq("publication", publication_uri) + .maybeSingle(); + if (!settings?.enabled) { + return { skipped: "newsletter_not_enabled" as const }; + } + + // Composite PK on (publication, document) is the idempotency guard: + // re-runs on document updates and races with publishToPublication + // both no-op here. + const { data: inserted } = await supabaseServerClient + .from("publication_post_sends") + .upsert( + { + publication: publication_uri, + document: document_uri, + status: "pending", + }, + { onConflict: "publication,document", ignoreDuplicates: true }, + ) + .select(); + if (!inserted || inserted.length === 0) { + return { skipped: "already_sent_or_pending" as const }; + } + return { claimed: true as const, publication_uri }; + }, + ); + + if ("claimed" in broadcast) { + await step.sendEvent("send-newsletter-broadcast", { + name: "newsletter/post.send.requested", + data: { + publication_uri: broadcast.publication_uri, + document_uri, + }, + }); + } + if (!bsky_post_uri) { return { handle: handleResult.handle }; } -- 2.51.2 From e5e1008ca66927bf150fc1327bf1873a1c21fea6 Mon Sep 17 00:00:00 2001 From: Jared Pereira Date: Thu, 7 May 2026 16:56:58 -0400 Subject: [PATCH 12/47] add metadata col to email subscribers --- src/email-subscribers/metadata.ts | 23 +++++++++++++++++++ ...20260507205333_add_subscriber_metadata.sql | 7 ++++++ 2 files changed, 30 insertions(+) create mode 100644 src/email-subscribers/metadata.ts create mode 100644 supabase/migrations/20260507205333_add_subscriber_metadata.sql diff --git a/src/email-subscribers/metadata.ts b/src/email-subscribers/metadata.ts new file mode 100644 index 00000000..e1a75a37 --- /dev/null +++ b/src/email-subscribers/metadata.ts @@ -0,0 +1,23 @@ +// Schema for the `metadata` jsonb column on `publication_email_subscribers`. +// +// The column is nullable — most rows have no metadata at all (organic +// signups). Within each variant, only the `source` discriminator is +// required; every other field is optional because different writers +// populate different subsets. + +export type CsvImportProvenance = { + source: "csv_import"; + imported_at?: string; + // Other CSV columns that the import script didn't consume directly + // (e.g. ip_address, signup_source) — kept verbatim so we don't lose + // information from the source list. The subscription date is not + // stored here; it lives on `created_at`/`confirmed_at`. + csv_columns?: Record; +}; + +// Discriminated union of known subscriber-row metadata shapes. Extend as +// new sources start writing the column. +export type SubscriberMetadata = CsvImportProvenance; + +// Column-level type — most rows are null. +export type SubscriberMetadataColumn = SubscriberMetadata | null; diff --git a/supabase/migrations/20260507205333_add_subscriber_metadata.sql b/supabase/migrations/20260507205333_add_subscriber_metadata.sql new file mode 100644 index 00000000..c5deba00 --- /dev/null +++ b/supabase/migrations/20260507205333_add_subscriber_metadata.sql @@ -0,0 +1,7 @@ +-- Provenance metadata on email subscribers (e.g. csv_import, imported_at, +-- original_subscription_date). The events table already has its own +-- per-event metadata column; this one tags the subscriber row itself so +-- "where did these subscribers come from" is a one-table query. + +alter table "public"."publication_email_subscribers" + add column "metadata" jsonb; -- 2.51.2 From 728b94b9f7dfee834194c636fe1cdf4d8b4d80a8 Mon Sep 17 00:00:00 2001 From: Jared Pereira Date: Thu, 7 May 2026 17:26:16 -0400 Subject: [PATCH 13/47] dynamic email icons --- app/api/email-assets/[name]/route.ts | 68 +++++++++++++++++++++++++++ emails/post.tsx | 13 ++++- emails/shared.tsx | 16 +++++++ public/email-assets/comment.png | Bin 864 -> 0 bytes public/email-assets/quote.png | Bin 690 -> 0 bytes 5 files changed, 95 insertions(+), 2 deletions(-) create mode 100644 app/api/email-assets/[name]/route.ts delete mode 100644 public/email-assets/comment.png delete mode 100644 public/email-assets/quote.png diff --git a/app/api/email-assets/[name]/route.ts b/app/api/email-assets/[name]/route.ts new file mode 100644 index 00000000..0d6ed45e --- /dev/null +++ b/app/api/email-assets/[name]/route.ts @@ -0,0 +1,68 @@ +import { NextRequest, NextResponse } from "next/server"; +import sharp from "sharp"; + +export const runtime = "nodejs"; + +// Path data mirrors `components/Icons/QuoteTiny.tsx` and +// `components/Icons/CommentTiny.tsx`. The icon components use +// `fill="currentColor"` for the web; here we bake the color in directly so +// the rendered PNG carries the resolved theme color. If the source icons +// are restyled, update these in lockstep. +const ICONS: Record< + string, + { viewBox: string; svg: (color: string) => string } +> = { + quote: { + viewBox: "0 0 16 16", + svg: (color) => + ``, + }, + comment: { + viewBox: "0 0 16 16", + svg: (color) => + ``, + }, +}; + +// Allow rgb()/rgba()/#hex/named CSS colors. Reject anything that could +// break out of the SVG attribute (quotes, angle brackets, backslashes). +const sanitizeColor = (input: string): string => { + if (input.length > 64) return "rgb(0, 0, 0)"; + if (/[<>"'\\]/.test(input)) return "rgb(0, 0, 0)"; + return input; +}; + +export async function GET( + req: NextRequest, + { params }: { params: Promise<{ name: string }> }, +) { + const { name: rawName } = await params; + const name = rawName.replace(/\.png$/, ""); + const icon = ICONS[name]; + if (!icon) return new NextResponse(null, { status: 404 }); + + const { searchParams } = req.nextUrl; + const color = sanitizeColor(searchParams.get("color") ?? "rgb(0, 0, 0)"); + const sizeParam = parseInt(searchParams.get("size") ?? "16", 10); + const displaySize = + Number.isFinite(sizeParam) && sizeParam > 0 + ? Math.min(128, Math.max(8, sizeParam)) + : 16; + // Render at 2x so Retina mail clients (Apple Mail) stay crisp when the + // displays at `displaySize`. + const renderSize = displaySize * 2; + + const svg = `${icon.svg(color)}`; + + const png = await sharp(Buffer.from(svg)).png().toBuffer(); + + return new NextResponse(png, { + headers: { + "Content-Type": "image/png", + // URL fully identifies the rendering (name + color + size), so the + // response is content-addressed — safe to cache forever. + "Cache-Control": "public, max-age=31536000, immutable", + "CDN-Cache-Control": "s-maxage=31536000, immutable", + }, + }); +} diff --git a/emails/post.tsx b/emails/post.tsx index 25c7b640..b4ade0cc 100644 --- a/emails/post.tsx +++ b/emails/post.tsx @@ -36,6 +36,7 @@ import { defaultEmailTheme, LeafletWatermark, MailHead, + makeEmailIconUrl, makeStaticUrl, resolveColors, type EmailTheme, @@ -392,7 +393,11 @@ export const PostEmail = (props: Partial = {}) => { See quotes @@ -408,7 +413,11 @@ export const PostEmail = (props: Partial = {}) => { See comments diff --git a/emails/shared.tsx b/emails/shared.tsx index 0da0c3c9..4dde8a1c 100644 --- a/emails/shared.tsx +++ b/emails/shared.tsx @@ -106,6 +106,22 @@ export const makeStaticUrl = (assetsBaseUrl: string) => { return (filename: string): string => `${base}/email-assets/${filename}`; }; +// Build a URL for the dynamic icon endpoint that bakes the theme color +// into a PNG at request time. We render PNG (not SVG) because Gmail's +// image proxy strips SVG for @gmail.com inboxes, and Microsoft removed +// SVG support across new Outlook + Outlook.com in late 2025. +export const makeEmailIconUrl = ( + assetsBaseUrl: string, + name: "quote" | "comment", + color: string, + size = 16, +): string => { + const base = assetsBaseUrl.replace(/\/$/, ""); + const params = new URLSearchParams({ color }); + if (size !== 16) params.set("size", String(size)); + return `${base}/api/email-assets/${name}.png?${params.toString()}`; +}; + // React's TypeScript types don't include the deprecated `bgcolor` HTML // attribute on
— the bulletproof email-centering - pattern. `min-width: 100%` keeps Gmail iOS from - shrink-wrapping the table around the short link text. */} - - - - - - - - + - {/* Spacer */} - - - + {p.postDescription ? ( + + {p.postDescription} + + ) : null} + + {byline ? ( +
+ + + + {byline} + + + + + + See quotes + + + + + + See comments + + + +
+ ) : null} + + {p.blocks.map((b, i) => ( + + ))} - {/* Horizontal rule above watermark.
margins are + {/* Footer: Gmail won't reliably cascade `text-align` from a + wrapping
- - Read in Browser - -
- {p.unsubscribeUrl ? ( - Unsubscribe + {p.postTitle} - ) : ( - - (preview — not sent to subscribers) - - )} -
-   -
, so each centered row is its own + + + +
— the bulletproof email-centering + pattern. `min-width: 100%` keeps Gmail iOS from + shrink-wrapping the table around the short link text. */} + + + + + + + + + + {/* Spacer */} + + + + + {/* Horizontal rule above watermark.
margins are flaky in Gmail, so we use a 1px-tall - - + + + - - - + + + - - - - -
+ + Read in Browser + +
+ {p.unsubscribeUrl ? ( + + Unsubscribe + + ) : ( + + (preview — not sent to subscribers) + + )} +
+   +
with border-top instead. */} -
-   -
+   +
-   -
+   +
- -
+
+ +
, but every email client (especially Outlook) reads it // directly — so we spread it via a typed cast. Combined with an inline diff --git a/public/email-assets/comment.png b/public/email-assets/comment.png deleted file mode 100644 index 115320f925b0e23a99ff986603459f10e1fc035e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 864 zcmeAS@N?(olHy`uVBq!ia0y~yU{C~M4mJh`hVUnc@);NyI14-?iy0WiR6&^0Gf3qF z0|NtNage(c!@6@aFBupZSkfJR9T^xl_H+M9WMyDr`s(T87*fIbHrzW;%27a9fywNI z#|HmuW)B@3%f^Q+2h}<37(a6In0xpMY&xy?gJoXqq);{PBk7aB=}mNyTCd>b5cg2- z%7UL?681dyT>HXFpdeqHBNpa8n*I%R5 zCZA+CN!6NK^y}|FVb#ksT$To{Ol*!e(Vb#5(>ZA_Z_?a{1sX2}gl@z*oKQE({^H}{ zS8XF#{_S|Mi0^}W^Zfqj9LU+~v`}H$<(G2>Ik(y7{OX-u;<74wZwNGE3Kr zT}@U|6YluisNiezQ*`M)A(q>w{jO|A`rnV&+!sFaebT%+K?aIn{wr>%ndzgpH%9Nf zpx{S#j=!!mAMakBb?uhCi1@D{mHTb{KaZo-=gx0_fp31i8{I!&KEqd~{VHMd zqO9!KCH8|FgDMy|v8|ohfxn_1UvZz4ec6 z4xh_+ImE24!jb2|v7fo;9`B6XU#nBAJ5Rqp{_lN4$m-DSjS(C<2_K!=)U7X@)rFj$ zX7)^@*Rw?T>-uhj&cE`CNE*viyI20FUZPhZH?Ykvv?Yt#ZimjO9Yh*18R)*~2Yf^aj zv}&)~nd4KvQaipj^fgQu`dal@i1GIfw^Jtf-*iT~r@%HpnBkdcWISK|Ys((#FCrxExy0{*oR`jBV&eODAbtL(*?k}6Ehi`juD%*^s>7z?N%v&S)eYPG zUNWS3H0)IHS9oEb8-M-Lwa1T7pI*w->9kPcgz$==&r|l@&C@TAiCkL!8WpR~=`cxs(!PVaH_Q1&UQg&|*(LS8Wd3{R&P&p(+21d| z7hW_mAor`dC4=a_8osm2+MI0bUfSKCHe=Q;lgWE7>TgmpuSv-Idu_Kft7O67WD%Xp zjS*A2k2*=|DSFOhc*xV)ywA|rLH~jG>c<7rj!)$?lRea%tn6k!PTG6IZsS6W`{U$N`o#`ve{a|(~OKVUr({X*5O#Na3c0|SGntDnm{r-UW|boejj -- 2.51.2 From aa875e36adb095818366d0ed1df778ee330e5c2a Mon Sep 17 00:00:00 2001 From: Jared Pereira Date: Thu, 7 May 2026 17:43:08 -0400 Subject: [PATCH 14/47] Refactor/route nav (#294) * move tabs into sidebar * move dashboard pages into new layout, tweak mobile layout * renaming and componentizing things in the Dashboard Layouts for clarity * componentize the search input on pages * added sidebar on mobile, refactored some stuff * added sticky scroll behavior to mobile header * animations for the mobile pageHeader * fix mobile nav trigger and component structure * futzing * more futzing * Added logged out state to Reader Inbox, refactored reader to use new layout * fix a z-index issue * use route based navigation * remove logs and small fixes * simplify * small fixes * updates to leaflet to accomodate sidebar changes * dynamic notification page * fix search when signed out * prevent default and add stop propogration to email submit * added new tab to the reader, decodeds urls in pickActiveTab function * tweaks to publish banner in sidebar to avoid redundancy * bubble up notifactions, scroll very long pub lists * rm interaction drawer in reader, add a layout component to reader pages * added leaflet about * move mobile nav to the bottom * added icons to the tabs * handle signed out users side bar actions --------- Co-authored-by: celine --- CLAUDE.md | 2 +- app/(home-pages)/(writer)/WriterShell.tsx | 50 ++ .../{ => (writer)}/home/Actions/Actions.tsx | 6 +- .../home/Actions/CreateNewButton.tsx | 24 +- .../home/Actions/HomeThemeSetter.tsx} | 12 +- .../home/HomeEmpty/DiscoverIllo.tsx | 0 .../home/HomeEmpty/HomeEmpty.tsx | 0 .../home/HomeEmpty/WelcomeToLeafletIllo.tsx | 0 .../{ => (writer)}/home/HomeLayout.tsx | 62 +- .../{ => (writer)}/home/IdentitySetter.tsx | 0 .../home/LeafletList/LeafletContent.tsx | 0 .../home/LeafletList/LeafletInfo.tsx | 0 .../home/LeafletList/LeafletListItem.tsx | 5 +- .../home/LeafletList/LeafletOptions.tsx | 0 .../LeafletList/LeafletPreview.module.css | 0 .../home/LeafletList/LeafletPreview.tsx | 0 app/(home-pages)/{ => (writer)}/home/icon.tsx | 0 app/(home-pages)/{ => (writer)}/home/page.tsx | 4 +- .../{ => (writer)}/home/storage.ts | 0 app/(home-pages)/(writer)/layout.tsx | 5 + .../looseleafs/LooseleafsLayout.tsx | 36 +- .../{ => (writer)}/looseleafs/page.tsx | 8 +- .../BskyPostEmbedNotification.tsx | 0 .../CommentMentionNotification.tsx | 0 .../notifications/CommentNotication.tsx | 0 .../notifications/FollowNotification.tsx | 0 .../notifications/MentionNotification.tsx | 0 .../notifications/Notification.tsx | 0 .../notifications/NotificationList.tsx | 0 .../notifications/QuoteNotification.tsx | 0 .../notifications/RecommendNotification.tsx | 0 .../notifications/ReplyNotification.tsx | 0 .../notifications/getNotifications.ts | 0 .../{ => (writer)}/notifications/page.tsx | 24 +- app/(home-pages)/home/Actions/HomeHelp.tsx | 27 - app/(home-pages)/p/[didOrHandle]/layout.tsx | 44 +- app/(home-pages)/page.tsx | 26 +- app/(home-pages)/reader/FeedLayout.tsx | 7 + app/(home-pages)/reader/GlobalContent.tsx | 26 +- app/(home-pages)/reader/InboxContent.tsx | 67 +- .../reader/InteractionDrawers.tsx | 117 ---- app/(home-pages)/reader/NewContent.tsx | 44 +- app/(home-pages)/reader/hot/page.tsx | 8 +- app/(home-pages)/reader/layout.tsx | 88 +-- app/(home-pages)/reader/loading.tsx | 4 - app/(home-pages)/reader/new/page.tsx | 21 +- app/(home-pages)/reader/page.tsx | 36 +- app/(home-pages)/reader/trending/page.tsx | 22 + app/(home-pages)/tag/[tag]/layout.tsx | 24 + app/(home-pages)/tag/[tag]/page.tsx | 21 +- app/[leaflet_id]/Footer.tsx | 27 +- app/[leaflet_id]/Leaflet.tsx | 2 - app/[leaflet_id]/Sidebar.tsx | 25 +- app/[leaflet_id]/actions/HomeButton.tsx | 55 +- app/[leaflet_id]/actions/PublishButton.tsx | 8 +- .../actions/ShareOptions/index.tsx | 2 + app/[leaflet_id]/publish/PublishPost.tsx | 2 +- app/globals.css | 37 ++ app/lish/[did]/[publication]/UpgradeModal.tsx | 1 + .../[did]/[publication]/dashboard/Actions.tsx | 9 +- .../[publication]/dashboard/DraftList.tsx | 2 +- .../[publication]/dashboard/DraftsTab.tsx | 51 ++ .../dashboard/NewDraftButton.tsx | 27 +- .../dashboard/PublicationDashboard.tsx | 109 ---- .../dashboard/PublicationSubscribers.tsx | 2 +- .../dashboard/PublishedPostsLists.tsx | 4 +- .../dashboard/analytics/page.tsx | 39 ++ .../[did]/[publication]/dashboard/layout.tsx | 133 ++++ .../[did]/[publication]/dashboard/page.tsx | 109 +--- .../[publication]/dashboard/posts/page.tsx | 42 ++ .../dashboard/settings/ProSettings.tsx | 10 +- .../[publication]/dashboard/settings/page.tsx | 28 + .../[publication]/dashboard/subs/page.tsx | 32 + .../theme-settings/ThemeSettingsContent.tsx | 2 +- components/ActionBar/ActionButton.tsx | 40 +- components/ActionBar/DesktopNavigation.tsx | 272 ++++++-- components/ActionBar/MobileNavigation.tsx | 217 +++++-- components/ActionBar/NavigationButtons.tsx | 114 ++-- components/ActionBar/ProfileButton.tsx | 6 +- .../ActionBar/PublicationNavigation.tsx | 148 ----- components/ActionBar/Publications.tsx | 174 +++--- components/ActionBar/Sidebar.tsx | 33 +- components/Icons/AnalyticsSmall.tsx | 19 + components/Icons/GoToArrowLined.tsx | 22 + components/Icons/LeafletTiny.tsx | 19 + components/Icons/MenuSmall.tsx | 1 + components/Icons/NewSmall.tsx | 19 + components/Icons/SortSmall.tsx | 1 + components/Icons/SubscribersSmall.tsx | 21 + components/Icons/TrendingSmall.tsx | 19 + components/IdentityProvider.tsx | 2 +- components/LoginButton.tsx | 24 +- components/Media.tsx | 4 +- components/Modal.tsx | 32 +- components/PageHeader.tsx | 8 +- components/PageLayouts/DashboardLayout.tsx | 580 ------------------ .../PageLayouts/DashboardPageLayout.tsx | 39 ++ components/PageLayouts/DashboardShell.tsx | 61 ++ .../PageLayouts/NotificationContent.tsx | 32 + components/PageLayouts/PageSearch.tsx | 333 ++++++++++ components/PageLayouts/dashboardState.ts | 126 ++++ components/Popover/index.tsx | 11 +- components/PostListing.tsx | 9 +- components/SpeedyLink.tsx | 2 + components/Subscribe/HandleSubscribe.tsx | 6 +- components/ThemeManager/ThemeSetter.tsx | 26 +- components/ToggleGroup.tsx | 6 +- components/ViewportSizeLayout.tsx | 118 ++-- components/utils/AddLeafletToHomepage.tsx | 20 - components/utils/DotLoader.tsx | 1 + src/hooks/isMobile.ts | 1 + 111 files changed, 2267 insertions(+), 1877 deletions(-) create mode 100644 app/(home-pages)/(writer)/WriterShell.tsx rename app/(home-pages)/{ => (writer)}/home/Actions/Actions.tsx (59%) rename app/(home-pages)/{ => (writer)}/home/Actions/CreateNewButton.tsx (77%) rename app/(home-pages)/{home/Actions/AccountSettings.tsx => (writer)/home/Actions/HomeThemeSetter.tsx} (72%) rename app/(home-pages)/{ => (writer)}/home/HomeEmpty/DiscoverIllo.tsx (100%) rename app/(home-pages)/{ => (writer)}/home/HomeEmpty/HomeEmpty.tsx (100%) rename app/(home-pages)/{ => (writer)}/home/HomeEmpty/WelcomeToLeafletIllo.tsx (100%) rename app/(home-pages)/{ => (writer)}/home/HomeLayout.tsx (89%) rename app/(home-pages)/{ => (writer)}/home/IdentitySetter.tsx (100%) rename app/(home-pages)/{ => (writer)}/home/LeafletList/LeafletContent.tsx (100%) rename app/(home-pages)/{ => (writer)}/home/LeafletList/LeafletInfo.tsx (100%) rename app/(home-pages)/{ => (writer)}/home/LeafletList/LeafletListItem.tsx (95%) rename app/(home-pages)/{ => (writer)}/home/LeafletList/LeafletOptions.tsx (100%) rename app/(home-pages)/{ => (writer)}/home/LeafletList/LeafletPreview.module.css (100%) rename app/(home-pages)/{ => (writer)}/home/LeafletList/LeafletPreview.tsx (100%) rename app/(home-pages)/{ => (writer)}/home/icon.tsx (100%) rename app/(home-pages)/{ => (writer)}/home/page.tsx (95%) rename app/(home-pages)/{ => (writer)}/home/storage.ts (100%) create mode 100644 app/(home-pages)/(writer)/layout.tsx rename app/(home-pages)/{ => (writer)}/looseleafs/LooseleafsLayout.tsx (80%) rename app/(home-pages)/{ => (writer)}/looseleafs/page.tsx (81%) rename app/(home-pages)/{ => (writer)}/notifications/BskyPostEmbedNotification.tsx (100%) rename app/(home-pages)/{ => (writer)}/notifications/CommentMentionNotification.tsx (100%) rename app/(home-pages)/{ => (writer)}/notifications/CommentNotication.tsx (100%) rename app/(home-pages)/{ => (writer)}/notifications/FollowNotification.tsx (100%) rename app/(home-pages)/{ => (writer)}/notifications/MentionNotification.tsx (100%) rename app/(home-pages)/{ => (writer)}/notifications/Notification.tsx (100%) rename app/(home-pages)/{ => (writer)}/notifications/NotificationList.tsx (100%) rename app/(home-pages)/{ => (writer)}/notifications/QuoteNotification.tsx (100%) rename app/(home-pages)/{ => (writer)}/notifications/RecommendNotification.tsx (100%) rename app/(home-pages)/{ => (writer)}/notifications/ReplyNotification.tsx (100%) rename app/(home-pages)/{ => (writer)}/notifications/getNotifications.ts (100%) rename app/(home-pages)/{ => (writer)}/notifications/page.tsx (61%) delete mode 100644 app/(home-pages)/home/Actions/HomeHelp.tsx create mode 100644 app/(home-pages)/reader/FeedLayout.tsx delete mode 100644 app/(home-pages)/reader/InteractionDrawers.tsx delete mode 100644 app/(home-pages)/reader/loading.tsx create mode 100644 app/(home-pages)/reader/trending/page.tsx create mode 100644 app/(home-pages)/tag/[tag]/layout.tsx create mode 100644 app/lish/[did]/[publication]/dashboard/DraftsTab.tsx delete mode 100644 app/lish/[did]/[publication]/dashboard/PublicationDashboard.tsx create mode 100644 app/lish/[did]/[publication]/dashboard/analytics/page.tsx create mode 100644 app/lish/[did]/[publication]/dashboard/layout.tsx create mode 100644 app/lish/[did]/[publication]/dashboard/posts/page.tsx create mode 100644 app/lish/[did]/[publication]/dashboard/settings/page.tsx create mode 100644 app/lish/[did]/[publication]/dashboard/subs/page.tsx delete mode 100644 components/ActionBar/PublicationNavigation.tsx create mode 100644 components/Icons/AnalyticsSmall.tsx create mode 100644 components/Icons/GoToArrowLined.tsx create mode 100644 components/Icons/LeafletTiny.tsx create mode 100644 components/Icons/NewSmall.tsx create mode 100644 components/Icons/SubscribersSmall.tsx create mode 100644 components/Icons/TrendingSmall.tsx delete mode 100644 components/PageLayouts/DashboardLayout.tsx create mode 100644 components/PageLayouts/DashboardPageLayout.tsx create mode 100644 components/PageLayouts/DashboardShell.tsx create mode 100644 components/PageLayouts/NotificationContent.tsx create mode 100644 components/PageLayouts/PageSearch.tsx create mode 100644 components/PageLayouts/dashboardState.ts delete mode 100644 components/utils/AddLeafletToHomepage.tsx diff --git a/CLAUDE.md b/CLAUDE.md index a2b07439..7dc6a047 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -71,4 +71,4 @@ To add or modify a lexicon: - **React contexts**: `DocumentProvider`, `LeafletContentProvider` for page-level data - **Inngest functions**: Async jobs in `app/api/inngest/functions/` - **Icons**: Icon components live in `components/Icons/`. Each icon is a named export in its own file (e.g. `RefreshSmall.tsx`), imports `Props` from `./Props`, spreads `{...props}` on the `` element, and uses `fill="currentColor"` instead of hardcoded colors like `fill="black"`. -- **Popovers and menus**: Use the existing `Popover` (`components/Popover`), `Menu`, and `MenuItem` (`components/Menu`) components — do not create new popover/menu primitives +- **Popovers and menus**: Use the existing `Popover` (`components/Popover`), `Menu`, and `MenuItem` (`components/Menu`) components — do not create new popover/menu primitives. Avoid using buttons inside Popover or Modal triggers unless using a specific button component. If button is being used as trigger, always add an "asChild" prop to the Menu or Popover diff --git a/app/(home-pages)/(writer)/WriterShell.tsx b/app/(home-pages)/(writer)/WriterShell.tsx new file mode 100644 index 00000000..965406de --- /dev/null +++ b/app/(home-pages)/(writer)/WriterShell.tsx @@ -0,0 +1,50 @@ +"use client"; + +import { usePathname } from "next/navigation"; +import { DashboardShell } from "components/PageLayouts/DashboardShell"; +import { PageTitle } from "components/ActionBar/DesktopNavigation"; +import { HomeSmall } from "components/Icons/HomeSmall"; +import { LooseleafTiny } from "components/Icons/LooseleafTiny"; +import { NotificationsUnreadSmall } from "components/Icons/NotificationSmall"; +import { Actions } from "./home/Actions/Actions"; + +const PAGE_META = [ + { + prefix: "/home", + id: "home", + title: "Home", + }, + { + prefix: "/looseleafs", + id: "looseleafs", + title: "Looseleafs", + showBackButton: true, + }, + { + prefix: "/notifications", + id: "notifications", + title: "Notifications", + }, +]; + +export function WriterShell(props: { children: React.ReactNode }) { + const pathname = usePathname(); + const meta = PAGE_META.find((m) => pathname.startsWith(m.prefix)); + + return ( + + ) + } + actions={} + > + {props.children} + + ); +} diff --git a/app/(home-pages)/home/Actions/Actions.tsx b/app/(home-pages)/(writer)/home/Actions/Actions.tsx similarity index 59% rename from app/(home-pages)/home/Actions/Actions.tsx rename to app/(home-pages)/(writer)/home/Actions/Actions.tsx index 6720edc0..dd300243 100644 --- a/app/(home-pages)/home/Actions/Actions.tsx +++ b/app/(home-pages)/(writer)/home/Actions/Actions.tsx @@ -1,8 +1,6 @@ "use client"; -import { ThemePopover } from "components/ThemeManager/ThemeSetter"; import { CreateNewLeafletButton } from "./CreateNewButton"; -import { HelpButton } from "app/[leaflet_id]/actions/HelpButton"; -import { AccountSettings } from "./AccountSettings"; +import { HomeThemeSetter } from "./HomeThemeSetter"; import { useIdentityData } from "components/IdentityProvider"; import { useReplicache } from "src/replicache"; @@ -12,7 +10,7 @@ export const Actions = () => { return ( <> - {identity && } + {identity && } ); }; diff --git a/app/(home-pages)/home/Actions/CreateNewButton.tsx b/app/(home-pages)/(writer)/home/Actions/CreateNewButton.tsx similarity index 77% rename from app/(home-pages)/home/Actions/CreateNewButton.tsx rename to app/(home-pages)/(writer)/home/Actions/CreateNewButton.tsx index 454ae7ca..cb9f8f54 100644 --- a/app/(home-pages)/home/Actions/CreateNewButton.tsx +++ b/app/(home-pages)/(writer)/home/Actions/CreateNewButton.tsx @@ -2,13 +2,14 @@ import { createNewLeaflet } from "actions/createNewLeaflet"; import { ActionButton } from "components/ActionBar/ActionButton"; +import { ButtonPrimary } from "components/Buttons"; import { AddTiny } from "components/Icons/AddTiny"; import { BlockCanvasPageSmall } from "components/Icons/BlockCanvasPageSmall"; import { BlockDocPageSmall } from "components/Icons/BlockDocPageSmall"; import { Menu, MenuItem } from "components/Menu"; import { useIsMobile } from "src/hooks/isMobile"; -export const CreateNewLeafletButton = (props: {}) => { +export const CreateNewLeafletButton = (props: { compact?: boolean }) => { let isMobile = useIsMobile(); let openNewLeaflet = (id: string) => { if (isMobile) { @@ -22,14 +23,21 @@ export const CreateNewLeafletButton = (props: {}) => { asChild side={isMobile ? "top" : "right"} align={isMobile ? "center" : "start"} + className="z-[60]!" trigger={ - - label="New" - smallOnMobile - /> + props.compact ? ( + + New Doc + + ) : ( + + label="New Doc" + /> + ) } > { +export const HomeThemeSetter = (props: { entityID: string }) => { let isMobile = useIsMobile(); - return ( { align={isMobile ? "center" : "start"} className={`w-xs bg-white!`} arrowFill="bg-white" - trigger={ label="Theme" />} + trigger={ + + label="Theme" + className="sm:flex! hidden" + /> + } > diff --git a/app/(home-pages)/home/HomeEmpty/DiscoverIllo.tsx b/app/(home-pages)/(writer)/home/HomeEmpty/DiscoverIllo.tsx similarity index 100% rename from app/(home-pages)/home/HomeEmpty/DiscoverIllo.tsx rename to app/(home-pages)/(writer)/home/HomeEmpty/DiscoverIllo.tsx diff --git a/app/(home-pages)/home/HomeEmpty/HomeEmpty.tsx b/app/(home-pages)/(writer)/home/HomeEmpty/HomeEmpty.tsx similarity index 100% rename from app/(home-pages)/home/HomeEmpty/HomeEmpty.tsx rename to app/(home-pages)/(writer)/home/HomeEmpty/HomeEmpty.tsx diff --git a/app/(home-pages)/home/HomeEmpty/WelcomeToLeafletIllo.tsx b/app/(home-pages)/(writer)/home/HomeEmpty/WelcomeToLeafletIllo.tsx similarity index 100% rename from app/(home-pages)/home/HomeEmpty/WelcomeToLeafletIllo.tsx rename to app/(home-pages)/(writer)/home/HomeEmpty/WelcomeToLeafletIllo.tsx diff --git a/app/(home-pages)/home/HomeLayout.tsx b/app/(home-pages)/(writer)/home/HomeLayout.tsx similarity index 89% rename from app/(home-pages)/home/HomeLayout.tsx rename to app/(home-pages)/(writer)/home/HomeLayout.tsx index 2220ac88..0bca199c 100644 --- a/app/(home-pages)/home/HomeLayout.tsx +++ b/app/(home-pages)/(writer)/home/HomeLayout.tsx @@ -14,16 +14,16 @@ import type { Attribute } from "src/replicache/attributes"; import { callRPC } from "app/api/rpc/client"; import { StaticLeafletDataContext } from "components/PageSWRDataProvider"; import { - HomeDashboardControls, - DashboardLayout, - DashboardState, + type DashboardState, useDashboardState, -} from "components/PageLayouts/DashboardLayout"; -import { Actions } from "./Actions/Actions"; +} from "components/PageLayouts/dashboardState"; +import { DashboardPageLayout } from "components/PageLayouts/DashboardPageLayout"; +import { PageSearch } from "components/PageLayouts/PageSearch"; import { GetLeafletDataReturnType } from "app/api/rpc/[command]/get_leaflet_data"; import { useState } from "react"; import { useDebouncedEffect } from "src/hooks/useDebouncedEffect"; import { HomeEmptyState } from "./HomeEmpty/HomeEmpty"; +import { CreateNewLeafletButton } from "./Actions/CreateNewButton"; export type Leaflet = { added_at: string; @@ -40,7 +40,7 @@ export type Leaflet = { }; }; -export const HomeLayout = (props: { +export const HomeContent = (props: { entityID: string | null; titles: { [root_entity: string]: string }; initialFacts: { @@ -73,34 +73,28 @@ export const HomeLayout = (props: { ).length > 0; return ( - } - tabs={{ - home: { - controls: ( - - ), - content: ( - - ), - }, - }} - pageTitle={"Home"} - /> + } + search={ + + } + showHeader={true} + > + + ); }; diff --git a/app/(home-pages)/home/IdentitySetter.tsx b/app/(home-pages)/(writer)/home/IdentitySetter.tsx similarity index 100% rename from app/(home-pages)/home/IdentitySetter.tsx rename to app/(home-pages)/(writer)/home/IdentitySetter.tsx diff --git a/app/(home-pages)/home/LeafletList/LeafletContent.tsx b/app/(home-pages)/(writer)/home/LeafletList/LeafletContent.tsx similarity index 100% rename from app/(home-pages)/home/LeafletList/LeafletContent.tsx rename to app/(home-pages)/(writer)/home/LeafletList/LeafletContent.tsx diff --git a/app/(home-pages)/home/LeafletList/LeafletInfo.tsx b/app/(home-pages)/(writer)/home/LeafletList/LeafletInfo.tsx similarity index 100% rename from app/(home-pages)/home/LeafletList/LeafletInfo.tsx rename to app/(home-pages)/(writer)/home/LeafletList/LeafletInfo.tsx diff --git a/app/(home-pages)/home/LeafletList/LeafletListItem.tsx b/app/(home-pages)/(writer)/home/LeafletList/LeafletListItem.tsx similarity index 95% rename from app/(home-pages)/home/LeafletList/LeafletListItem.tsx rename to app/(home-pages)/(writer)/home/LeafletList/LeafletListItem.tsx index 05907c33..979b0fba 100644 --- a/app/(home-pages)/home/LeafletList/LeafletListItem.tsx +++ b/app/(home-pages)/(writer)/home/LeafletList/LeafletListItem.tsx @@ -70,10 +70,7 @@ export const LeafletListItem = (props: { {cardBorderHidden && (
)} diff --git a/app/(home-pages)/home/LeafletList/LeafletOptions.tsx b/app/(home-pages)/(writer)/home/LeafletList/LeafletOptions.tsx similarity index 100% rename from app/(home-pages)/home/LeafletList/LeafletOptions.tsx rename to app/(home-pages)/(writer)/home/LeafletList/LeafletOptions.tsx diff --git a/app/(home-pages)/home/LeafletList/LeafletPreview.module.css b/app/(home-pages)/(writer)/home/LeafletList/LeafletPreview.module.css similarity index 100% rename from app/(home-pages)/home/LeafletList/LeafletPreview.module.css rename to app/(home-pages)/(writer)/home/LeafletList/LeafletPreview.module.css diff --git a/app/(home-pages)/home/LeafletList/LeafletPreview.tsx b/app/(home-pages)/(writer)/home/LeafletList/LeafletPreview.tsx similarity index 100% rename from app/(home-pages)/home/LeafletList/LeafletPreview.tsx rename to app/(home-pages)/(writer)/home/LeafletList/LeafletPreview.tsx diff --git a/app/(home-pages)/home/icon.tsx b/app/(home-pages)/(writer)/home/icon.tsx similarity index 100% rename from app/(home-pages)/home/icon.tsx rename to app/(home-pages)/(writer)/home/icon.tsx diff --git a/app/(home-pages)/home/page.tsx b/app/(home-pages)/(writer)/home/page.tsx similarity index 95% rename from app/(home-pages)/home/page.tsx rename to app/(home-pages)/(writer)/home/page.tsx index 5408ab37..45163cd9 100644 --- a/app/(home-pages)/home/page.tsx +++ b/app/(home-pages)/(writer)/home/page.tsx @@ -2,7 +2,7 @@ import { getIdentityData } from "actions/getIdentityData"; import { getFactsFromHomeLeaflets } from "app/api/rpc/[command]/getFactsFromHomeLeaflets"; import { supabaseServerClient } from "supabase/serverClient"; -import { HomeLayout } from "./HomeLayout"; +import { HomeContent } from "./HomeLayout"; export default async function Home() { let auth_res = await getIdentityData(); @@ -23,7 +23,7 @@ export default async function Home() { let home_docs_initialFacts = allLeafletFacts?.result || {}; return ( - {props.children}; +} diff --git a/app/(home-pages)/looseleafs/LooseleafsLayout.tsx b/app/(home-pages)/(writer)/looseleafs/LooseleafsLayout.tsx similarity index 80% rename from app/(home-pages)/looseleafs/LooseleafsLayout.tsx rename to app/(home-pages)/(writer)/looseleafs/LooseleafsLayout.tsx index c389d6e4..523e48eb 100644 --- a/app/(home-pages)/looseleafs/LooseleafsLayout.tsx +++ b/app/(home-pages)/(writer)/looseleafs/LooseleafsLayout.tsx @@ -1,17 +1,16 @@ "use client"; -import { DashboardLayout } from "components/PageLayouts/DashboardLayout"; +import { DashboardPageLayout } from "components/PageLayouts/DashboardPageLayout"; import { useState } from "react"; import { useDebouncedEffect } from "src/hooks/useDebouncedEffect"; import { Fact, PermissionToken } from "src/replicache"; import { Attribute } from "src/replicache/attributes"; -import { Actions } from "../home/Actions/Actions"; import { callRPC } from "app/api/rpc/client"; import { useIdentityData } from "components/IdentityProvider"; import useSWR from "swr"; -import { getHomeDocs } from "../home/storage"; import { Leaflet, LeafletList } from "../home/HomeLayout"; +import { CreateNewLeafletButton } from "../home/Actions/CreateNewButton"; -export const LooseleafsLayout = (props: { +export const LooseleafsContent = (props: { entityID: string | null; titles: { [root_entity: string]: string }; initialFacts: { @@ -30,25 +29,18 @@ export const LooseleafsLayout = (props: { ); return ( - } - tabs={{ - home: { - controls: null, - content: ( - - ), - }, - }} + + mobileActions={} + showHeader={false} + > + + ); }; diff --git a/app/(home-pages)/looseleafs/page.tsx b/app/(home-pages)/(writer)/looseleafs/page.tsx similarity index 81% rename from app/(home-pages)/looseleafs/page.tsx rename to app/(home-pages)/(writer)/looseleafs/page.tsx index 9ae9f7b3..462e8e99 100644 --- a/app/(home-pages)/looseleafs/page.tsx +++ b/app/(home-pages)/(writer)/looseleafs/page.tsx @@ -1,11 +1,7 @@ import { getIdentityData } from "actions/getIdentityData"; -import { DashboardLayout } from "components/PageLayouts/DashboardLayout"; -import { Actions } from "../home/Actions/Actions"; -import { Fact } from "src/replicache"; -import { Attribute } from "src/replicache/attributes"; import { getFactsFromHomeLeaflets } from "app/api/rpc/[command]/getFactsFromHomeLeaflets"; import { supabaseServerClient } from "supabase/serverClient"; -import { LooseleafsLayout } from "./LooseleafsLayout"; +import { LooseleafsContent } from "./LooseleafsLayout"; export default async function Home() { let auth_res = await getIdentityData(); @@ -26,7 +22,7 @@ export default async function Home() { let home_docs_initialFacts = allLeafletFacts?.result || {}; return ( - , - }, - }} - /> + + + ); } diff --git a/app/(home-pages)/home/Actions/HomeHelp.tsx b/app/(home-pages)/home/Actions/HomeHelp.tsx deleted file mode 100644 index 83c5d3d5..00000000 --- a/app/(home-pages)/home/Actions/HomeHelp.tsx +++ /dev/null @@ -1,27 +0,0 @@ -"use client"; -import { ActionButton } from "components/ActionBar/ActionButton"; -import { HelpSmall } from "components/Icons/HelpSmall"; -import { Popover } from "components/Popover"; - -export const HomeHelp = () => { - return ( - } label="Info" />} - > -
-

- Leaflets are saved to home per-device / browser using - cookies. -

-

- If you clear your cookies, they'll disappear. -

-

- Please contact us for help - recovering Leaflets! -

-
-
- ); -}; diff --git a/app/(home-pages)/p/[didOrHandle]/layout.tsx b/app/(home-pages)/p/[didOrHandle]/layout.tsx index 7619ade1..b84cfe71 100644 --- a/app/(home-pages)/p/[didOrHandle]/layout.tsx +++ b/app/(home-pages)/p/[didOrHandle]/layout.tsx @@ -4,12 +4,15 @@ import { supabaseServerClient } from "supabase/serverClient"; import { Json } from "supabase/database.types"; import { ProfileHeader } from "./ProfileHeader"; import { ProfileTabs } from "./ProfileTabs"; -import { DashboardLayout } from "components/PageLayouts/DashboardLayout"; +import { DashboardShell } from "components/PageLayouts/DashboardShell"; +import { DashboardPageLayout } from "components/PageLayouts/DashboardPageLayout"; import { ProfileLayout } from "./ProfileLayout"; import { Agent } from "@atproto/api"; import { get_profile_data } from "app/api/rpc/[command]/get_profile_data"; import { Metadata } from "next"; import { cache } from "react"; +import { PageTitle } from "components/ActionBar/DesktopNavigation"; +import { Avatar } from "components/Avatar"; // Cache the profile data call to prevent concurrent OAuth restores const getCachedProfileData = cache(async (did: string) => { @@ -77,29 +80,30 @@ export default async function ProfilePageLayout(props: { if (!profile) return null; + let displayName = profile.displayName || profile.handle; + return ( - } tabs={{ - default: { - controls: null, - content: ( - - - - <>{props.children} - - ), - }, + Inbox: { href: "/reader" }, + Trending: { href: "/reader/trending" }, + New: { href: "/reader/new" }, }} - /> + > + + + + + <>{props.children} + + + ); } diff --git a/app/(home-pages)/page.tsx b/app/(home-pages)/page.tsx index 036e9562..5b1539da 100644 --- a/app/(home-pages)/page.tsx +++ b/app/(home-pages)/page.tsx @@ -1,31 +1,15 @@ import { cookies } from "next/headers"; -import ReaderLayout from "./reader/layout"; -import ReaderPage from "./reader/page"; -import HomePage from "./home/page"; +import { redirect } from "next/navigation"; export default async function RootPage() { const cookieStore = await cookies(); const hasAuth = - cookieStore.has("auth_token") || - cookieStore.has("external_auth_token"); + cookieStore.has("auth_token") || cookieStore.has("external_auth_token"); - if (!hasAuth) { - return ( - - - - ); - } + if (!hasAuth) redirect("/reader"); const navState = cookieStore.get("nav-state")?.value; + if (navState === "reader") redirect("/reader"); - if (navState === "reader") { - return ( - - - - ); - } - - return ; + redirect("/home"); } diff --git a/app/(home-pages)/reader/FeedLayout.tsx b/app/(home-pages)/reader/FeedLayout.tsx new file mode 100644 index 00000000..53361ad0 --- /dev/null +++ b/app/(home-pages)/reader/FeedLayout.tsx @@ -0,0 +1,7 @@ +export const FeedLayout = (props: { children: React.ReactNode }) => { + return ( +
+ {props.children} +
+ ); +}; diff --git a/app/(home-pages)/reader/GlobalContent.tsx b/app/(home-pages)/reader/GlobalContent.tsx index 31ae8004..e081d0c4 100644 --- a/app/(home-pages)/reader/GlobalContent.tsx +++ b/app/(home-pages)/reader/GlobalContent.tsx @@ -5,10 +5,6 @@ import { callRPC } from "app/api/rpc/client"; import { EmptyState } from "components/EmptyState"; import { PostListing } from "components/PostListing"; import type { Post } from "./getReaderFeed"; -import { - DesktopInteractionPreviewDrawer, - MobileInteractionPreviewDrawer, -} from "./InteractionDrawers"; import { useSelectedPostListing } from "src/useSelectedPostState"; export const GlobalContent = (props: { @@ -38,18 +34,14 @@ export const GlobalContent = (props: { } return ( -
-
- {posts.map((p) => ( - - ))} -
- - -
+ <> + {posts.map((p) => ( + + ))} + ); }; diff --git a/app/(home-pages)/reader/InboxContent.tsx b/app/(home-pages)/reader/InboxContent.tsx index 6d8f9169..6b1cfb2a 100644 --- a/app/(home-pages)/reader/InboxContent.tsx +++ b/app/(home-pages)/reader/InboxContent.tsx @@ -8,16 +8,16 @@ import { getReaderFeed } from "./getReaderFeed"; import { useEffect, useRef } from "react"; import Link from "next/link"; import { PostListing } from "components/PostListing"; -import { - DesktopInteractionPreviewDrawer, - MobileInteractionPreviewDrawer, -} from "./InteractionDrawers"; import { useSelectedPostListing } from "src/useSelectedPostState"; +import { useIdentityData } from "components/IdentityProvider"; +import { LoginContent } from "components/LoginButton"; +import { EmptyState } from "components/EmptyState"; export const InboxContent = (props: { promise: Promise<{ posts: Post[]; nextCursor: Cursor | null }>; }) => { const { posts, nextCursor } = use(props.promise); + let { identity: identityData } = useIdentityData(); const getKey = ( pageIndex: number, previousPageData: { @@ -77,46 +77,49 @@ export const InboxContent = (props: { new Date(a.documents.data?.publishedAt || 0).getTime(), ); + if (!identityData) { + return ( + + + + ); + } + if (allPosts.length === 0) return ; return ( -
-
- {sortedPosts.map((p) => ( - - ))} - {/* Trigger element for loading more posts */} - - - -
+ ))} + {/* Trigger element for loading more posts */} +