From 3e7901bdbb54fdc2fa42083de674a9d4ab20fbc7 Mon Sep 17 00:00:00 2001 From: celine Date: Thu, 16 Apr 2026 03:38:44 +0000 Subject: [PATCH] Merge branch 'main' of https://github.com/hyperlink-academy/minilink into refactor/sign-in --- components/EmptyState.tsx | 30 ++++++++++++++++++++++++++++++ components/Modal.tsx | 4 +++- components/PageHeader.tsx | 4 +--- components/Tab.tsx | 3 ++- components/Toast.tsx | 2 +- components/ActionBar/ProfileButton.tsx | 6 ++++-- components/Domains/PublicationDomains.tsx | 6 ++---- components/Icons/PasteTiny.tsx | 19 +++++++++++++++++++ components/PageLayouts/DashboardLayout.tsx | 4 ++-- components/Pages/Page.tsx | 2 +- components/Pages/PublicationMetadata.tsx | 2 +- components/Popover/index.tsx | 8 ++++++-- components/ThemeManager/PubThemeSetter.tsx | 589 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- components/ThemeManager/PublicationThemeProvider.tsx | 28 ++++++++++++++++++++++------ app/(home-pages)/notifications/BskyPostEmbedNotification.tsx | 2 +- app/(home-pages)/notifications/Notification.tsx | 11 +++-------- app/(home-pages)/notifications/NotificationList.tsx | 16 +++++++++------- app/(home-pages)/notifications/QuoteNotification.tsx | 7 ++----- app/(home-pages)/reader/GlobalContent.tsx | 7 ++----- app/(home-pages)/reader/NewContent.tsx | 7 ++----- app/(home-pages)/reader/ReaderMentionsContent.tsx | 16 ++++++++++------ app/(home-pages)/reader/layout.tsx | 2 +- app/api/inngest/client.ts | 7 +++++++ app/lish/createPub/UpdatePubForm.tsx | 9 +-------- components/ThemeManager/Pickers/ColorPicker.tsx | 54 +++++++++++++++++++++++++++++++++--------------------- components/ThemeManager/Pickers/HexKeyboard.tsx | 230 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ components/ThemeManager/Pickers/NumPad.tsx | 162 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ components/ThemeManager/PubPickers/PubBackgroundPickers.tsx | 5 +++-- components/ThemeManager/PubPickers/PubPageWidthSetter.tsx | 4 ++-- components/ThemeManager/PubPickers/PubPresetPicker.tsx | 149 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ app/(home-pages)/home/LeafletList/LeafletListItem.tsx | 13 ++----------- app/(home-pages)/p/[didOrHandle]/PostsContent.tsx | 3 ++- app/(home-pages)/p/[didOrHandle]/ProfileHeader.tsx | 2 +- app/(home-pages)/p/[didOrHandle]/ProfileLayout.tsx | 6 +++--- app/(home-pages)/p/[didOrHandle]/ProfileTabs.tsx | 8 +++++--- app/(home-pages)/tag/[tag]/page.tsx | 40 ++++++++++++++-------------------------- app/api/webhooks/stripe/route.ts | 23 +++++++++++++++++++++++ app/lish/[did]/[publication]/PublicationContent.tsx | 253 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ app/lish/[did]/[publication]/PublicationHomeLayout.tsx | 4 ++-- app/lish/[did]/[publication]/UpgradeModal.tsx | 62 +++++++++++++++++++++++++++++++++++++------------------------- app/lish/[did]/[publication]/page.tsx | 198 +++++++++++++++++++++++++----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- app/(home-pages)/p/[didOrHandle]/comments/CommentsContent.tsx | 7 +++---- app/(home-pages)/p/[didOrHandle]/subscriptions/SubscriptionsContent.tsx | 5 ++--- app/lish/[did]/[publication]/[rkey]/LinearDocumentPage.tsx | 2 -- app/lish/[did]/[publication]/[rkey]/PostContent.tsx | 8 +++++--- app/lish/[did]/[publication]/dashboard/PublishedPostsLists.tsx | 11 +++++------ app/lish/[did]/[publication]/theme-settings/PostPreview.tsx | 150 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ app/lish/[did]/[publication]/theme-settings/PubPreview.tsx | 99 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ app/lish/[did]/[publication]/theme-settings/ThemeSettingsContent.tsx | 277 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ app/lish/[did]/[publication]/theme-settings/page.tsx | 62 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ app/lish/[did]/[publication]/theme-settings/postPreviewFakeBlocks.ts | 353 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ app/lish/[did]/[publication]/[rkey]/Interactions/Interactions.tsx | 4 +++- app/lish/[did]/[publication]/[rkey]/PostHeader/PostHeader.tsx | 4 ++-- app/lish/[did]/[publication]/dashboard/settings/GeneralSettings.tsx | 55 ++++++++++++++++++++++++++++++++----------------------- app/lish/[did]/[publication]/dashboard/settings/ManageProSubscription.tsx | 52 +++++++++++++++++++++++++++++++++++++--------------- app/lish/[did]/[publication]/dashboard/settings/PostOptions.tsx | 128 -------------------------------------------------------------------------------------------------------------------------------- app/lish/[did]/[publication]/dashboard/settings/PostSettings.tsx | 85 +++++++++++++++++++++++++++++++++++++++++++------------------------------------------ app/lish/[did]/[publication]/dashboard/settings/PubDomainSettings.tsx | 374 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ app/lish/[did]/[publication]/dashboard/settings/PublicationSettings.tsx | 166 ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- app/lish/[did]/[publication]/dashboard/settings/SettingsContent.tsx | 274 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------------------------------- app/lish/[did]/[publication]/dashboard/settings/ThemeSettings.tsx | 27 +++++++++++++++++---------- app/lish/[did]/[publication]/dashboard/settings/deletePublication.ts | 124 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 62 file(s) changed, 3162 insertion(s)(+), 1112 deletion(s)(-) diff --git a/components/EmptyState.tsx b/components/EmptyState.tsx new file mode 100644 --- /dev/null +++ b/components/EmptyState.tsx @@ -0,0 +1,30 @@ +export function EmptyState({ + title, + description, + className, + container = "frosted", + children, +}: { + title?: string; + description?: string; + className?: string; + container?: "frosted" | "opaque" | "none"; + children?: React.ReactNode; +}) { + const containerClass = + container === "frosted" + ? "frosted-container" + : container === "opaque" + ? "opaque-container" + : ""; + + return ( +
+ {title &&
{title}
} + {description && {description}} + {children} +
+ ); +} diff --git a/components/Modal.tsx b/components/Modal.tsx --- a/components/Modal.tsx +++ b/components/Modal.tsx @@ -41,7 +41,9 @@ ${className}`} > {title ? ( - {title} + +

{title}

+
) : ( )} diff --git a/components/PageHeader.tsx b/components/PageHeader.tsx --- a/components/PageHeader.tsx +++ b/components/PageHeader.tsx @@ -21,9 +21,7 @@ } }, []); - let headerBGColor = !cardBorderHidden - ? "var(--bg-leaflet)" - : "var(--bg-page)"; + let headerBGColor = cardBorderHidden ? "var(--bg-leaflet)" : "var(--bg-page)"; return (
{ return (
props.onSelect()} onMouseEnter={props.onMouseEnter} onPointerDown={props.onPointerDown} + aria-label={props.icon ? props.name : undefined} > {props.icon ? props.icon : props.name} {props.href && } diff --git a/components/Toast.tsx b/components/Toast.tsx --- a/components/Toast.tsx +++ b/components/Toast.tsx @@ -105,7 +105,7 @@ className={`toastAnimationWrapper fixed top-0 bottom-0 right-0 left-0 z-50 h-fit`} >
- +
+ +
)} diff --git a/components/Domains/PublicationDomains.tsx b/components/Domains/PublicationDomains.tsx --- a/components/Domains/PublicationDomains.tsx +++ b/components/Domains/PublicationDomains.tsx @@ -17,7 +17,7 @@ assignDomainToPublication, removeDomainAssignment, } from "actions/domains"; -import { PubSettingsHeader } from "app/lish/[did]/[publication]/dashboard/settings/PublicationSettings"; + import { AddDomainForm } from "./AddDomainForm"; import { DomainSettingsView } from "./DomainSettingsView"; import { PinTiny } from "components/Icons/PinTiny"; @@ -44,9 +44,7 @@ return (
- - Domains - +

Domains

This Publication's Domains

DEFAULT
diff --git a/components/Icons/PasteTiny.tsx b/components/Icons/PasteTiny.tsx new file mode 100644 --- /dev/null +++ b/components/Icons/PasteTiny.tsx @@ -0,0 +1,19 @@ +import { Props } from "./Props"; + +export const PasteTiny = (props: Props) => { + return ( + + + + ); +}; diff --git a/components/PageLayouts/DashboardLayout.tsx b/components/PageLayouts/DashboardLayout.tsx --- a/components/PageLayouts/DashboardLayout.tsx +++ b/components/PageLayouts/DashboardLayout.tsx @@ -187,7 +187,7 @@
@@ -217,7 +217,7 @@ })}
)} - {props.publication && ( + {props.publication && controls && ( - -
- {sample === "pub" ? ( - - ) : ( - - )} -
-
-
- - ); -}; - -const SamplePub = (props: { - pubBGImage: string | null; - pubBGRepeat: number | null; - showPageBackground: boolean; -}) => { - let { data } = usePublicationData(); - let { publication } = data || {}; - let record = useNormalizedPublicationRecord(); - - return ( -
-
-
- {record?.icon && publication?.uri && ( -
+
+ - )} +
+ { + setTheme((t) => ({ ...t, bgPage: color })); + }} + setBackgroundColor={(color) => { + setTheme((t) => ({ ...t, bgLeaflet: color })); + }} + openPicker={openPicker} + setOpenPicker={setOpenPicker} + hasPageBackground={!!showPageBackground} + setHasPageBackground={setShowPageBackground} + /> +
-
- {record?.name} -
-
- {record?.description} -
-
-
- Subscribe with Bluesky -
+
+
-
-
A Sample Post
-
- This is a sample description about the sample post +
+
+ { + setTheme((t) => ({ ...t, bgPage: color })); + }} + setPrimary={(color) => { + setTheme((t) => ({ ...t, primary: color })); + }} + openPicker={openPicker} + setOpenPicker={(pickers) => setOpenPicker(pickers)} + hasPageBackground={showPageBackground} + /> +
+ +
-
Jan 1, 20XX
+ { + setTheme((t) => ({ ...t, accent1: color })); + }} + accent2={localPubTheme.accent2} + setAccent2={(color) => { + setTheme((t) => ({ ...t, accent2: color })); + }} + openPicker={openPicker} + setOpenPicker={(pickers) => setOpenPicker(pickers)} + />
); -}; +} -const SamplePost = (props: { - pubBGImage: string | null; - pubBGRepeat: number | null; - showPageBackground: boolean; +export const PubThemeSetter = (props: { + backToMenu: () => void; + loading: boolean; + setLoading: (l: boolean) => void; }) => { - let { data } = usePublicationData(); - let { publication } = data || {}; - let record = useNormalizedPublicationRecord(); + let [sample, setSample] = useState<"pub" | "post">("pub"); + let state = usePubThemeEditorState(); + let { + localPubTheme, + headingFont, + bodyFont, + image, + pageWidth, + pubBGImage, + leafletBGRepeat, + pub, + record, + showPageBackground, + submitTheme, + } = state; + return ( -
-
+ -
-
- {record?.name} -
-
- A Sample Post -
-
- A short sample description about the sample post -
-
Jan 1, 20XX
-
-
-
- Lorem ipsum dolor sit amet consectetur adipiscing elit. Quisque - faucibus ex sapien vitae pellentesque sem placerat. In id cursus mi - pretium tellus duis convallis. Tempus leo eu aenean sed diam urna - tempor. +
+
+
-
- Pulvinar vivamus fringilla lacus nec metus bibendum egestas. Iaculis - massa nisl malesuada lacinia integer nunc posuere. Ut hendrerit - semper vel class aptent taciti sociosqu. Ad litora torquent per - conubia nostra inceptos himenaeos. -
-
- Sed et nisi semper, egestas purus a, egestas nulla. Nulla ultricies, - purus non dapibus tincidunt, nunc sem rhoncus sem, vel malesuada - tellus enim sit amet magna. Donec ac justo a ipsum fermentum - vulputate. Etiam sit amet viverra leo. Aenean accumsan consectetur - velit. Vivamus at justo a nisl imperdiet dictum. Donec scelerisque - ex eget turpis scelerisque tincidunt. Proin non convallis nibh, eget - aliquet ex. Curabitur ornare a ipsum in ultrices. +
+ +
+
+
Preview
+ {" "} + + +
+
+
+ { + await submitTheme(props.setLoading); + }} + > + {props.loading ? "Saving..." : "Save Theme"} + +
-
-
+ + ); }; diff --git a/components/ThemeManager/PublicationThemeProvider.tsx b/components/ThemeManager/PublicationThemeProvider.tsx --- a/components/ThemeManager/PublicationThemeProvider.tsx +++ b/components/ThemeManager/PublicationThemeProvider.tsx @@ -69,16 +69,31 @@ pub_creator: string; className?: string; children: React.ReactNode; + localBgImage?: string | null; + localBgImageRepeat?: number | null; }) { - let backgroundImage = props.theme?.backgroundImage?.image?.ref - ? blobRefToSrc(props.theme?.backgroundImage?.image?.ref, props.pub_creator) - : null; + let backgroundImage = + props.localBgImage !== undefined + ? props.localBgImage + : props.theme?.backgroundImage?.image?.ref + ? blobRefToSrc( + props.theme?.backgroundImage?.image?.ref, + props.pub_creator, + ) + : null; - let backgroundImageRepeat = props.theme?.backgroundImage?.repeat; - let backgroundImageSize = props.theme?.backgroundImage?.width || 500; + let backgroundImageRepeat = + props.localBgImageRepeat !== undefined + ? !!props.localBgImageRepeat + : props.theme?.backgroundImage?.repeat; + let backgroundImageSize = + (props.localBgImageRepeat !== undefined + ? props.localBgImageRepeat + : props.theme?.backgroundImage?.width) || 500; + return (
setTheme({}), changes: Object.keys(localOverrides).length > 0, }; }; diff --git a/app/(home-pages)/notifications/BskyPostEmbedNotification.tsx b/app/(home-pages)/notifications/BskyPostEmbedNotification.tsx --- a/app/(home-pages)/notifications/BskyPostEmbedNotification.tsx +++ b/app/(home-pages)/notifications/BskyPostEmbedNotification.tsx @@ -28,7 +28,7 @@ {props.bskyPostText && (
               {props.bskyPostText}
diff --git a/app/(home-pages)/notifications/Notification.tsx b/app/(home-pages)/notifications/Notification.tsx
--- a/app/(home-pages)/notifications/Notification.tsx
+++ b/app/(home-pages)/notifications/Notification.tsx
@@ -23,14 +23,9 @@
     
+      
     );
   return (
-    
-
+
+
+

Notifications

+ {notifications.map((n) => { if (n.type === "comment") { if (n.parentData) return ; diff --git a/app/(home-pages)/notifications/QuoteNotification.tsx b/app/(home-pages)/notifications/QuoteNotification.tsx --- a/app/(home-pages)/notifications/QuoteNotification.tsx +++ b/app/(home-pages)/notifications/QuoteNotification.tsx @@ -27,12 +27,9 @@ content={
- +
               {postText}
diff --git a/app/(home-pages)/reader/GlobalContent.tsx b/app/(home-pages)/reader/GlobalContent.tsx
--- a/app/(home-pages)/reader/GlobalContent.tsx
+++ b/app/(home-pages)/reader/GlobalContent.tsx
@@ -2,6 +2,7 @@
 import { use } from "react";
 import useSWR from "swr";
 import { callRPC } from "app/api/rpc/client";
+import { EmptyState } from "components/EmptyState";
 import { PostListing } from "components/PostListing";
 import type { Post } from "./getReaderFeed";
 import {
@@ -33,11 +34,7 @@
   let selectedPost = useSelectedPostListing((s) => s.selectedPostListing);
 
   if (posts.length === 0) {
-    return (
-      
- Nothing trending right now. Check back soon! -
- ); + return ; } return ( diff --git a/app/(home-pages)/reader/NewContent.tsx b/app/(home-pages)/reader/NewContent.tsx --- a/app/(home-pages)/reader/NewContent.tsx +++ b/app/(home-pages)/reader/NewContent.tsx @@ -1,6 +1,7 @@ "use client"; import { use } from "react"; +import { EmptyState } from "components/EmptyState"; import type { Cursor, Post } from "./getReaderFeed"; import useSWRInfinite from "swr/infinite"; import { getNewFeed } from "./getNewFeed"; @@ -65,11 +66,7 @@ const allPosts = data ? data.flatMap((page) => page.posts) : []; if (allPosts.length === 0) { - return ( -
- No posts yet. Check back soon! -
- ); + return ; } return ( diff --git a/app/(home-pages)/reader/ReaderMentionsContent.tsx b/app/(home-pages)/reader/ReaderMentionsContent.tsx --- a/app/(home-pages)/reader/ReaderMentionsContent.tsx +++ b/app/(home-pages)/reader/ReaderMentionsContent.tsx @@ -2,6 +2,7 @@ import useSWR from "swr"; import { PostView } from "@atproto/api/dist/client/types/app/bsky/feed/defs"; import { BskyPostContent } from "app/lish/[did]/[publication]/[rkey]/BskyPostContent"; +import { EmptyState } from "components/EmptyState"; import { DotLoader } from "components/utils/DotLoader"; async function fetchBskyPosts(uris: string[]): Promise { @@ -17,9 +18,10 @@ quotesAndMentions: { uri: string; link?: string }[]; }) { const uris = props.quotesAndMentions.map((q) => q.uri); - const key = uris.length > 0 - ? `/api/bsky/hydrate?${new URLSearchParams({ uris: JSON.stringify(uris) }).toString()}` - : null; + const key = + uris.length > 0 + ? `/api/bsky/hydrate?${new URLSearchParams({ uris: JSON.stringify(uris) }).toString()}` + : null; const { data: bskyPosts, isLoading } = useSWR(key, () => fetchBskyPosts(uris), @@ -27,9 +29,11 @@ if (props.quotesAndMentions.length === 0) { return ( -
-
no mentions yet!
-
+ ); } diff --git a/app/(home-pages)/reader/layout.tsx b/app/(home-pages)/reader/layout.tsx --- a/app/(home-pages)/reader/layout.tsx +++ b/app/(home-pages)/reader/layout.tsx @@ -57,7 +57,7 @@ className={`pubTabs px-1 py-0 flex gap-1 items-center rounded-md hover:cursor-pointer ${ isActive(tab.href) ? "text-accent-2 bg-accent-1 font-bold -mb-px" - : "text-tertiary" + : "text-secondary" }`} > {tab.name} diff --git a/app/api/inngest/client.ts b/app/api/inngest/client.ts --- a/app/api/inngest/client.ts +++ b/app/api/inngest/client.ts @@ -82,6 +82,13 @@ subscriptionId: string; }; }; + "stripe/invoice.payment.succeeded": { + data: { + invoiceId: string; + subscriptionId: string; + customerId: string; + }; + }; "stripe/invoice.payment.failed": { data: { invoiceId: string; diff --git a/app/lish/createPub/UpdatePubForm.tsx b/app/lish/createPub/UpdatePubForm.tsx --- a/app/lish/createPub/UpdatePubForm.tsx +++ b/app/lish/createPub/UpdatePubForm.tsx @@ -10,7 +10,6 @@ import { mutate } from "swr"; import { AddTiny } from "components/Icons/AddTiny"; import { useToaster } from "components/Toast"; -import { PubSettingsHeader } from "../[did]/[publication]/dashboard/settings/PublicationSettings"; import { Toggle } from "components/Toggle"; export const EditPubForm = (props: { @@ -85,13 +84,7 @@ mutate("publication-data"); }} > - - General Settings - +

diff --git a/components/ThemeManager/Pickers/ColorPicker.tsx b/components/ThemeManager/Pickers/ColorPicker.tsx --- a/components/ThemeManager/Pickers/ColorPicker.tsx +++ b/components/ThemeManager/Pickers/ColorPicker.tsx @@ -15,6 +15,9 @@ import { pickers } from "../ThemeSetter"; import { Separator } from "components/Layout"; import { onMouseDown } from "src/utils/iosInputMouseDown"; +import { useIsMobile } from "src/hooks/isMobile"; +import { HexKeyboard } from "./HexKeyboard"; +import { NumPad } from "./NumPad"; export let thumbStyle = "w-4 h-4 rounded-full border-2 border-white shadow-[0_0_0_1px_#8C8C8C,inset_0_0_0_1px_#8C8C8C]"; @@ -33,11 +36,14 @@ disabled?: boolean; children?: React.ReactNode; }) => { + let isMobile = useIsMobile(); + return (

diff --git a/components/ThemeManager/Pickers/HexKeyboard.tsx b/components/ThemeManager/Pickers/HexKeyboard.tsx new file mode 100644 --- /dev/null +++ b/components/ThemeManager/Pickers/HexKeyboard.tsx @@ -0,0 +1,230 @@ +"use client"; + +import { useState, useCallback, useRef } from "react"; +import { Color, parseColor } from "react-aria-components"; +import { Popover } from "components/Popover"; +import { GoToArrow } from "components/Icons/GoToArrow"; +import { useSmoker } from "components/Toast"; +import { CopyTiny } from "components/Icons/CopyTiny"; +import { PasteTiny } from "components/Icons/PasteTiny"; + +const HEX_ROWS = [ + ["1", "2", "3", "A", "B", "C"], + ["4", "5", "6", "D", "E", "F"], +] as const; + +const BOTTOM_ROW = ["7", "8", "9", "0"] as const; + +const HexKey = (props: { + onClick: () => void; + children: React.ReactNode; + className?: string; + disabled?: boolean; + ariaLabel?: string; +}) => { + return ( + + ); +}; + +export const HexKeyboard = (props: { + value: Color | undefined; + setValue: (c: Color) => void; +}) => { + let hexString = props.value + ? props.value.toString("hex").toUpperCase() + : "#000000"; + + let [draft, setDraft] = useState(hexString); + let committed = useRef(false); + let copyButtonRef = useRef(null); + let smoker = useSmoker(); + + let syncDraftToValue = useCallback(() => { + let hex = props.value + ? props.value.toString("hex").toUpperCase() + : "#000000"; + setDraft(hex); + committed.current = true; + }, [props.value]); + + let handleKey = (key: string) => { + if (committed.current) { + setDraft("#" + key); + committed.current = false; + return; + } + // Max hex length is 7 chars including # + if (draft.length >= 7) return; + setDraft((prev) => prev + key); + }; + + let handleDelete = () => { + committed.current = false; + setDraft((prev) => { + // Don't delete the # prefix + if (prev.length <= 1) return prev; + return prev.slice(0, -1); + }); + }; + + let handleSubmit = () => { + try { + // Pad short values: if user typed #ABC, expand to #AABBCC + let raw = draft.replace("#", ""); + let padded = raw; + if (raw.length === 3) { + padded = raw[0] + raw[0] + raw[1] + raw[1] + raw[2] + raw[2]; + } else if (raw.length < 6) { + padded = raw.padEnd(6, "0"); + } + let color = parseColor("#" + padded); + props.setValue(color); + setDraft("#" + padded.toUpperCase()); + committed.current = true; + } catch { + // Invalid color, reset draft + syncDraftToValue(); + } + }; + + let isValidPartialHex = /^#[0-9A-Fa-f]{0,6}$/.test(draft); + let isSubmittable = + /^#[0-9A-Fa-f]{3}$/.test(draft) || /^#[0-9A-Fa-f]{6}$/.test(draft); + + let handleCopy = () => { + navigator.clipboard.writeText(draft); + let rect = copyButtonRef.current?.getBoundingClientRect(); + smoker({ + position: { + x: rect ? rect.left + rect.width / 2 : 0, + y: rect ? rect.top - 8 : 0, + }, + text: "Copied!", + }); + }; + + let handlePaste = async () => { + try { + let text = await navigator.clipboard.readText(); + let cleaned = text.trim(); + if (!cleaned.startsWith("#")) cleaned = "#" + cleaned; + cleaned = cleaned.toUpperCase(); + if (/^#[0-9A-Fa-f]{0,6}$/.test(cleaned)) { + setDraft(cleaned); + committed.current = false; + } + } catch { + // Clipboard read failed, do nothing + } + }; + + return ( + { + if (open) { + syncDraftToValue(); + } + }} + trigger={ +
+ {hexString} +
+ } + > +
+ {/* Display current draft value */} +
+ {draft} +
+ + {/* Copy / Paste buttons */} +
+ + +
+ +
+ + {/* Hex keyboard grid — 6 columns */} +
+ {/* Row 1: 1 2 3 A B C */} + {HEX_ROWS[0].map((key) => ( + handleKey(key)}> + {key} + + ))} + + {/* Row 2: 4 5 6 D E F */} + {HEX_ROWS[1].map((key) => ( + handleKey(key)}> + {key} + + ))} + + {/* Row 3: 7 8 9 0 [delete] [submit] */} + {BOTTOM_ROW.map((key) => ( + handleKey(key)}> + {key} + + ))} + + ⌫ + + + + +
+
+
+ ); +}; diff --git a/components/ThemeManager/Pickers/NumPad.tsx b/components/ThemeManager/Pickers/NumPad.tsx new file mode 100644 --- /dev/null +++ b/components/ThemeManager/Pickers/NumPad.tsx @@ -0,0 +1,162 @@ +"use client"; + +import { useState, useCallback, useRef } from "react"; +import { Color } from "react-aria-components"; +import { Popover } from "components/Popover"; +import { GoToArrow } from "components/Icons/GoToArrow"; + +const NUM_ROWS = [ + ["1", "2", "3"], + ["4", "5", "6"], + ["7", "8", "9"], +] as const; + +const NumKey = (props: { + onClick: () => void; + children: React.ReactNode; + className?: string; + disabled?: boolean; + ariaLabel?: string; +}) => { + return ( + + ); +}; + +export const NumPad = (props: { + value: Color | undefined; + setValue: (c: Color) => void; +}) => { + let alphaPercent = props.value + ? Math.round(props.value.getChannelValue("alpha") * 100) + : 100; + + let displayValue = `${alphaPercent}%`; + + let [draft, setDraft] = useState(String(alphaPercent)); + let committed = useRef(false); + + let syncDraftToValue = useCallback(() => { + let percent = props.value + ? Math.round(props.value.getChannelValue("alpha") * 100) + : 100; + setDraft(String(percent)); + committed.current = true; + }, [props.value]); + + let handleKey = (key: string) => { + if (committed.current) { + committed.current = false; + setDraft(key); + return; + } + setDraft((prev) => { + let next = prev === "0" ? key : prev + key; + // Don't allow more than 3 digits (max 100) + if (next.length > 3) return prev; + let num = parseInt(next, 10); + if (num > 100) return prev; + return next; + }); + }; + + let handleDelete = () => { + committed.current = false; + setDraft((prev) => { + if (prev.length <= 1) return "0"; + return prev.slice(0, -1); + }); + }; + + let handleSubmit = () => { + let num = parseInt(draft, 10); + if (isNaN(num) || num < 0 || num > 100) { + syncDraftToValue(); + return; + } + if (props.value) { + let updated = props.value.withChannelValue("alpha", num / 100); + props.setValue(updated); + } + committed.current = true; + }; + + let isValid = /^\d{1,3}$/.test(draft) && parseInt(draft, 10) <= 100; + + return ( + { + if (open) { + syncDraftToValue(); + } + }} + trigger={ + + } + > +
+ {/* Display current draft value */} +
+ {draft}% +
+ + {/* Numpad grid — 3 columns */} +
+ {NUM_ROWS.map((row) => + row.map((key) => ( + handleKey(key)}> + {key} + + )), + )} + + {/* Bottom row: 0, delete, submit */} + handleKey("0")}>0 + + ⌫ + + + + +
+
+
+ ); +}; diff --git a/components/ThemeManager/PubPickers/PubBackgroundPickers.tsx b/components/ThemeManager/PubPickers/PubBackgroundPickers.tsx --- a/components/ThemeManager/PubPickers/PubBackgroundPickers.tsx +++ b/components/ThemeManager/PubPickers/PubBackgroundPickers.tsx @@ -103,7 +103,7 @@ alpha={props.hasPageBackground ? true : false} /> )} -
+
-
- ); -}; - -const LeafletLink = (props: { id: string; className: string }) => { - return ( - ); }; diff --git a/app/(home-pages)/p/[didOrHandle]/PostsContent.tsx b/app/(home-pages)/p/[didOrHandle]/PostsContent.tsx --- a/app/(home-pages)/p/[didOrHandle]/PostsContent.tsx +++ b/app/(home-pages)/p/[didOrHandle]/PostsContent.tsx @@ -1,5 +1,6 @@ "use client"; +import { EmptyState } from "components/EmptyState"; import { PostListing } from "components/PostListing"; import type { Post } from "app/(home-pages)/reader/getReaderFeed"; import type { Cursor } from "./getProfilePosts"; @@ -64,7 +65,7 @@ const allPosts = data ? data.flatMap((page) => page.posts) : []; if (allPosts.length === 0 && !isValidating) { - return
No posts yet
; + return ; } return ( diff --git a/app/(home-pages)/p/[didOrHandle]/ProfileHeader.tsx b/app/(home-pages)/p/[didOrHandle]/ProfileHeader.tsx --- a/app/(home-pages)/p/[didOrHandle]/ProfileHeader.tsx +++ b/app/(home-pages)/p/[didOrHandle]/ProfileHeader.tsx @@ -45,7 +45,7 @@ return (
{!props.popover && } diff --git a/app/(home-pages)/p/[didOrHandle]/ProfileLayout.tsx b/app/(home-pages)/p/[didOrHandle]/ProfileLayout.tsx --- a/app/(home-pages)/p/[didOrHandle]/ProfileLayout.tsx +++ b/app/(home-pages)/p/[didOrHandle]/ProfileLayout.tsx @@ -10,10 +10,10 @@ className={` ${ cardBorderHidden - ? "" - : "overflow-y-scroll h-full border border-border-light rounded-lg bg-bg-page px-3 sm:px-4" + ? "bg-transparent" + : "overflow-y-scroll h-full border border-border-light rounded-lg! container px-3 sm:px-4 sm:-mt-2" } - max-w-prose mx-auto w-full + max-w-prose w-full flex flex-col pb-3 text-center `} diff --git a/app/(home-pages)/p/[didOrHandle]/ProfileTabs.tsx b/app/(home-pages)/p/[didOrHandle]/ProfileTabs.tsx --- a/app/(home-pages)/p/[didOrHandle]/ProfileTabs.tsx +++ b/app/(home-pages)/p/[didOrHandle]/ProfileTabs.tsx @@ -41,7 +41,9 @@ const bgColor = cardBorderHidden ? "var(--bg-leaflet)" : "var(--bg-page)"; return ( -
+
{props.name} diff --git a/app/(home-pages)/tag/[tag]/page.tsx b/app/(home-pages)/tag/[tag]/page.tsx --- a/app/(home-pages)/tag/[tag]/page.tsx +++ b/app/(home-pages)/tag/[tag]/page.tsx @@ -1,8 +1,6 @@ import { DashboardLayout } from "components/PageLayouts/DashboardLayout"; -import { Tag } from "components/Tags"; import { PostListing } from "components/PostListing"; import { getDocumentsByTag } from "./getDocumentsByTag"; -import { TagTiny } from "components/Icons/TagTiny"; import { Metadata } from "next"; export async function generateMetadata(props: { @@ -41,38 +39,28 @@ posts: Awaited>["posts"]; }) => { return ( -
-
- -
-
+
+

Tag: {props.tag}

+ +
{props.posts.length === 0 ? ( - + ) : ( - props.posts.map((post) => ( - - )) + <> +
+ {props.posts.length} {props.posts.length === 1 ? "post" : "posts"} +
+ {props.posts.map((post) => ( + + ))} + )}
); }; -const TagHeader = (props: { tag: string; postCount: number }) => { - return ( -
-
- -

{props.tag}

-
-
- {props.postCount} {props.postCount === 1 ? "post" : "posts"} -
-
- ); -}; - -const EmptyState = (props: { tag: string }) => { +const NoPostsForTag = (props: { tag: string }) => { return (
diff --git a/app/api/webhooks/stripe/route.ts b/app/api/webhooks/stripe/route.ts --- a/app/api/webhooks/stripe/route.ts +++ b/app/api/webhooks/stripe/route.ts @@ -57,6 +57,29 @@ break; } + case "invoice.payment_succeeded": { + const invoice = event.data.object; + const sub = invoice.parent?.subscription_details?.subscription; + const subId = + typeof sub === "string" + ? sub + : typeof sub === "object" && sub + ? sub.id + : ""; + if (subId) { + await inngest.send({ + name: "stripe/invoice.payment.succeeded", + data: { + invoiceId: invoice.id, + subscriptionId: subId, + customerId: invoice.customer as string, + }, + }); + await handleSubscriptionUpdated(subId); + } + break; + } + case "invoice.payment_failed": { const invoice = event.data.object; const sub = invoice.parent?.subscription_details?.subscription; diff --git a/app/lish/[did]/[publication]/PublicationContent.tsx b/app/lish/[did]/[publication]/PublicationContent.tsx new file mode 100644 --- /dev/null +++ b/app/lish/[did]/[publication]/PublicationContent.tsx @@ -0,0 +1,253 @@ +import React from "react"; +import { AtUri } from "@atproto/syntax"; +import { + getPublicationURL, + getDocumentURL, +} from "app/lish/createPub/getPublicationURL"; +import { SubscribeWithBluesky } from "app/lish/Subscribe"; +import { InteractionPreview } from "components/InteractionsPreview"; +import { LocalizedDate } from "./LocalizedDate"; +import { PublicationHomeLayout } from "./PublicationHomeLayout"; +import { PublicationAuthor } from "./PublicationAuthor"; +import { + normalizePublicationRecord, + normalizeDocumentRecord, +} from "src/utils/normalizeRecords"; +import { getFirstParagraph } from "src/utils/getFirstParagraph"; +import { FontLoader } from "components/FontLoader"; +import { SpeedyLink } from "components/SpeedyLink"; + +type FakePost = { + title: string; + description: string; + date: React.ReactNode; +}; + +export const PublicationContent = ({ + record, + publication, + did, + profile, + showPageBackground, + fakePosts, +}: { + record: ReturnType; + publication: { + uri: string; + name: string; + identity_did: string; + record: unknown; + publication_subscriptions: { identity: string }[]; + documents_in_publications: { + documents: { + uri: string; + data: unknown; + comments_on_documents: { count: number }[]; + document_mentions_in_bsky: { count: number }[]; + recommends_on_documents: { count: number }[]; + } | null; + }[]; + }; + did: string; + profile: { did: string; displayName?: string; handle: string } | undefined; + showPageBackground: boolean | undefined; + fakePosts?: FakePost[]; +}) => { + return ( + <> + + + + ) : undefined + } + subscribeButton={ + + } + /> +
+ {fakePosts && + fakePosts.map((post, i) => ( + + ))} + {!fakePosts && + publication.documents_in_publications + .filter((d) => !!d?.documents) + .sort((a, b) => { + const aRecord = normalizeDocumentRecord(a.documents?.data); + const bRecord = normalizeDocumentRecord(b.documents?.data); + const aDate = aRecord?.publishedAt + ? new Date(aRecord.publishedAt) + : new Date(0); + const bDate = bRecord?.publishedAt + ? new Date(bRecord.publishedAt) + : new Date(0); + return bDate.getTime() - aDate.getTime(); // Sort by most recent first + }) + .map((doc) => { + if (!doc.documents) return null; + const doc_record = normalizeDocumentRecord(doc.documents.data); + if (!doc_record) return null; + let uri = new AtUri(doc.documents.uri); + let quotes = + doc.documents.document_mentions_in_bsky[0].count || 0; + let comments = + record?.preferences?.showComments === false + ? 0 + : doc.documents.comments_on_documents[0].count || 0; + let recommends = + doc.documents.recommends_on_documents?.[0]?.count || 0; + let tags = doc_record.tags || []; + + const docUrl = getDocumentURL( + doc_record, + doc.documents.uri, + publication, + ); + return ( + + + ) : undefined + } + interactions={ + + } + /> + + ); + })} +
+
+ + ); +}; + +export function PublicationHeader(props: { + iconUrl?: string; + publicationName: string; + description?: string; + author?: React.ReactNode; + subscribeButton?: React.ReactNode; +}) { + return ( +
+ {props.iconUrl && ( +
+ )} +

+ {props.publicationName} +

+

{props.description}

+ {props.author} +
{props.subscribeButton}
+
+ ); +} + +export function PublicationPostItem(props: { + href?: string; + title?: string; + description?: string; + date?: React.ReactNode; + interactions?: React.ReactNode; +}) { + const content = ( + <> + {props.title &&

{props.title}

} +

{props.description}

+ + ); + + return ( + <> +
+ {props.href ? ( + + {content} + + ) : ( +
+ {content} +
+ )} + +
+

{props.date}

+ {props.interactions} +
+
+
+ + ); +} diff --git a/app/lish/[did]/[publication]/PublicationHomeLayout.tsx b/app/lish/[did]/[publication]/PublicationHomeLayout.tsx --- a/app/lish/[did]/[publication]/PublicationHomeLayout.tsx +++ b/app/lish/[did]/[publication]/PublicationHomeLayout.tsx @@ -11,11 +11,11 @@ return (
{props.children}
diff --git a/app/lish/[did]/[publication]/UpgradeModal.tsx b/app/lish/[did]/[publication]/UpgradeModal.tsx --- a/app/lish/[did]/[publication]/UpgradeModal.tsx +++ b/app/lish/[did]/[publication]/UpgradeModal.tsx @@ -3,6 +3,7 @@ import { useState } from "react"; import { createCheckoutSession } from "actions/createCheckoutSession"; import { DotLoader } from "components/utils/DotLoader"; +import { ToggleGroup } from "components/ToggleGroup"; export const UpgradeContent = () => { let [cadence, setCadence] = useState<"year" | "month">("year"); @@ -37,20 +38,15 @@
-
- - -
+
{cadence === "year" ? "$120" : "$12"} @@ -90,20 +86,36 @@ ); }; -export const InlineUpgrade = () => { +export const InlineUpgradeToPro = (props: { compact?: boolean }) => { return ( -
- - Upgrade to Leaflet Pro! - - } - /> -
+
+ +
Analytics for all your pubs!
Emails and membership coming soon.
+ ); +}; + +export const UpgradeToProButton = (props: { + fullWidth?: boolean; + compact?: boolean; +}) => { + return ( + + Upgrade to Leaflet Pro! + + } + /> ); }; diff --git a/app/lish/[did]/[publication]/page.tsx b/app/lish/[did]/[publication]/page.tsx --- a/app/lish/[did]/[publication]/page.tsx +++ b/app/lish/[did]/[publication]/page.tsx @@ -1,5 +1,4 @@ import { supabaseServerClient } from "supabase/serverClient"; -import { AtUri } from "@atproto/syntax"; import { getPublicationURL, getDocumentURL, @@ -7,38 +6,23 @@ import { BskyAgent } from "@atproto/api"; import { publicationNameOrUriFilter } from "src/utils/uriHelpers"; import React from "react"; -import { - PublicationBackgroundProvider, - PublicationThemeProvider, -} from "components/ThemeManager/PublicationThemeProvider"; import { NotFoundLayout } from "components/PageLayouts/NotFoundLayout"; -import { SpeedyLink } from "components/SpeedyLink"; -import { InteractionPreview } from "components/InteractionsPreview"; -import { LocalizedDate } from "./LocalizedDate"; -import { PublicationHomeLayout } from "./PublicationHomeLayout"; -import { PublicationAuthor } from "./PublicationAuthor"; +import { normalizePublicationRecord } from "src/utils/normalizeRecords"; +import { PublicationContent } from "./PublicationContent"; import { - normalizePublicationRecord, - normalizeDocumentRecord, -} from "src/utils/normalizeRecords"; -import { getFirstParagraph } from "src/utils/getFirstParagraph"; -import { FontLoader } from "components/FontLoader"; -import { - SubscribeButton, - SubscribeInput, -} from "components/Subscribe/SubscribeButton"; -import { dummy } from "./[rkey]/PostPubInfo"; -import { SubscribeWithBluesky } from "app/lish/Subscribe"; + PublicationThemeProvider, + PublicationBackgroundProvider, +} from "components/ThemeManager/PublicationThemeProvider"; export default async function Publication(props: { params: Promise<{ publication: string; did: string }>; }) { let params = await props.params; - let did = decodeURIComponent(params.did); + const did = decodeURIComponent(params.did); if (!did) return ; - let agent = new BskyAgent({ service: "https://public.api.bsky.app" }); - let publication_name = decodeURIComponent(params.publication); - let [{ data: publications }, { data: profile }] = await Promise.all([ + const agent = new BskyAgent({ service: "https://public.api.bsky.app" }); + const publication_name = decodeURIComponent(params.publication); + const [{ data: publications }, { data: profile }] = await Promise.all([ supabaseServerClient .from("publications") .select( @@ -58,164 +42,32 @@ .limit(1), agent.getProfile({ actor: did }), ]); - let publication = publications?.[0]; + const publication = publications?.[0]; const record = normalizePublicationRecord(publication?.record); - let showPageBackground = record?.theme?.showPageBackground; + const showPageBackground = record?.theme?.showPageBackground; if (!publication) return ; try { return ( - <> - - + - - -
- {record?.icon && ( -
- )} -

- {publication.name} -

-

- {record?.description}{" "} -

- {profile && ( - - )} -
- {/**/} - - -
-
-
- {publication.documents_in_publications - .filter((d) => !!d?.documents) - .sort((a, b) => { - const aRecord = normalizeDocumentRecord(a.documents?.data); - const bRecord = normalizeDocumentRecord(b.documents?.data); - const aDate = aRecord?.publishedAt - ? new Date(aRecord.publishedAt) - : new Date(0); - const bDate = bRecord?.publishedAt - ? new Date(bRecord.publishedAt) - : new Date(0); - return bDate.getTime() - aDate.getTime(); // Sort by most recent first - }) - .map((doc) => { - if (!doc.documents) return null; - const doc_record = normalizeDocumentRecord( - doc.documents.data, - ); - if (!doc_record) return null; - let uri = new AtUri(doc.documents.uri); - let quotes = - doc.documents.document_mentions_in_bsky[0].count || 0; - let comments = - record?.preferences?.showComments === false - ? 0 - : doc.documents.comments_on_documents[0].count || 0; - let recommends = - doc.documents.recommends_on_documents?.[0]?.count || 0; - let tags = doc_record.tags || []; - - const docUrl = getDocumentURL( - doc_record, - doc.documents.uri, - publication, - ); - return ( - -
- - {doc_record.title && ( -

- {doc_record.title} -

- )} -

- {doc_record.description || - getFirstParagraph(doc_record)} -

-
- -
-

- {doc_record.publishedAt && ( - - )}{" "} -

- - -
-
-
-
- ); - })} -
- - - - + + + ); } catch (e) { console.log(e); diff --git a/app/(home-pages)/p/[didOrHandle]/comments/CommentsContent.tsx b/app/(home-pages)/p/[didOrHandle]/comments/CommentsContent.tsx --- a/app/(home-pages)/p/[didOrHandle]/comments/CommentsContent.tsx +++ b/app/(home-pages)/p/[didOrHandle]/comments/CommentsContent.tsx @@ -1,5 +1,6 @@ "use client"; +import { EmptyState } from "components/EmptyState"; import { useEffect, useRef, useMemo } from "react"; import useSWRInfinite from "swr/infinite"; import { AppBskyActorProfile, AtUri } from "@atproto/api"; @@ -79,9 +80,7 @@ const allComments = data ? data.flatMap((page) => page.comments) : []; if (allComments.length === 0 && !isValidating) { - return ( -
No comments yet
- ); + return ; } return ( @@ -206,7 +205,7 @@
)}
             No subscriptions yet
- ); + return ; } return ( diff --git a/app/lish/[did]/[publication]/[rkey]/LinearDocumentPage.tsx b/app/lish/[did]/[publication]/[rkey]/LinearDocumentPage.tsx --- a/app/lish/[did]/[publication]/[rkey]/LinearDocumentPage.tsx +++ b/app/lish/[did]/[publication]/[rkey]/LinearDocumentPage.tsx @@ -38,8 +38,6 @@ did, profile, preferences, - pubRecord, - theme, prerenderedCodeBlocks, bskyPostData, pollData, diff --git a/app/lish/[did]/[publication]/[rkey]/PostContent.tsx b/app/lish/[did]/[publication]/[rkey]/PostContent.tsx --- a/app/lish/[did]/[publication]/[rkey]/PostContent.tsx +++ b/app/lish/[did]/[publication]/[rkey]/PostContent.tsx @@ -69,7 +69,7 @@ return (
{blocks.map((b, index) => { return ( @@ -162,10 +162,12 @@ ) alignment = "text-center justify-center"; + let isHeading = PubLeafletBlocksHeader.isMain(b.block); + let className = ` postBlockWrapper min-h-7 - mt-1 mb-2 + ${isFirst ? "mt-0" : "mt-1"} ${isLast ? "mb-3 sm:mb-4" : isHeading ? "mb-0!" : "mb-2"} ${isList && "isListItem mb-0! "} ${alignment} `; @@ -390,7 +392,7 @@ return ( // all this margin stuff is a highly unfortunate hack so that the border-l on blockquote is the height of just the text rather than the height of the block, which includes padding.
- Nothing's been published yet... -
- ); + return ; // Sort by publishedAt (most recent first) const sortedDocuments = [...documents].sort((a, b) => { @@ -89,7 +86,9 @@

- {doc.record.title} + {doc.record.title === "" || doc.record.title === undefined + ? "Untitled" + : doc.record.title}

diff --git a/app/lish/[did]/[publication]/theme-settings/PostPreview.tsx b/app/lish/[did]/[publication]/theme-settings/PostPreview.tsx new file mode 100644 --- /dev/null +++ b/app/lish/[did]/[publication]/theme-settings/PostPreview.tsx @@ -0,0 +1,150 @@ +"use client"; + +import { LinearDocumentPage } from "app/lish/[did]/[publication]/[rkey]/LinearDocumentPage"; +import { LeafletContentProvider } from "contexts/LeafletContentContext"; +import { + DocumentProvider, + type PublicationContext, +} from "contexts/DocumentContext"; +import { useIdentityData } from "components/IdentityProvider"; +import type { ProfileViewDetailed } from "@atproto/api/dist/client/types/app/bsky/actor/defs"; +import type { PubLeafletPagesLinearDocument } from "lexicons/api"; +import type { PostPageData } from "app/lish/[did]/[publication]/[rkey]/getPostPageData"; +import type { DocumentContextValue } from "contexts/DocumentContext"; +import { + usePublicationData, + useNormalizedPublicationRecord, +} from "../dashboard/PublicationSWRProvider"; +import { fakeBlocks, fakePage } from "./postPreviewFakeBlocks"; + +const FAKE_DID = "did:plc:fake-preview-user"; +const FAKE_DOC_URI = + "at://did:plc:fake-preview-user/site.standard.document/preview"; + +const fakeNormalizedDocument = { + $type: "site.standard.document" as const, + title: "Building your Dream Theme!", + description: "A short description of this preview post.", + publishedAt: new Date().toISOString(), + site: "at://did:plc:fake-preview-user/site.standard.publication/preview", + tags: ["preview", "theme"], +}; + +function makeFakeDocument( + publication?: { + uri: string; + name: string; + identity_did: string; + record: unknown; + } | null, +): NonNullable { + return { + data: {}, + uri: FAKE_DOC_URI, + normalizedDocument: fakeNormalizedDocument, + normalizedPublication: null, + quotesAndMentions: [], + theme: null, + prevNext: undefined, + publication: publication || null, + comments: [], + comments_on_documents: [], + mentions: [], + document_mentions_in_bsky: [], + leaflets_in_publications: [], + leafletId: null, + recommendsCount: 0, + documents_in_publications: publication + ? [{ publications: publication }] + : [], + recommends_on_documents: [], + } as unknown as NonNullable; +} + +export function PostPreview(props: { + showPageBackground: boolean; + pageWidth: number; +}) { + let { identity } = useIdentityData(); + let { data } = usePublicationData(); + let { publication } = data || {}; + let record = useNormalizedPublicationRecord(); + let preferences = record?.preferences; + let profileRecord = identity?.bsky_profiles + ?.record as unknown as ProfileViewDetailed; + + let profile = profileRecord ?? { + did: FAKE_DID, + handle: "preview.bsky.social", + displayName: "Preview Author", + }; + + let pubInfo: PublicationContext = publication + ? { + uri: publication.uri, + name: publication.name, + identity_did: publication.identity_did, + record: publication.record as NonNullable["record"], + publication_subscriptions: ( + publication.publication_subscriptions || [] + ).map((s) => ({ + created_at: s.created_at, + identity: s.identity, + publication: s.publication, + record: s.record, + uri: s.uri, + })), + } + : null; + + let fakeDocument = makeFakeDocument(pubInfo); + + let fakeDocumentContextValue: DocumentContextValue = { + uri: FAKE_DOC_URI, + normalizedDocument: fakeNormalizedDocument, + normalizedPublication: null, + theme: undefined, + prevNext: undefined, + quotesAndMentions: [], + publication: pubInfo, + comments: [], + mentions: [], + leafletId: null, + recommendsCount: 0, + }; + + return ( + + +
+ +
+
+
+ ); +} diff --git a/app/lish/[did]/[publication]/theme-settings/PubPreview.tsx b/app/lish/[did]/[publication]/theme-settings/PubPreview.tsx new file mode 100644 --- /dev/null +++ b/app/lish/[did]/[publication]/theme-settings/PubPreview.tsx @@ -0,0 +1,99 @@ +"use client"; + +import { + usePublicationData, + useNormalizedPublicationRecord, +} from "app/lish/[did]/[publication]/dashboard/PublicationSWRProvider"; +import { useIdentityData } from "components/IdentityProvider"; +import { ProfileViewDetailed } from "@atproto/api/dist/client/types/app/bsky/actor/defs"; +import { PublicationContent } from "../PublicationContent"; +import { LocalizedDate } from "../LocalizedDate"; + +export function PubPreview(props: { + showPageBackground: boolean; + pageWidth: number; +}) { + let { data } = usePublicationData(); + let { publication } = data || {}; + let { identity } = useIdentityData(); + let record = useNormalizedPublicationRecord(); + + let profileRecord = identity?.bsky_profiles + ?.record as unknown as ProfileViewDetailed; + + let did = publication?.identity_did || ""; + + let profile = identity?.bsky_profiles + ? { + did: identity.bsky_profiles.did, + displayName: profileRecord?.displayName, + handle: identity.bsky_profiles.handle || "", + } + : undefined; + + if (!publication) return null; + + const hasPosts = publication.documents_in_publications.some( + (d) => !!d?.documents, + ); + const today = new Date(); + const yesterday = new Date(today); + yesterday.setDate(today.getDate() - 1); + const dayBefore = new Date(today); + dayBefore.setDate(today.getDate() - 2); + + const dateOptions: Intl.DateTimeFormatOptions = { + year: "numeric", + month: "long", + day: "2-digit", + }; + + const fakePosts = !hasPosts + ? undefined + : [ + { + title: "Your Personal Antheme", + description: + "Welcome to the Publication Theme Setter. This is how posts will appear in your publication", + date: ( + + ), + }, + { + title: "The Theme of the Crop", + description: + "This is the place to make your publication look and feel like home. It looks great!", + date: ( + + ), + }, + { + title: "Reams and Reams of Colorful Themes!", + description: + "So happy to have you. There's so much cool stuff happening here, including this publication :)", + date: ( + + ), + }, + ]; + + return ( + + ); +} diff --git a/app/lish/[did]/[publication]/theme-settings/ThemeSettingsContent.tsx b/app/lish/[did]/[publication]/theme-settings/ThemeSettingsContent.tsx new file mode 100644 --- /dev/null +++ b/app/lish/[did]/[publication]/theme-settings/ThemeSettingsContent.tsx @@ -0,0 +1,277 @@ +"use client"; + +import { useRef, useState } from "react"; +import { useParams, useRouter } from "next/navigation"; +import { + BaseThemeProvider, + CardBorderHiddenContext, +} from "components/ThemeManager/ThemeProvider"; +import { ButtonPrimary, ButtonSecondary } from "components/Buttons"; +import { DotLoader } from "components/utils/DotLoader"; +import { ToggleGroup } from "components/ToggleGroup"; +import { PaintSmall } from "components/Icons/PaintSmall"; +import { Popover } from "components/Popover"; +import { + usePubThemeEditorState, + PubThemePickerPanel, +} from "components/ThemeManager/PubThemeSetter"; +import { PubPreview } from "./PubPreview"; +import { PostPreview } from "./PostPreview"; +import { PublicationBackgroundProvider } from "components/ThemeManager/PublicationThemeProvider"; +import { + usePublicationData, + useNormalizedPublicationRecord, +} from "../dashboard/PublicationSWRProvider"; +import { Separator } from "components/Layout"; +import { GoToArrow } from "components/Icons/GoToArrow"; +import Link from "next/link"; + +export function ThemeSettingsContent() { + let toolbarRef = useRef(null); + let [previewMode, setPreviewMode] = useState<"post" | "pub">("post"); + let params = useParams<{ did: string; publication: string }>(); + let { data } = usePublicationData(); + let { publication } = data || {}; + let record = useNormalizedPublicationRecord(); + let state = usePubThemeEditorState(); + let { + localPubTheme, + headingFont, + bodyFont, + image, + pageWidth, + pubBGImage, + leafletBGRepeat, + showPageBackground, + changes, + } = state; + + let settingsHref = `/lish/${params.did}/${params.publication}/dashboard?tab=Settings`; + + let hasUnsavedChanges = + changes || + headingFont !== record?.theme?.headingFont || + bodyFont !== record?.theme?.bodyFont || + pageWidth !== (record?.theme?.pageWidth || 624) || + showPageBackground !== !!record?.theme?.showPageBackground; + + return ( + + +
+ {/* Theme Setter Panel */} +
+
+ +
+ + + +
+
+
+ + {/* Full-page Preview */} + +
{ + e.preventDefault(); + e.stopPropagation(); + }} + > + {previewMode === "pub" ? ( + + ) : ( + + )} +
+
+
+
+
+ ); +} + +const BackToPubButton = (props: { + hasUnsavedChanges: boolean; + settingsHref: string; + localPubTheme: ReturnType["localPubTheme"]; + headingFont: ReturnType["headingFont"]; + bodyFont: ReturnType["bodyFont"]; + image: ReturnType["image"]; + pageWidth: ReturnType["pageWidth"]; +}) => { + let router = useRouter(); + let [open, setOpen] = useState(false); + if (props.hasUnsavedChanges) + return ( + + + Back To Settings +
+ } + > + +
+

Discard unsaved changes?

+

+ You have unsaved changes to your theme. Leaving the page will lose + your edits! +

+
+ router.push(props.settingsHref)} + > + Discard and Leave + + + +
+
+
+ + ); + else + return ( + +
+ + Back To Settings +
+ + ); +}; + +const PubThemePopover = ({ + state, + toolbarRef, +}: { + state: ReturnType; + toolbarRef: React.RefObject; +}) => { + let { + localPubTheme, + headingFont, + bodyFont, + image, + pageWidth, + submitTheme, + toaster, + } = state; + let [loading, setLoading] = useState(false); + + return ( + + + + } + asChild + > + +
+ {/* Toggle + Save Header */} + +
+ { + let result = await submitTheme(setLoading); + if (result?.success) { + toaster({ + content: "Theme saved!", + type: "success", + }); + } + }} + > + {loading ? : "Save Changes"} + +
+ +
+ +
+
+
+
+ ); +}; diff --git a/app/lish/[did]/[publication]/theme-settings/page.tsx b/app/lish/[did]/[publication]/theme-settings/page.tsx new file mode 100644 --- /dev/null +++ b/app/lish/[did]/[publication]/theme-settings/page.tsx @@ -0,0 +1,62 @@ +import { supabaseServerClient } from "supabase/serverClient"; +import { getIdentityData } from "actions/getIdentityData"; +import { get_publication_data } from "app/api/rpc/[command]/get_publication_data"; +import { PublicationSWRDataProvider } from "../dashboard/PublicationSWRProvider"; +import { AtUri } from "@atproto/syntax"; +import { NotFoundLayout } from "components/PageLayouts/NotFoundLayout"; +import { normalizePublicationRecord } from "src/utils/normalizeRecords"; +import { ThemeSettingsContent } from "./ThemeSettingsContent"; + +export default async function ThemeSettingsPage(props: { + params: Promise<{ publication: string; did: string }>; +}) { + let params = await props.params; + let identity = await getIdentityData(); + if (!identity || !identity.atp_did) + return ( + +

Looks like you're not logged in.

+

+ If the issue persists please{" "} + send us a note. +

+
+ ); + let did = decodeURIComponent(params.did); + if (!did) return ; + let { result: publication_data } = await get_publication_data.handler( + { + did, + publication_name: decodeURIComponent(params.publication), + }, + { supabase: supabaseServerClient }, + ); + let { publication } = publication_data; + const record = normalizePublicationRecord(publication?.record); + + if (!publication || identity.atp_did !== publication.identity_did || !record) + return ; + let uri = new AtUri(publication.uri); + + return ( + + + + ); +} + +const ThemeNotFound = () => { + return ( + +

Sorry, we can't find this publication!

+

+ This may be a glitch on our end. If the issue persists please{" "} + send us a note. +

+
+ ); +}; diff --git a/app/lish/[did]/[publication]/theme-settings/postPreviewFakeBlocks.ts b/app/lish/[did]/[publication]/theme-settings/postPreviewFakeBlocks.ts new file mode 100644 --- /dev/null +++ b/app/lish/[did]/[publication]/theme-settings/postPreviewFakeBlocks.ts @@ -0,0 +1,353 @@ +import type { PubLeafletPagesLinearDocument } from "lexicons/api"; + +export const fakeBlocks: PubLeafletPagesLinearDocument.Block[] = [ + { + $type: "pub.leaflet.pages.linearDocument#block", + block: { + $type: "pub.leaflet.blocks.text", + plaintext: "Welcome to Leaflet, intrepid writer!", + }, + }, + { + $type: "pub.leaflet.pages.linearDocument#block", + block: { + $type: "pub.leaflet.blocks.text", + plaintext: + "This is a place to write, blog, journal, and above all, express oneself. As such we take theming very seriously. Read on to discover how to make your wildest themes come true!", + }, + }, + { + $type: "pub.leaflet.pages.linearDocument#block", + block: { + $type: "pub.leaflet.blocks.text", + plaintext: 'And now, for a horizontal rule (also known as a "divider")!', + }, + }, + { + $type: "pub.leaflet.pages.linearDocument#block", + block: { + $type: "pub.leaflet.blocks.horizontalRule", + }, + }, + { + $type: "pub.leaflet.pages.linearDocument#block", + block: { + $type: "pub.leaflet.blocks.header", + level: 2, + plaintext: "TLDR", + }, + }, + { + $type: "pub.leaflet.pages.linearDocument#block", + block: { + $type: "pub.leaflet.blocks.text", + plaintext: + "We have some great presets available! Go ahead and apply one of those, and mess with the accent color to make it yours. Just keep in mind", + }, + }, + { + $type: "pub.leaflet.pages.linearDocument#block", + block: { + $type: "pub.leaflet.blocks.unorderedList", + children: [ + { + $type: "pub.leaflet.blocks.unorderedList#listItem", + content: { + $type: "pub.leaflet.blocks.text", + plaintext: "Go for a nice bright accent ", + }, + }, + { + $type: "pub.leaflet.blocks.unorderedList#listItem", + content: { + $type: "pub.leaflet.blocks.text", + plaintext: + "Make sure the text on accent is still legible. White or black if you're not sure!", + }, + }, + ], + }, + }, + { + $type: "pub.leaflet.pages.linearDocument#block", + block: { + $type: "pub.leaflet.blocks.horizontalRule", + }, + }, + { + $type: "pub.leaflet.pages.linearDocument#block", + block: { + $type: "pub.leaflet.blocks.header", + level: 2, + plaintext: "Your Text", + }, + }, + { + $type: "pub.leaflet.pages.linearDocument#block", + block: { + $type: "pub.leaflet.blocks.header", + level: 3, + plaintext: "Text", + }, + }, + { + $type: "pub.leaflet.pages.linearDocument#block", + block: { + $type: "pub.leaflet.blocks.text", + plaintext: "This is your default text color. ", + }, + }, + { + $type: "pub.leaflet.pages.linearDocument#block", + block: { + $type: "pub.leaflet.blocks.text", + plaintext: + "We also mix it with your background color to make lighter text or border colors!", + }, + }, + { + $type: "pub.leaflet.pages.linearDocument#block", + block: { + $type: "pub.leaflet.blocks.text", + plaintext: + "No need to think too hard, black or white is good! Just make sure it shows up strong against your background color. ", + }, + }, + { + $type: "pub.leaflet.pages.linearDocument#block", + block: { + $type: "pub.leaflet.blocks.header", + level: 3, + plaintext: "Accent Colors ", + }, + }, + { + $type: "pub.leaflet.pages.linearDocument#block", + block: { + $type: "pub.leaflet.blocks.text", + facets: [ + { + index: { + byteEnd: 32, + byteStart: 20, + }, + features: [ + { + uri: "https://leaflet.pub/about", + $type: "pub.leaflet.richtext.facet#link", + }, + ], + }, + ], + plaintext: + "We use this in your inline links, and in certain block types like...", + }, + }, + { + $type: "pub.leaflet.pages.linearDocument#block", + block: { + url: "https://www.leaflet.pub/about", + text: "Buttons!", + $type: "pub.leaflet.blocks.button", + }, + }, + { + $type: "pub.leaflet.pages.linearDocument#block", + block: { + $type: "pub.leaflet.blocks.text", + plaintext: "Pick something... ", + }, + }, + { + $type: "pub.leaflet.pages.linearDocument#block", + block: { + $type: "pub.leaflet.blocks.unorderedList", + children: [ + { + $type: "pub.leaflet.blocks.unorderedList#listItem", + content: { + $type: "pub.leaflet.blocks.text", + plaintext: "nice and bright", + }, + }, + { + $type: "pub.leaflet.blocks.unorderedList#listItem", + content: { + $type: "pub.leaflet.blocks.text", + plaintext: + "with legible text on accent (white usually works, but sometimes black shows up better) ", + }, + }, + { + $type: "pub.leaflet.blocks.unorderedList#listItem", + content: { + $type: "pub.leaflet.blocks.text", + plaintext: "that shows off your personality!", + }, + }, + ], + }, + }, + { + $type: "pub.leaflet.pages.linearDocument#block", + block: { + $type: "pub.leaflet.blocks.horizontalRule", + }, + }, + { + $type: "pub.leaflet.pages.linearDocument#block", + block: { + $type: "pub.leaflet.blocks.header", + level: 2, + plaintext: "Your Background", + }, + }, + { + $type: "pub.leaflet.pages.linearDocument#block", + block: { + $type: "pub.leaflet.blocks.header", + level: 3, + plaintext: "Background", + }, + }, + { + $type: "pub.leaflet.pages.linearDocument#block", + block: { + $type: "pub.leaflet.blocks.text", + plaintext: "This is your background color. It can also be an image!", + }, + }, + { + $type: "pub.leaflet.pages.linearDocument#block", + block: { + $type: "pub.leaflet.blocks.text", + plaintext: "If you decide to go for a solid background color...", + }, + }, + { + $type: "pub.leaflet.pages.linearDocument#block", + block: { + $type: "pub.leaflet.blocks.unorderedList", + children: [ + { + $type: "pub.leaflet.blocks.unorderedList#listItem", + content: { + $type: "pub.leaflet.blocks.text", + plaintext: "pick one that isn't too vibrant", + }, + }, + { + $type: "pub.leaflet.blocks.unorderedList#listItem", + content: { + $type: "pub.leaflet.blocks.text", + plaintext: "go either dark or light, not in the middle", + }, + }, + { + $type: "pub.leaflet.blocks.unorderedList#listItem", + content: { + $type: "pub.leaflet.blocks.text", + plaintext: + "try a very dark or very light version of your accent color", + }, + }, + ], + }, + }, + { + $type: "pub.leaflet.pages.linearDocument#block", + block: { + $type: "pub.leaflet.blocks.text", + plaintext: + "If you go for an image, it's easy to overwhelm a reader with too much, so pick one that's... ", + }, + }, + { + $type: "pub.leaflet.pages.linearDocument#block", + block: { + $type: "pub.leaflet.blocks.unorderedList", + children: [ + { + $type: "pub.leaflet.blocks.unorderedList#listItem", + content: { + $type: "pub.leaflet.blocks.text", + plaintext: + 'not distracting. ie, not colorful or busy. You want something that could be described as "kinda boring". It\'ll still give a lot of personality once you apply it!', + }, + }, + { + $type: "pub.leaflet.blocks.unorderedList#listItem", + content: { + $type: "pub.leaflet.blocks.text", + plaintext: "Gradients are a classy classic", + }, + }, + ], + }, + }, + { + $type: "pub.leaflet.pages.linearDocument#block", + block: { + $type: "pub.leaflet.blocks.header", + level: 3, + plaintext: "Page or Container", + }, + }, + { + $type: "pub.leaflet.pages.linearDocument#block", + block: { + $type: "pub.leaflet.blocks.text", + plaintext: "You can choose to have a page background or not. ", + }, + }, + { + $type: "pub.leaflet.pages.linearDocument#block", + block: { + $type: "pub.leaflet.blocks.text", + plaintext: + "Page background puts a box around your writing. It's especially useful if you have a background image making your text harder to read. It's also another place to inject a color to give your writing some zuzsh.", + }, + }, + { + $type: "pub.leaflet.pages.linearDocument#block", + block: { + $type: "pub.leaflet.blocks.text", + plaintext: "No page background looks clean and minimal. It's sup to you!", + }, + }, + { + $type: "pub.leaflet.pages.linearDocument#block", + block: { + $type: "pub.leaflet.blocks.horizontalRule", + }, + }, + { + $type: "pub.leaflet.pages.linearDocument#block", + block: { + $type: "pub.leaflet.blocks.header", + level: 2, + plaintext: "Stuck?", + }, + }, + { + $type: "pub.leaflet.pages.linearDocument#block", + block: { + $type: "pub.leaflet.blocks.text", + plaintext: + "If you're really stuck, try one of our preset themes! Change just the accent color to give it a more personal flair. ", + }, + }, + { + $type: "pub.leaflet.pages.linearDocument#block", + block: { + $type: "pub.leaflet.blocks.text", + plaintext: "Good luck!", + }, + }, +]; + +export const fakePage: PubLeafletPagesLinearDocument.Main = { + $type: "pub.leaflet.pages.linearDocument", + id: "preview-page", + blocks: fakeBlocks, +}; diff --git a/app/lish/[did]/[publication]/[rkey]/Interactions/Interactions.tsx b/app/lish/[did]/[publication]/[rkey]/Interactions/Interactions.tsx --- a/app/lish/[did]/[publication]/[rkey]/Interactions/Interactions.tsx +++ b/app/lish/[did]/[publication]/[rkey]/Interactions/Interactions.tsx @@ -134,7 +134,9 @@ const tagCount = tags?.length || 0; let interactionsAvailable = - props.showComments || props.showMentions || props.showRecommends; + props.showComments || + (props.showMentions && props.quotesCount > 0) || + props.showRecommends; return (
{props.postTitle && ( -

+

{props.postTitle} -

+ )} {props.postDescription ? (
diff --git a/app/lish/[did]/[publication]/dashboard/settings/GeneralSettings.tsx b/app/lish/[did]/[publication]/dashboard/settings/GeneralSettings.tsx --- a/app/lish/[did]/[publication]/dashboard/settings/GeneralSettings.tsx +++ b/app/lish/[did]/[publication]/dashboard/settings/GeneralSettings.tsx @@ -1,6 +1,8 @@ import { useRef } from "react"; import { Input } from "components/Input"; import { AddTiny } from "components/Icons/AddTiny"; +import { DashboardContainer } from "./SettingsContent"; +import { EditTiny } from "components/Icons/EditTiny"; export function GeneralSettings(props: { nameValue: string; @@ -14,26 +16,35 @@ let fileInputRef = useRef(null); return ( -
-

General Settings

+ -
-

+

+

Logo (optional)

-
fileInputRef.current?.click()} - > - {props.iconPreview ? ( - Logo preview - ) : ( - +
+ {props.iconPreview && ( +
fileInputRef.current?.click()} + > + +
)} +
fileInputRef.current?.click()} + > + {props.iconPreview ? ( + Logo preview + ) : ( + + )} +
+ ); } diff --git a/app/lish/[did]/[publication]/dashboard/settings/ManageProSubscription.tsx b/app/lish/[did]/[publication]/dashboard/settings/ManageProSubscription.tsx --- a/app/lish/[did]/[publication]/dashboard/settings/ManageProSubscription.tsx +++ b/app/lish/[did]/[publication]/dashboard/settings/ManageProSubscription.tsx @@ -7,7 +7,7 @@ import { GoBackSmall } from "components/Icons/GoBackSmall"; import { PRODUCT_DEFINITION } from "stripe/products"; -export const ManageProSubscription = (props: {}) => { +export const ManageProSubscription = (props: { compact?: boolean }) => { const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const { identity } = useIdentityData(); @@ -29,27 +29,49 @@ setLoading(false); } } - - return ( -
-
- Manage Subscription -
-
-
- You have a
- {PRODUCT_DEFINITION.name} subscription -
- {PRODUCT_DEFINITION.name} -
+ if (props.compact) { + return ( +
+ + {loading ? : "Manage Billing"} + +
{subscription?.status === "canceled" ? "Your subscription has ended" : subscription?.status === "canceling" ? `Access until ${renewalDate}` - : `Renews on ${renewalDate}`} + : `Renews ${renewalDate}`} +
+ + {error &&
{error}
} +
+ ); + } + return ( +
+
+
+ You are subscribed to +
+
+ {PRODUCT_DEFINITION.name} +
+
+ {subscription?.status === "canceled" + ? "Your subscription has ended" + : subscription?.status === "canceling" + ? `Access until ${renewalDate}` + : `Renews ${renewalDate}`} +
void; - loading: boolean; - setLoading: (l: boolean) => void; -}) => { - let { data } = usePublicationData(); - - let { publication: pubData } = data || {}; - const record = useNormalizedPublicationRecord(); - - let [showComments, setShowComments] = useState( - record?.preferences?.showComments === undefined - ? true - : record.preferences.showComments, - ); - let [showMentions, setShowMentions] = useState( - record?.preferences?.showMentions === undefined - ? true - : record.preferences.showMentions, - ); - let [showRecommends, setShowRecommends] = useState( - record?.preferences?.showRecommends === undefined - ? true - : record.preferences.showRecommends, - ); - let [showPrevNext, setShowPrevNext] = useState( - record?.preferences?.showPrevNext === undefined - ? true - : record.preferences.showPrevNext, - ); - - let toast = useToaster(); - return ( -
{ - if (!pubData || !record) return; - e.preventDefault(); - props.setLoading(true); - let data = await updatePublication({ - name: record.name, - uri: pubData.uri, - preferences: { - showInDiscover: - record?.preferences?.showInDiscover === undefined - ? true - : record.preferences.showInDiscover, - showComments: showComments, - showMentions: showMentions, - showPrevNext: showPrevNext, - showRecommends: showRecommends, - }, - }); - toast({ type: "success", content: Posts Updated! }); - props.setLoading(false); - mutate("publication-data"); - }} - className="text-primary flex flex-col" - > - - Post Options - -

Layout

- { - setShowPrevNext(!showPrevNext); - }} - > -
Show Prev/Next Buttons
-
-
-

Interactions

-
- { - setShowComments(!showComments); - }} - > -
Show Comments
-
- - { - setShowMentions(!showMentions); - }} - > -
-
Show Mentions
-
- Display a list of Bluesky mentions about your post -
-
-
- - { - setShowRecommends(!showRecommends); - }} - > -
-
Show Recommends
-
- Allow readers to recommend/like your post -
-
-
-
-
- ); -}; diff --git a/app/lish/[did]/[publication]/dashboard/settings/PostSettings.tsx b/app/lish/[did]/[publication]/dashboard/settings/PostSettings.tsx --- a/app/lish/[did]/[publication]/dashboard/settings/PostSettings.tsx +++ b/app/lish/[did]/[publication]/dashboard/settings/PostSettings.tsx @@ -1,4 +1,5 @@ import { Toggle } from "components/Toggle"; +import { DashboardContainer } from "./SettingsContent"; export function PostSettings(props: { showComments: boolean; @@ -14,14 +15,13 @@ }) { return ( <> -
-

Discovery

+ props.setShowInDiscover(!props.showInDiscover)} >
-
Make Public
+
Make Public
Your posts will appear in{" "} @@ -31,52 +31,53 @@
-
-
+ -

Post Layout

- props.setShowPrevNext(!props.showPrevNext)} - > -
Show Prev/Next Buttons on Post
-
- -
- -

Post Interactions

-
+ props.setShowComments(!props.showComments)} + toggle={props.showPrevNext} + onToggle={() => props.setShowPrevNext(!props.showPrevNext)} > -
Show Comments
-
- - props.setShowMentions(!props.showMentions)} - > -
-
Show Mentions
-
- Display a list of Bluesky mentions about your post -
+
+ Show Prev/Next Buttons on Post
+ - props.setShowRecommends(!props.showRecommends)} - > -
-
Show Recommends
-
- Allow readers to recommend/like your post + +
+ props.setShowComments(!props.showComments)} + > +
Show Comments
+
+ + props.setShowMentions(!props.showMentions)} + > +
+
Show Mentions
+
+ Display a list of Bluesky mentions about your post +
-
- -
+ + + props.setShowRecommends(!props.showRecommends)} + > +
+
Show Recommends
+
+ Allow readers to recommend/like your post +
+
+
+
+ ); } diff --git a/app/lish/[did]/[publication]/dashboard/settings/PubDomainSettings.tsx b/app/lish/[did]/[publication]/dashboard/settings/PubDomainSettings.tsx new file mode 100644 --- /dev/null +++ b/app/lish/[did]/[publication]/dashboard/settings/PubDomainSettings.tsx @@ -0,0 +1,374 @@ +"use client"; + +import { useState } from "react"; +import { mutate } from "swr"; +import { useDomainStatus } from "components/Domains/useDomainStatus"; +import { + getDomainAssignment, + describeAssignment, +} from "components/Domains/domainAssignment"; +import { + useIdentityData, + mutateIdentityData, +} from "components/IdentityProvider"; +import { ButtonPrimary } from "components/Buttons"; +import { + usePublicationData, + useNormalizedPublicationRecord, +} from "../PublicationSWRProvider"; +import { updatePublicationBasePath } from "app/lish/createPub/updatePublication"; +import { + assignDomainToPublication, + removeDomainAssignment, +} from "actions/domains"; +import { PinTiny } from "components/Icons/PinTiny"; +import { LoadingTiny } from "components/Icons/LoadingTiny"; +import { UnlinkTiny } from "components/Icons/UnlinkTiny"; +import { DotLoader } from "components/utils/DotLoader"; +import { useToaster } from "components/Toast"; +import type { CustomDomain } from "components/Domains/DomainList"; + +export const PubDomainSettings = () => { + let { data, mutate: mutatePubData } = usePublicationData(); + let { publication: pubData } = data || {}; + let record = useNormalizedPublicationRecord(); + let { identity, mutate: mutateIdentity } = useIdentityData(); + let toaster = useToaster(); + let basePath = record?.url?.replace(/^https?:\/\//, "") || ""; + + let pubDomains = pubData?.publication_domains || []; + let pubDomainNames = new Set(pubDomains.map((d) => d.domain)); + + if (!pubData) return null; + + return ( + <> +
+

This Publication's Domains

+
DEFAULT
+ {pubDomains + .filter((d) => d.domain === basePath) + .map((d) => ( + + ))} + {pubDomains.filter((d) => d.domain !== basePath).length !== 0 && ( + <> +
ALTERNATES
+ {pubDomains + .filter((d) => d.domain !== basePath) + .map((d) => ( + + ))} + + )} + +
+

Available Domains

+ {(() => { + let availableDomains = (identity?.custom_domains || []) + .filter((d) => !pubDomainNames.has(d.domain)) + .filter( + (d) => + d.publication_domains.length === 0 && + d.custom_domain_routes.length === 0, + ); + return availableDomains.length > 0 ? ( + <> + {availableDomains.map((d) => ( + { + mutateIdentity(); + mutate("publication-data"); + }} + /> + ))} +
+ Add new domains from your profile settings! +
+ + ) : ( +
+ No available domains! +
+ Add new domains from your profile settings! +
+ ); + })()} +
+ + ); +}; + +function PubDomainRow(props: { + domain: string; + publication_uri: string; + basePath: string; + mutatePubData: ReturnType["mutate"]; + mutateIdentity: ReturnType["mutate"]; + toaster: ReturnType; +}) { + let { pending } = useDomainStatus(props.domain); + let [loading, setLoading] = useState(false); + let [unlinking, setUnlinking] = useState(false); + let toaster = props.toaster; + + return ( +
+ {props.domain} +
+ {pending ? ( +
+

+ pending +

+ +
+ ) : ( + <> + {props.basePath !== props.domain && ( +
+ {!props.domain.endsWith(".leaflet.pub") && ( + + )} + +
+ )} + + )} +
+
+ ); +} + +function UnassignedDomainRow(props: { + domainData: CustomDomain; + publication_uri: string; + mutatePubData: ReturnType["mutate"]; + onAssigned: () => void; +}) { + let { pending } = useDomainStatus(props.domainData.domain); + let { mutate: mutateIdentity } = useIdentityData(); + let assignment = getDomainAssignment(props.domainData); + let [confirming, setConfirming] = useState(false); + let [loading, setLoading] = useState(false); + + async function doAssign() { + setLoading(true); + try { + mutateIdentityData(mutateIdentity, (draft) => { + let domain = draft.custom_domains.find( + (d) => d.domain === props.domainData.domain, + ); + if (domain) { + domain.custom_domain_routes = []; + let pub = draft.publications?.find( + (p) => p.uri === props.publication_uri, + ); + domain.publication_domains = [ + { + publication: props.publication_uri, + domain: props.domainData.domain, + identity: "", + created_at: new Date().toISOString(), + publications: pub ? { name: pub.name } : null, + }, + ]; + } + }); + props.mutatePubData( + (current) => { + if (!current) return current; + let pub = current.publication; + if (!pub) return current; + return { + ...current, + publication: { + ...pub, + publication_domains: [ + ...(pub.publication_domains || []), + { + publication: props.publication_uri, + domain: props.domainData.domain, + created_at: new Date().toISOString(), + identity: "", + }, + ], + }, + }; + }, + { revalidate: false }, + ); + setConfirming(false); + props.onAssigned(); + await assignDomainToPublication({ + domain: props.domainData.domain, + publication_uri: props.publication_uri, + }); + } finally { + setLoading(false); + } + } + + return ( +
+
+ {props.domainData.domain} + {pending ? ( +
unverified
+ ) : confirming ? null : ( + + )} +
+ {confirming && ( +
+

+ This domain is currently assigned to{" "} + {describeAssignment(assignment)}. Assigning it here will remove that + assignment. +

+
+ + + {loading ? : "Reassign"} + +
+
+ )} +
+ ); +} diff --git a/app/lish/[did]/[publication]/dashboard/settings/PublicationSettings.tsx b/app/lish/[did]/[publication]/dashboard/settings/PublicationSettings.tsx deleted file mode 100644 --- a/app/lish/[did]/[publication]/dashboard/settings/PublicationSettings.tsx +++ /dev/null @@ -1,166 +0,0 @@ -"use client"; - -import { ActionButton } from "components/ActionBar/ActionButton"; -import { Popover } from "components/Popover"; -import { SettingsSmall } from "components/Icons/SettingsSmall"; -import { EditPubForm } from "app/lish/createPub/UpdatePubForm"; -import { PubThemeSetter } from "components/ThemeManager/PubThemeSetter"; -import { useIsMobile } from "src/hooks/isMobile"; -import { useState } from "react"; -import { GoBackSmall } from "components/Icons/GoBackSmall"; -import { theme } from "tailwind.config"; -import { ButtonPrimary } from "components/Buttons"; -import { DotLoader } from "components/utils/DotLoader"; -import { ArrowRightTiny } from "components/Icons/ArrowRightTiny"; -import { PostOptions } from "./PostOptions"; -import { PublicationDomains } from "components/Domains/PublicationDomains"; -import { usePublicationData } from "../PublicationSWRProvider"; - -type menuState = "menu" | "pub-settings" | "theme" | "post-settings" | "domains"; - -export function PublicationSettingsButton(props: { publication: string }) { - let isMobile = useIsMobile(); - let [state, setState] = useState("menu"); - let [loading, setLoading] = useState(false); - - return ( - setState("menu")} - side={isMobile ? "top" : "right"} - align={isMobile ? "center" : "start"} - className={`flex flex-col max-w-xs w-[1000px] ${state === "theme" && "bg-white!"} pb-0!`} - arrowFill={theme.colors["border-light"]} - trigger={ - - label="Settings" - smallOnMobile - /> - } - > - {state === "pub-settings" ? ( - setState("menu")} - loading={loading} - setLoadingAction={setLoading} - /> - ) : state === "theme" ? ( - setState("menu")} - loading={loading} - setLoading={setLoading} - /> - ) : state === "post-settings" ? ( - setState("menu")} - loading={loading} - setLoading={setLoading} - /> - ) : state === "domains" ? ( - setState("menu")} /> - ) : ( - - )} -
- - ); -} - -const PubSettingsMenu = (props: { - state: menuState; - setState: (s: menuState) => void; - loading: boolean; - setLoading: (l: boolean) => void; -}) => { - let menuItemClassName = - "menuItem -mx-[8px] text-left flex items-center justify-between hover:no-underline!"; - - return ( -
- Settings - - - - -
- ); -}; - -function PublicationDomainsView(props: { backToMenu: () => void }) { - let { data } = usePublicationData(); - let { publication: pubData } = data || {}; - if (!pubData) return null; - return ( - - ); -} - -export const PubSettingsHeader = (props: { - backToMenuAction?: () => void; - loading?: boolean; - setLoadingAction?: (l: boolean) => void; - children: React.ReactNode; -}) => { - return ( -
- {props.children} - {props.backToMenuAction && ( -
- - {props.setLoadingAction && ( - - {props.loading ? : "Update"} - - )} -
- )} -
- ); -}; diff --git a/app/lish/[did]/[publication]/dashboard/settings/SettingsContent.tsx b/app/lish/[did]/[publication]/dashboard/settings/SettingsContent.tsx --- a/app/lish/[did]/[publication]/dashboard/settings/SettingsContent.tsx +++ b/app/lish/[did]/[publication]/dashboard/settings/SettingsContent.tsx @@ -1,7 +1,7 @@ "use client"; -import { useState, useEffect } from "react"; -import { ButtonPrimary } from "components/Buttons"; +import { useState, useEffect, useMemo } from "react"; +import { ButtonPrimary, ButtonSecondary } from "components/Buttons"; import { DotLoader } from "components/utils/DotLoader"; import { useToaster } from "components/Toast"; import { mutate } from "swr"; @@ -10,42 +10,34 @@ useNormalizedPublicationRecord, } from "../PublicationSWRProvider"; import { updatePublication } from "app/lish/createPub/updatePublication"; -import { DomainsInline } from "./DomainsInline"; -import { PubThemeSetter } from "components/ThemeManager/PubThemeSetter"; +import { PubDomainSettings } from "./PubDomainSettings"; import { GeneralSettings } from "./GeneralSettings"; import { PostSettings } from "./PostSettings"; import { ThemeSettings } from "./ThemeSettings"; import { useCardBorderHidden } from "components/Pages/useCardBorderHidden"; +import { ManageProSubscription } from "./ManageProSubscription"; +import { useIsPro, useCanSeePro } from "src/hooks/useEntitlement"; +import { InlineUpgradeToPro, UpgradeToProButton } from "../../UpgradeModal"; +import { Modal } from "components/Modal"; +import { Input } from "components/Input"; +import { deletePublication } from "./deletePublication"; +import { useRouter } from "next/navigation"; +import { + isOAuthSessionError, + OAuthErrorMessage, +} from "components/OAuthError"; type SettingsView = "all" | "theme"; export function SettingsContent(props: { showPageBackground: boolean }) { - let [view, setView] = useState("all"); - let [themeLoading, setThemeLoading] = useState(false); - - if (view === "theme") { - return ( -
- setView("all")} - loading={themeLoading} - setLoading={setThemeLoading} - /> -
- ); - } - - return setView("theme")} />; -} - -function SettingsForm(props: { onOpenTheme: () => void }) { let { data } = usePublicationData(); let { publication: pubData } = data || {}; + let isPro = useIsPro(); + let canSeePro = useCanSeePro(); let record = useNormalizedPublicationRecord(); let [loading, setLoading] = useState(false); let toast = useToaster(); - // --- General Settings state --- let [nameValue, setNameValue] = useState(record?.name || ""); let [descriptionValue, setDescriptionValue] = useState( record?.description || "", @@ -92,9 +84,58 @@ ); }, [pubData, record]); + let hasUnsavedChanges = useMemo(() => { + if (!record) return false; + if (nameValue !== (record.name || "")) return true; + if (descriptionValue !== (record.description || "")) return true; + if (iconFile !== null) return true; + + let savedShowInDiscover = + record.preferences?.showInDiscover === undefined + ? true + : record.preferences.showInDiscover; + if (showInDiscover !== savedShowInDiscover) return true; + + let savedShowComments = + record.preferences?.showComments === undefined + ? true + : record.preferences.showComments; + if (showComments !== savedShowComments) return true; + + let savedShowMentions = + record.preferences?.showMentions === undefined + ? true + : record.preferences.showMentions; + if (showMentions !== savedShowMentions) return true; + + let savedShowRecommends = + record.preferences?.showRecommends === undefined + ? true + : record.preferences.showRecommends; + if (showRecommends !== savedShowRecommends) return true; + + let savedShowPrevNext = + record.preferences?.showPrevNext === undefined + ? true + : record.preferences.showPrevNext; + if (showPrevNext !== savedShowPrevNext) return true; + + return false; + }, [ + record, + nameValue, + descriptionValue, + iconFile, + showInDiscover, + showComments, + showMentions, + showRecommends, + showPrevNext, + ]); + return (
{ e.preventDefault(); if (!pubData) return; @@ -117,7 +158,7 @@ mutate("publication-data"); }} > -
+
{/* ── General Settings ── */} -
+ + + {/* ── Post Settings ── */} -
+ +
+ +
+
- {/* ── Theme ── */} - + + {canSeePro && !isPro ? ( + + ) : ( + + )} + +
+
+
+
-
+ + + - {/* ── Domains ── */} - -
- - {/* ── Sticky Save Footer ── */} -
- - {loading ? : "Save Changes"} - + {hasUnsavedChanges && }
+ ); +} + +function SettingsFooter(props: { loading: boolean }) { + let [distanceFromBottom, setDistanceFromBottom] = useState(Infinity); + + useEffect(() => { + const scrollContainer = document.getElementById("home-content"); + if (!scrollContainer) return; + + const handleScroll = () => { + const dist = + scrollContainer.scrollHeight - + scrollContainer.scrollTop - + scrollContainer.clientHeight; + setDistanceFromBottom(dist); + }; + + handleScroll(); + scrollContainer.addEventListener("scroll", handleScroll); + return () => scrollContainer.removeEventListener("scroll", handleScroll); + }, []); + + const threshold = 100; + // ratio: 1 = far from bottom (full margin), 0 = at bottom (no margin) + const ratio = Math.min(distanceFromBottom / threshold, 1); + const mx = ratio * 8; // 8px = mx-2 + + return ( +
+
+ You have unsaved updates! + + {props.loading ? : "Update Pub"} + +
+
); } export const DashboardContainer = (props: { children: React.ReactNode; className?: string; + section?: string; }) => { let cardBorderHidden = useCardBorderHidden(); return (
+ {props.section && ( + <> +

{props.section}

+
+ + )} {props.children}
+ ); +}; + +let pluralize = (n: number, word: string) => + `${n} ${word}${n === 1 ? "" : "s"}`; + +const DeletePublication = () => { + let [value, setValue] = useState(""); + let [deleting, setDeleting] = useState(false); + let record = useNormalizedPublicationRecord(); + let { data: pub } = usePublicationData(); + let postCount = pub?.documents?.length ?? 0; + let draftCount = pub?.drafts?.length ?? 0; + let subCount = pub?.publication?.publication_subscriptions?.length ?? 0; + let toaster = useToaster(); + let router = useRouter(); + let pubUri = pub?.publication?.uri; + + let onDelete = async () => { + if (!pubUri || record?.name !== value || deleting) return; + setDeleting(true); + let result = await deletePublication(pubUri); + if (!result.success) { + setDeleting(false); + toaster({ + type: "error", + content: isOAuthSessionError(result.error) ? ( + + ) : typeof result.error === "string" ? ( + result.error + ) : ( + "Failed to delete publication" + ), + }); + return; + } + toaster({ + type: "success", + content: `${record?.name ?? "Publication"} deleted`, + }); + router.push("/home"); + }; + + return ( + Delete Publication} + title="Are you sure?" + > +
+
+ This will permanently delete: +
    +
  • This publication and its settings
  • +
  • + {pluralize(postCount, "published post")} + {postCount > 0 ? " (removed from your PDS)" : ""} +
  • +
  • {pluralize(draftCount, "draft")}
  • +
  • All associated records on your PDS
  • +
+ {subCount > 0 && ( +
+ {pluralize(subCount, "subscriber")} will lose access. +
+ )} +
+ This cannot be undone. +
+
+
+ Enter the name of this publication to confirm +
+ + setValue(e.currentTarget.value)} + /> + + {deleting ? : "Delete Publication"} + +
+
); }; diff --git a/app/lish/[did]/[publication]/dashboard/settings/ThemeSettings.tsx b/app/lish/[did]/[publication]/dashboard/settings/ThemeSettings.tsx --- a/app/lish/[did]/[publication]/dashboard/settings/ThemeSettings.tsx +++ b/app/lish/[did]/[publication]/dashboard/settings/ThemeSettings.tsx @@ -1,14 +1,21 @@ -export function ThemeSettings(props: { onOpenTheme: () => void }) { +"use client"; + +import { GoToArrow } from "components/Icons/GoToArrow"; +import { SpeedyLink } from "components/SpeedyLink"; +import { useParams } from "next/navigation"; + +export function ThemeSettings() { + let params = useParams<{ did: string; publication: string }>(); + let href = `/lish/${params.did}/${params.publication}/theme-settings`; + return ( -
-

Theme and Layout

- -
+ Customize Theme + + ); } diff --git a/app/lish/[did]/[publication]/dashboard/settings/deletePublication.ts b/app/lish/[did]/[publication]/dashboard/settings/deletePublication.ts new file mode 100644 --- /dev/null +++ b/app/lish/[did]/[publication]/dashboard/settings/deletePublication.ts @@ -0,0 +1,124 @@ +"use server"; + +import { AtpBaseClient } from "lexicons/api"; +import { getIdentityData } from "actions/getIdentityData"; +import { restoreOAuthSession, OAuthSessionError } from "src/atproto-oauth"; +import { AtUri } from "@atproto/syntax"; +import { supabaseServerClient } from "supabase/serverClient"; +import { drizzle } from "drizzle-orm/node-postgres"; +import { + entities, + permission_tokens, + permission_token_rights, +} from "drizzle/schema"; +import { eq, inArray } from "drizzle-orm"; +import { pool } from "supabase/pool"; +import { revalidatePath } from "next/cache"; + +export async function deletePublication( + publication_uri: string, +): Promise< + { success: true } | { success: false; error: string | OAuthSessionError } +> { + let identity = await getIdentityData(); + if (!identity || !identity.atp_did) { + return { success: false, error: "Not authenticated" }; + } + + let pubUri = new AtUri(publication_uri); + if (pubUri.host !== identity.atp_did) { + return { success: false, error: "Not authorized" }; + } + + const sessionResult = await restoreOAuthSession(identity.atp_did); + if (!sessionResult.ok) { + return { success: false, error: sessionResult.error }; + } + let credentialSession = sessionResult.value; + let agent = new AtpBaseClient( + credentialSession.fetchHandler.bind(credentialSession), + ); + + // Collect these BEFORE deleting the publication row — cascading deletes would remove the join rows. + let [docs, drafts] = await Promise.all([ + supabaseServerClient + .from("documents_in_publications") + .select("document") + .eq("publication", publication_uri), + supabaseServerClient + .from("leaflets_in_publications") + .select("leaflet") + .eq("publication", publication_uri), + ]); + let documentUris = Array.from( + new Set([...(docs.data ?? []).map((r) => r.document)]), + ); + let draftTokenIds = (drafts.data ?? []).map((r) => r.leaflet); + + let pdsDeletes = Promise.all([ + ...documentUris.flatMap((docUri) => { + let u = new AtUri(docUri); + if (u.host !== credentialSession.did) return []; + return [ + agent.pub.leaflet.document + .delete({ repo: credentialSession.did, rkey: u.rkey }) + .catch(() => {}), + agent.site.standard.document + .delete({ repo: credentialSession.did, rkey: u.rkey }) + .catch(() => {}), + ]; + }), + agent.pub.leaflet.publication + .delete({ repo: credentialSession.did, rkey: pubUri.rkey }) + .catch(() => {}), + agent.site.standard.publication + .delete({ repo: credentialSession.did, rkey: pubUri.rkey }) + .catch(() => {}), + ]); + + let draftDeletes = async () => { + if (draftTokenIds.length === 0) return; + const client = await pool.connect(); + try { + const db = drizzle(client); + await db.transaction(async (tx) => { + let tokens = await tx + .select() + .from(permission_tokens) + .leftJoin( + permission_token_rights, + eq(permission_tokens.id, permission_token_rights.token), + ) + .where(inArray(permission_tokens.id, draftTokenIds)); + let entitySets = tokens + .map((t) => t.permission_token_rights?.entity_set) + .filter((s): s is string => !!s); + if (entitySets.length > 0) { + await tx.delete(entities).where(inArray(entities.set, entitySets)); + } + await tx + .delete(permission_tokens) + .where(inArray(permission_tokens.id, draftTokenIds)); + }); + } finally { + client.release(); + } + }; + + await Promise.all([pdsDeletes, draftDeletes()]); + + // Delete document rows before publication rows — publication cascade would leave orphaned docs. + if (documentUris.length > 0) { + await supabaseServerClient + .from("documents") + .delete() + .in("uri", documentUris); + } + await supabaseServerClient + .from("publications") + .delete() + .eq("uri", publication_uri); + + revalidatePath("/lish/[did]/[publication]", "layout"); + return { success: true }; +} -- tangled.sh