From 7cd621e634d4f459e6812c677cc863df8825233d Mon Sep 17 00:00:00 2001 From: Jared Pereira Date: Tue, 24 Mar 2026 18:28:16 -0400 Subject: [PATCH 01/36] Fix Android selection handle drag breaking selection state (#274) * Fix Android selection handle drag breaking selection state The highlightSelectionPlugin creates inline decorations (wrapping text in elements) on every selection change. On Android, these DOM mutations during native selection handle drag cause the browser to lose track of the selection handles. Since these decorations are invisible when the editor is focused (via CSS), skip decoration updates for selection-only changes on Android to prevent the DOM mutations. https://claude.ai/code/session_01Qcv5Xm4QcDnzhWdTbfgQh9 * Skip selection-only decoration updates on all platforms The DOM mutations from updating inline decorations on selection changes are unnecessary since the decorations are invisible when the editor is focused via CSS. Removing the Android-only guard avoids the overhead everywhere. https://claude.ai/code/session_01Qcv5Xm4QcDnzhWdTbfgQh9 * Update decoration on blur so toolbar highlight still works The selection highlight needs to be visible when focus moves from the editor to the toolbar (e.g. for entering a link URL). Dispatch an update on blur via handleDOMEvents so the decoration matches the current selection at that point, while still skipping DOM mutations during active selection drag. https://claude.ai/code/session_01Qcv5Xm4QcDnzhWdTbfgQh9 * Trim verbose comment in highlightSelectionPlugin https://claude.ai/code/session_01Qcv5Xm4QcDnzhWdTbfgQh9 * Fix selection highlight not showing when editor is blurred The `.selected .selection-highlight` CSS rule required a `.selected` ancestor class that only exists during multiselect, so the highlight was never visible for single-block text selection (e.g. when clicking the toolbar). Remove the `.selected` ancestor requirement so the decoration is visible whenever the editor loses focus. https://claude.ai/code/session_01Qcv5Xm4QcDnzhWdTbfgQh9 --------- Co-authored-by: Claude --- app/globals.css | 2 +- components/Blocks/TextBlock/plugins.ts | 16 ++++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/app/globals.css b/app/globals.css index 2145c5b7..24130b0a 100644 --- a/app/globals.css +++ b/app/globals.css @@ -338,7 +338,7 @@ pre.shiki { @apply box-decoration-clone; } -.selected .selection-highlight { +.selection-highlight { background-color: Highlight; @apply py-[1.5px]; } diff --git a/components/Blocks/TextBlock/plugins.ts b/components/Blocks/TextBlock/plugins.ts index c78fb953..256ab470 100644 --- a/components/Blocks/TextBlock/plugins.ts +++ b/components/Blocks/TextBlock/plugins.ts @@ -1,11 +1,19 @@ import { Decoration, DecorationSet } from "prosemirror-view"; import { Plugin } from "prosemirror-state"; + export const highlightSelectionPlugin = new Plugin({ state: { init(_, { doc }) { return DecorationSet.empty; }, apply(tr, oldDecorations, oldState, newState) { + // Skip selection-only changes to avoid DOM mutations that break + // native selection handle dragging. On blur, we force an update + // so the highlight is visible when focus moves to the toolbar. + if (!tr.docChanged && !tr.getMeta("updateSelectionHighlight")) { + return oldDecorations; + } + let decorations = []; // Check if there's a selection @@ -20,6 +28,14 @@ export const highlightSelectionPlugin = new Plugin({ }, }, props: { + handleDOMEvents: { + blur(view) { + view.dispatch( + view.state.tr.setMeta("updateSelectionHighlight", true), + ); + return false; + }, + }, decorations(state) { return this.getState(state); }, -- 2.51.2 From c5602126775a19a2a797506ec4dd0065a7550a00 Mon Sep 17 00:00:00 2001 From: Jared Pereira Date: Tue, 24 Mar 2026 15:44:11 -0700 Subject: [PATCH 02/36] add swipe gestures on list blocks Squashed commit of the following: commit 88e9ea4d1c889a4d304438c872112658f61d2b36 Author: Jared Pereira Date: Tue Mar 24 15:38:47 2026 -0700 tweaks commit 15d34c925489742b00b7bcb4f754b90c35a2fde7 Merge: c10e691e 7cd621e6 Author: Jared Pereira Date: Tue Mar 24 15:29:08 2026 -0700 Merge branch 'main' into feature/list-gestures commit c10e691e797c86f14c25ef1a1799c40819f99d1d Author: Jared Pereira Date: Tue Mar 24 09:47:43 2026 -0700 add swipe gesture on list marker --- components/Blocks/Block.tsx | 43 +++++++++++++++++++++++++++++++++---- package-lock.json | 19 ++++++++++++++++ package.json | 1 + 3 files changed, 59 insertions(+), 4 deletions(-) diff --git a/components/Blocks/Block.tsx b/components/Blocks/Block.tsx index 1ba258be..370eafbf 100644 --- a/components/Blocks/Block.tsx +++ b/components/Blocks/Block.tsx @@ -9,7 +9,8 @@ import { useLongPress } from "src/hooks/useLongPress"; import { focusBlock } from "src/utils/focusBlock"; import { useHandleDrop } from "./useHandleDrop"; import { useEntitySetContext } from "components/EntitySetProvider"; - +import { indent, outdent } from "src/utils/list-operations"; +import { useDrag } from "@use-gesture/react"; import { TextBlock } from "./TextBlock/index"; import { ImageBlock } from "./ImageBlock"; import { PageLinkBlock } from "./PageLinkBlock"; @@ -37,6 +38,8 @@ import { Separator } from "components/Layout"; import { moveBlockUp, moveBlockDown } from "src/utils/moveBlock"; import { deleteBlock } from "src/utils/deleteBlock"; +const SWIPE_THRESHOLD = 50; + export type Block = { factID: string; parent: string; @@ -76,6 +79,8 @@ export const Block = memo(function Block( nextPosition: props.nextPosition, }); let entity_set = useEntitySetContext(); + let isMobile = useIsMobile(); + let { rep } = useReplicache(); let { isLongPress, longPressHandlers } = useLongPress(() => { if (isTextBlock[props.type]) return; @@ -115,9 +120,39 @@ export const Block = memo(function Block( // THIS IS WHERE YOU SET WHETHER OR NOT AREYOUSURE IS TRIGGERED ON THE DELETE KEY useBlockKeyboardHandlers(props, areYouSure, setAreYouSure); + const bindSwipe = useDrag( + ({ last, movement: [mx] }) => { + if (!last) return; + if (!rep || !props.listData || !entity_set.permissions.write) return; + if (Math.abs(mx) < SWIPE_THRESHOLD) return; + let { foldedBlocks, toggleFold } = useUIState.getState(); + if (mx > 0) { + if (props.previousBlock) { + indent(props, props.previousBlock, rep, { + foldedBlocks, + toggleFold, + }); + } + } else { + outdent(props, props.previousBlock, rep, { + foldedBlocks, + toggleFold, + }); + } + }, + { + axis: "x", + filterTaps: true, + pointer: { touch: true }, + enabled: isMobile && !!props.listData, + }, + ); + return (
{ - let isMobile = useIsMobile(); let checklist = useEntity(props.value, "block/check-list"); let listStyle = useEntity(props.value, "block/list-style"); let headingLevel = useEntity(props.value, "block/heading-level")?.data.value; @@ -511,7 +546,6 @@ export const ListMarker = ( let [editingNumber, setEditingNumber] = useState(false); let [numberInputValue, setNumberInputValue] = useState(""); - useEffect(() => { if (!editingNumber) { setNumberInputValue(""); @@ -545,6 +579,7 @@ export const ListMarker = ( setEditingNumber(false); }; + return (
= 16.8.0" + } + }, "node_modules/@vercel/analytics": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@vercel/analytics/-/analytics-1.5.0.tgz", diff --git a/package.json b/package.json index c4c8f256..cccf2886 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,7 @@ "@tinybirdco/sdk": "^0.0.55", "@tiptap/core": "^2.11.5", "@types/mdx": "^2.0.13", + "@use-gesture/react": "^10.3.1", "@vercel/analytics": "^1.5.0", "@vercel/functions": "^2.2.12", "@vercel/sdk": "^1.11.4", -- 2.51.2 From 1561298adfe0da3e6e0d4715dba024a09904e5b6 Mon Sep 17 00:00:00 2001 From: Jared Pereira Date: Tue, 24 Mar 2026 16:17:09 -0700 Subject: [PATCH 03/36] prevent text selection after swipe --- components/Blocks/Block.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/components/Blocks/Block.tsx b/components/Blocks/Block.tsx index 370eafbf..6a9300f6 100644 --- a/components/Blocks/Block.tsx +++ b/components/Blocks/Block.tsx @@ -121,10 +121,11 @@ export const Block = memo(function Block( useBlockKeyboardHandlers(props, areYouSure, setAreYouSure); const bindSwipe = useDrag( - ({ last, movement: [mx] }) => { + ({ last, movement: [mx], event }) => { if (!last) return; if (!rep || !props.listData || !entity_set.permissions.write) return; if (Math.abs(mx) < SWIPE_THRESHOLD) return; + event?.preventDefault(); let { foldedBlocks, toggleFold } = useUIState.getState(); if (mx > 0) { if (props.previousBlock) { -- 2.51.2 From d1c1952a020e49b03911de3f924e2d952b55cc96 Mon Sep 17 00:00:00 2001 From: Jared Pereira Date: Tue, 24 Mar 2026 18:56:37 -0700 Subject: [PATCH 04/36] fix bsky post ref and tag in update flow --- actions/publishToPublication.ts | 41 ++++++++++++++++------ app/[leaflet_id]/actions/PublishButton.tsx | 7 ++-- 2 files changed, 35 insertions(+), 13 deletions(-) diff --git a/actions/publishToPublication.ts b/actions/publishToPublication.ts index d125d0be..288b0d91 100644 --- a/actions/publishToPublication.ts +++ b/actions/publishToPublication.ts @@ -182,7 +182,7 @@ export async function publishToPublication({ credentialSession.did!, ); - let existingRecord: Partial = {}; + let existingRecord: Partial = {}; const normalizedDoc = normalizeDocumentRecord(draft?.documents?.data); if (normalizedDoc) { // When reading existing data, use normalized format to extract fields @@ -194,6 +194,7 @@ export async function publishToPublication({ tags: normalizedDoc.tags, coverImage: normalizedDoc.coverImage, theme: normalizedDoc.theme, + bskyPostRef: normalizedDoc.bskyPostRef, }; } @@ -249,6 +250,14 @@ export async function publishToPublication({ // Determine the rkey early since we need it for the path field const rkey = existingDocUri ? new AtUri(existingDocUri).rkey : TID.nextStr(); + // Resolve fields: use new values if provided, otherwise preserve existing + const resolvedDescription = + description !== undefined ? description : existingRecord.description; + const resolvedTags = tags !== undefined ? tags : existingRecord.tags; + const resolvedCoverImage = coverImageBlob ?? existingRecord.coverImage; + const resolvedPublishedAt = + publishedAt || existingRecord.publishedAt || new Date().toISOString(); + // Create record based on the document type let record: PubLeafletDocument.Record | SiteStandardDocument.Record; @@ -263,11 +272,15 @@ export async function publishToPublication({ title: title || "", site: siteUri, path: "/" + rkey, - publishedAt: - publishedAt || existingRecord.publishedAt || new Date().toISOString(), - ...(description && { description }), - ...(tags !== undefined && { tags }), - ...(coverImageBlob && { coverImage: coverImageBlob }), + publishedAt: resolvedPublishedAt, + ...(resolvedDescription !== undefined && { + description: resolvedDescription, + }), + ...(resolvedTags !== undefined && { tags: resolvedTags }), + ...(resolvedCoverImage && { coverImage: resolvedCoverImage }), + ...(existingRecord.bskyPostRef && { + bskyPostRef: existingRecord.bskyPostRef, + }), // Include theme for standalone documents (not for publication documents) ...(!publication_uri && theme && { theme }), ...(preferences && { @@ -295,12 +308,14 @@ export async function publishToPublication({ }, }), title: title || "", - description: description || "", - ...(tags !== undefined && { tags }), - ...(coverImageBlob && { coverImage: coverImageBlob }), + description: resolvedDescription || "", + ...(resolvedTags !== undefined && { tags: resolvedTags }), + ...(resolvedCoverImage && { coverImage: resolvedCoverImage }), + ...(existingRecord.bskyPostRef && { + postRef: existingRecord.bskyPostRef, + }), pages: pagesArray, - publishedAt: - publishedAt || existingRecord.publishedAt || new Date().toISOString(), + publishedAt: resolvedPublishedAt, } satisfies PubLeafletDocument.Record; } @@ -332,6 +347,8 @@ export async function publishToPublication({ publication: publication_uri, title: title, description: description, + tags: resolvedTags ?? [], + cover_image: cover_image ?? null, }), ]); } else { @@ -341,6 +358,8 @@ export async function publishToPublication({ document: result.uri, title: title || "", description: description || "", + tags: resolvedTags ?? [], + cover_image: cover_image ?? null, }); // Heuristic: Remove title entities if this is the first time publishing standalone diff --git a/app/[leaflet_id]/actions/PublishButton.tsx b/app/[leaflet_id]/actions/PublishButton.tsx index c2497139..5991f28c 100644 --- a/app/[leaflet_id]/actions/PublishButton.tsx +++ b/app/[leaflet_id]/actions/PublishButton.tsx @@ -65,7 +65,7 @@ export const PublishButton = (props: { entityID: string }) => { const UpdateButton = () => { let [isLoading, setIsLoading] = useState(false); - let { data: pub, mutate } = useLeafletPublicationData(); + let { data: pub, mutate, normalizedDocument } = useLeafletPublicationData(); let { permission_token, rootEntity, rep } = useReplicache(); let { identity } = useIdentityData(); let toaster = useToaster(); @@ -88,8 +88,11 @@ const UpdateButton = () => { : pub?.description || ""; // Get tags from Replicache state (same as draft editor) + // Fall back to normalized document tags if Replicache hasn't pulled yet let tags = useSubscribe(rep, (tx) => tx.get("publication_tags")); - const currentTags = Array.isArray(tags) ? tags : []; + const currentTags = Array.isArray(tags) + ? tags + : normalizedDocument?.tags ?? []; // Get cover image from Replicache state let coverImage = useSubscribe(rep, (tx) => -- 2.51.2 From b1d4212d031b64404d68cead0daf93e2a7e7a068 Mon Sep 17 00:00:00 2001 From: Jared Pereira Date: Wed, 25 Mar 2026 12:49:38 -0700 Subject: [PATCH 05/36] remove unsupported rss tags --- app/lish/[did]/[publication]/generateFeed.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/app/lish/[did]/[publication]/generateFeed.ts b/app/lish/[did]/[publication]/generateFeed.ts index 94227ea0..abe30c65 100644 --- a/app/lish/[did]/[publication]/generateFeed.ts +++ b/app/lish/[did]/[publication]/generateFeed.ts @@ -93,6 +93,17 @@ export async function generateFeed( } } + let content = chunks.join(""); + // Strip preload tags injected by React SSR — they trigger + // security warnings in RSS validators and aren't useful in feeds. + content = content.replace(/]*>/gi, ""); + // Convert relative URLs to absolute so RSS readers can resolve them. + const baseUrl = pubRecord.url.replace(/\/$/, ""); + content = content.replace( + /(src|href)="\/(?!\/)/g, + `$1="${baseUrl}/`, + ); + const docUrl = getDocumentURL(record, doc.documents.uri, pubRecord); feed.addItem({ title: record.title, @@ -100,7 +111,7 @@ export async function generateFeed( date: record.publishedAt ? new Date(record.publishedAt) : new Date(), id: docUrl, link: docUrl, - content: chunks.join(""), + content, }); } -- 2.51.2 From 095bf7ceb3d826c2ebbb12039033dd8e6d838d45 Mon Sep 17 00:00:00 2001 From: Jared Pereira Date: Wed, 25 Mar 2026 13:27:12 -0700 Subject: [PATCH 06/36] update middleware route cache --- middleware.ts | 50 ++++++++++++++++++++++++++++++-------------------- 1 file changed, 30 insertions(+), 20 deletions(-) diff --git a/middleware.ts b/middleware.ts index 5181b052..27b6e977 100644 --- a/middleware.ts +++ b/middleware.ts @@ -1,5 +1,6 @@ import { AtUri } from "@atproto/syntax"; import { createClient } from "@supabase/supabase-js"; +import { getCache } from "@vercel/functions"; import { NextRequest, NextResponse } from "next/server"; import { Database } from "supabase/database.types"; @@ -19,21 +20,22 @@ export const config = { let supabase = createClient( process.env.NEXT_PUBLIC_SUPABASE_API_URL as string, process.env.SUPABASE_SERVICE_ROLE_KEY as string, - { - global: { - fetch: async (...args) => { - const response = await fetch(args[0], { - ...args[1], - next: { - revalidate: 60, - }, - }); - return response; - }, - }, - }, ); +const cache = getCache(); + +async function getDomainRoutes(hostname: string) { + let { data } = await supabase + .from("custom_domains") + .select( + "*, custom_domain_routes(*), publication_domains(*, publications(*))", + ) + .eq("domain", hostname) + .single(); + return data; +} +type DomainRoutes = Awaited>; + const auth_callback_route = "/auth_callback"; const receive_auth_callback_route = "/receive_auth_callback"; export default async function middleware(req: NextRequest) { @@ -44,13 +46,21 @@ export default async function middleware(req: NextRequest) { if (hostname === "leaflet.pub") return; if (req.nextUrl.pathname === "/not-found") return; - let { data: routes } = await supabase - .from("custom_domains") - .select( - "*, custom_domain_routes(*), publication_domains(*, publications(*))", - ) - .eq("domain", hostname) - .single(); + let routes: DomainRoutes = null; + try { + routes = (await cache.get(`domain:${hostname}`)) as DomainRoutes; + } catch {} + if (!routes) { + routes = await getDomainRoutes(hostname); + if (routes) { + try { + await cache.set(`domain:${hostname}`, routes, { + ttl: 60, + tags: [`domain:${hostname}`], + }); + } catch {} + } + } let pub = routes?.publication_domains[0]?.publications; if (pub) { -- 2.51.2 From 0034da769a7fbec490e9281f5a2c90ec5bea3dad Mon Sep 17 00:00:00 2001 From: Jared Pereira Date: Wed, 25 Mar 2026 14:07:15 -0700 Subject: [PATCH 07/36] add unified function for getting leaflet page data --- ...260325000000_add_get_leaflet_page_data.sql | 100 ++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 supabase/migrations/20260325000000_add_get_leaflet_page_data.sql diff --git a/supabase/migrations/20260325000000_add_get_leaflet_page_data.sql b/supabase/migrations/20260325000000_add_get_leaflet_page_data.sql new file mode 100644 index 00000000..dc5c0399 --- /dev/null +++ b/supabase/migrations/20260325000000_add_get_leaflet_page_data.sql @@ -0,0 +1,100 @@ +CREATE OR REPLACE FUNCTION public.get_leaflet_page_data(p_token_id uuid) +RETURNS TABLE ( + permission_token json, + permission_token_rights json, + leaflets_in_publications json, + leaflets_to_documents json, + custom_domain_routes json, + facts json +) +LANGUAGE sql STABLE +AS $$ +WITH token AS ( + SELECT pt.* + FROM permission_tokens pt + WHERE pt.id = p_token_id +), +token_rights AS ( + SELECT json_agg(row_to_json(ptr)) AS rights, array_agg(ptr.entity_set) AS entity_sets + FROM permission_token_rights ptr + WHERE ptr.token = p_token_id +), +related_tokens AS ( + SELECT array_agg(pt2.id) AS ids + FROM permission_token_rights ptr2 + JOIN permission_tokens pt2 ON pt2.id = ptr2.token + WHERE ptr2.entity_set IN (SELECT unnest(entity_sets) FROM token_rights) + AND pt2.id != p_token_id +), +lip_direct AS ( + SELECT json_agg(row_to_json(sub)) AS data + FROM ( + SELECT lip.*, + row_to_json(pub) AS publications, + row_to_json(d) AS documents + FROM leaflets_in_publications lip + LEFT JOIN publications pub ON pub.uri = lip.publication + LEFT JOIN documents d ON d.uri = lip.doc + WHERE lip.leaflet = p_token_id + ) sub +), +lip_fallback AS ( + SELECT json_agg(row_to_json(sub)) AS data + FROM ( + SELECT lip.*, + row_to_json(pub) AS publications, + row_to_json(d) AS documents + FROM leaflets_in_publications lip + LEFT JOIN publications pub ON pub.uri = lip.publication + LEFT JOIN documents d ON d.uri = lip.doc + WHERE lip.leaflet IN (SELECT unnest(ids) FROM related_tokens) + ) sub +), +ltd_direct AS ( + SELECT json_agg(row_to_json(sub)) AS data + FROM ( + SELECT ltd.*, + row_to_json(doc) AS documents + FROM leaflets_to_documents ltd + LEFT JOIN documents doc ON doc.uri = ltd.document + WHERE ltd.leaflet = p_token_id + ) sub +), +ltd_fallback AS ( + SELECT json_agg(row_to_json(sub)) AS data + FROM ( + SELECT ltd.*, + row_to_json(doc) AS documents + FROM leaflets_to_documents ltd + LEFT JOIN documents doc ON doc.uri = ltd.document + WHERE ltd.leaflet IN (SELECT unnest(ids) FROM related_tokens) + ) sub +), +cdr AS ( + SELECT json_agg(row_to_json(c)) AS data + FROM custom_domain_routes c + WHERE c.edit_permission_token = p_token_id +), +facts AS ( + SELECT json_agg(row_to_json(f)) AS data + FROM get_facts((SELECT root_entity FROM token)) f +) +SELECT + row_to_json(token) AS permission_token, + (SELECT rights FROM token_rights) AS permission_token_rights, + COALESCE((SELECT data FROM lip_direct), (SELECT data FROM lip_fallback)) AS leaflets_in_publications, + COALESCE((SELECT data FROM ltd_direct), (SELECT data FROM ltd_fallback)) AS leaflets_to_documents, + (SELECT data FROM cdr) AS custom_domain_routes, + (SELECT data FROM facts) AS facts +FROM token; +$$; + +-- Indexes for get_leaflet_page_data query patterns +CREATE INDEX IF NOT EXISTS leaflets_in_publications_leaflet_idx + ON public.leaflets_in_publications(leaflet); + +CREATE INDEX IF NOT EXISTS permission_token_rights_entity_set_idx + ON public.permission_token_rights(entity_set); + +CREATE INDEX IF NOT EXISTS custom_domain_routes_edit_permission_token_idx + ON public.custom_domain_routes(edit_permission_token); -- 2.51.2 From eed0d15715dfd8d0313c8e97dfd901d1abb3270a Mon Sep 17 00:00:00 2001 From: celine Date: Wed, 25 Mar 2026 17:53:16 -0400 Subject: [PATCH 08/36] set profile description font to inherit body --- app/(home-pages)/p/[didOrHandle]/ProfileHeader.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/app/(home-pages)/p/[didOrHandle]/ProfileHeader.tsx b/app/(home-pages)/p/[didOrHandle]/ProfileHeader.tsx index 13cdbfba..3589e780 100644 --- a/app/(home-pages)/p/[didOrHandle]/ProfileHeader.tsx +++ b/app/(home-pages)/p/[didOrHandle]/ProfileHeader.tsx @@ -43,7 +43,6 @@ export const ProfileHeader = (props: {
); - return (
-
+          
             {profileRecord.description
               ? parseDescription(profileRecord.description)
               : null}
-- 
2.51.2


From 790125ef88bf06f0d841ef90677aad42187cab25 Mon Sep 17 00:00:00 2001
From: celine 
Date: Wed, 25 Mar 2026 16:59:22 -0500
Subject: [PATCH 09/36] Refactor/domain management (#277)

* refactor domain management

* fix bugs in ui model

* check if route already assigned

* add confirm to domain delete

* add loaders

* remove domains on specific leaflets

* tweak copy

* use styling from previous branch

* merged in main, moved the domain settings to a modal

* a buncha cruncha styling updates

* Merge branch 'main' of https://github.com/hyperlink-academy/minilink
into refactor/domain-management

* bug fixes

* fixed an input console error

* Delete components/utils/CoordDebugger.tsx

* consolidate domain actions

---------

Co-authored-by: Jared Pereira 
---
 CLAUDE.md                                     |   1 +
 actions/domains/addDomain.ts                  |  98 ---
 actions/domains/addDomainPath.ts              |  39 --
 actions/domains/deleteDomain.ts               |  48 --
 actions/domains/index.ts                      | 210 ++++++
 actions/getIdentityData.ts                    |   2 +-
 app/[leaflet_id]/actions/PublishButton.tsx    |   8 +-
 .../actions/ShareOptions/DomainOptions.tsx    | 605 ++++++++----------
 app/lish/Subscribe.tsx                        |   4 +-
 .../[rkey]/DocumentPageRenderer.tsx           |  17 +-
 .../settings/PublicationSettings.tsx          |  26 +-
 app/lish/createPub/UpdatePubForm.tsx          | 339 +---------
 app/lish/createPub/page.tsx                   |   2 +-
 components/ActionBar/ProfileButton.tsx        |   7 +-
 components/Buttons.tsx                        |   6 +-
 components/Domains/AddDomainForm.tsx          |  87 +++
 components/Domains/DomainList.tsx             | 152 +++++
 components/Domains/DomainSettingsView.tsx     | 426 ++++++++++++
 components/Domains/ManageDomains.tsx          |  58 ++
 components/Domains/PublicationDomains.tsx     | 399 ++++++++++++
 components/Domains/domainAssignment.ts        |  32 +
 components/Domains/useDomainStatus.ts         |  10 +
 components/Icons/GoToArrow.tsx                |   1 +
 components/Icons/RefreshSmall.tsx             |  19 +
 components/Icons/UnlinkTiny.tsx               |  18 +
 components/Icons/WebSmall.tsx                 |  19 +
 components/Modal.tsx                          |   8 +-
 components/OAuthError.tsx                     |   4 +-
 components/Popover/index.tsx                  |   4 +-
 components/Toast.tsx                          |   2 +-
 next-env.d.ts                                 |   2 +-
 31 files changed, 1782 insertions(+), 871 deletions(-)
 delete mode 100644 actions/domains/addDomain.ts
 delete mode 100644 actions/domains/addDomainPath.ts
 delete mode 100644 actions/domains/deleteDomain.ts
 create mode 100644 actions/domains/index.ts
 create mode 100644 components/Domains/AddDomainForm.tsx
 create mode 100644 components/Domains/DomainList.tsx
 create mode 100644 components/Domains/DomainSettingsView.tsx
 create mode 100644 components/Domains/ManageDomains.tsx
 create mode 100644 components/Domains/PublicationDomains.tsx
 create mode 100644 components/Domains/domainAssignment.ts
 create mode 100644 components/Domains/useDomainStatus.ts
 create mode 100644 components/Icons/RefreshSmall.tsx
 create mode 100644 components/Icons/UnlinkTiny.tsx
 create mode 100644 components/Icons/WebSmall.tsx

diff --git a/CLAUDE.md b/CLAUDE.md
index 00fe4923..dd3521e7 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -70,3 +70,4 @@ To add or modify a lexicon:
 - **Replicache mutations**: Named handlers in `src/replicache/mutations.ts`, keep server mutations idempotent
 - **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"`.
diff --git a/actions/domains/addDomain.ts b/actions/domains/addDomain.ts
deleted file mode 100644
index 9f17e85c..00000000
--- a/actions/domains/addDomain.ts
+++ /dev/null
@@ -1,98 +0,0 @@
-"use server";
-import { Vercel } from "@vercel/sdk";
-import { cookies } from "next/headers";
-
-import { Database } from "supabase/database.types";
-import { createServerClient } from "@supabase/ssr";
-import { getIdentityData } from "actions/getIdentityData";
-
-const VERCEL_TOKEN = process.env.VERCEL_TOKEN;
-const vercel = new Vercel({
-  bearerToken: VERCEL_TOKEN,
-});
-
-let supabase = createServerClient(
-  process.env.NEXT_PUBLIC_SUPABASE_API_URL as string,
-  process.env.SUPABASE_SERVICE_ROLE_KEY as string,
-  { cookies: {} },
-);
-
-export async function addDomain(domain: string) {
-  let identity = await getIdentityData();
-  if (!identity || (!identity.email && !identity.atp_did)) return {};
-  if (
-    domain.includes("leaflet.pub") &&
-    (!identity.email ||
-      ![
-        "celine@hyperlink.academy",
-        "brendan@hyperlink.academy",
-        "jared@hyperlink.academy",
-        "brendan.schlagel@gmail.com",
-      ].includes(identity.email))
-  )
-    return {};
-  return await createDomain(domain, identity.email, identity.id);
-}
-
-export async function addPublicationDomain(
-  domain: string,
-  publication_uri: string,
-) {
-  let identity = await getIdentityData();
-  if (!identity || !identity.atp_did) return {};
-  let { data: publication } = await supabase
-    .from("publications")
-    .select("*")
-    .eq("uri", publication_uri)
-    .single();
-
-  if (publication?.identity_did !== identity.atp_did) return {};
-  let { error } = await createDomain(domain, null, identity.id);
-  if (error) return { error };
-  await supabase.from("publication_domains").insert({
-    publication: publication_uri,
-    identity: identity.atp_did,
-    domain,
-  });
-  return {};
-}
-
-async function createDomain(
-  domain: string,
-  email: string | null,
-  identity_id: string,
-) {
-  try {
-    await vercel.projects.addProjectDomain({
-      idOrName: "prj_9jX4tmYCISnm176frFxk07fF74kG",
-      teamId: "team_42xaJiZMTw9Sr7i0DcLTae9d",
-      requestBody: {
-        name: domain,
-      },
-    });
-  } catch (e) {
-    console.log(e);
-    let error: "unknown-error" | "invalid_domain" | "domain_already_in_use" =
-      "unknown-error";
-    if ((e as any).rawValue) {
-      error =
-        (e as { rawValue?: { error?: { code?: "invalid_domain" } } })?.rawValue
-          ?.error?.code || "unknown-error";
-    }
-    if ((e as any).body) {
-      try {
-        error = JSON.parse((e as any).body)?.error?.code || "unknown-error";
-      } catch (e) {}
-    }
-
-    return { error };
-  }
-
-  await supabase.from("custom_domains").insert({
-    domain,
-    identity: email,
-    confirmed: false,
-    identity_id,
-  });
-  return {};
-}
diff --git a/actions/domains/addDomainPath.ts b/actions/domains/addDomainPath.ts
deleted file mode 100644
index df580fc4..00000000
--- a/actions/domains/addDomainPath.ts
+++ /dev/null
@@ -1,39 +0,0 @@
-"use server";
-import { cookies } from "next/headers";
-import { Database } from "supabase/database.types";
-import { createServerClient } from "@supabase/ssr";
-import { getIdentityData } from "actions/getIdentityData";
-
-let supabase = createServerClient(
-  process.env.NEXT_PUBLIC_SUPABASE_API_URL as string,
-  process.env.SUPABASE_SERVICE_ROLE_KEY as string,
-  { cookies: {} },
-);
-export async function addDomainPath({
-  domain,
-  view_permission_token,
-  edit_permission_token,
-  route,
-}: {
-  domain: string;
-  view_permission_token: string;
-  edit_permission_token: string;
-  route: string;
-}) {
-  let auth_data = await getIdentityData();
-  if (!auth_data || !auth_data.custom_domains.find((d) => d.domain === domain))
-    return null;
-
-  await supabase
-    .from("custom_domain_routes")
-    .delete()
-    .eq("edit_permission_token", edit_permission_token);
-
-  await supabase.from("custom_domain_routes").insert({
-    domain,
-    route,
-    view_permission_token,
-    edit_permission_token,
-  });
-  return true;
-}
diff --git a/actions/domains/deleteDomain.ts b/actions/domains/deleteDomain.ts
deleted file mode 100644
index 2842041d..00000000
--- a/actions/domains/deleteDomain.ts
+++ /dev/null
@@ -1,48 +0,0 @@
-"use server";
-import { cookies } from "next/headers";
-import { Database } from "supabase/database.types";
-import { createServerClient } from "@supabase/ssr";
-import { Vercel } from "@vercel/sdk";
-
-let supabase = createServerClient(
-  process.env.NEXT_PUBLIC_SUPABASE_API_URL as string,
-  process.env.SUPABASE_SERVICE_ROLE_KEY as string,
-  { cookies: {} },
-);
-
-const VERCEL_TOKEN = process.env.VERCEL_TOKEN;
-const vercel = new Vercel({
-  bearerToken: VERCEL_TOKEN,
-});
-export async function deleteDomain({ domain }: { domain: string }) {
-  let auth_token = (await cookies()).get("auth_token")?.value;
-  if (!auth_token) return null;
-  let { data: auth_data } = await supabase
-    .from("email_auth_tokens")
-    .select(
-      `*,
-          identities(
-            *,
-            custom_domains!custom_domains_identity_fkey(*)
-          )`,
-    )
-    .eq("id", auth_token)
-    .eq("confirmed", true)
-    .single();
-  if (
-    !auth_data ||
-    !auth_data.email ||
-    !auth_data.identities?.custom_domains.find((d) => d.domain === domain)
-  )
-    return null;
-
-  await supabase.from("custom_domain_routes").delete().eq("domain", domain);
-  await supabase.from("custom_domains").delete().eq("domain", domain);
-  await vercel.projects.removeProjectDomain({
-    idOrName: "prj_9jX4tmYCISnm176frFxk07fF74kG",
-    teamId: "team_42xaJiZMTw9Sr7i0DcLTae9d",
-    domain,
-  });
-
-  return true;
-}
diff --git a/actions/domains/index.ts b/actions/domains/index.ts
new file mode 100644
index 00000000..4c529182
--- /dev/null
+++ b/actions/domains/index.ts
@@ -0,0 +1,210 @@
+"use server";
+import { Database } from "supabase/database.types";
+import { createServerClient } from "@supabase/ssr";
+import { Vercel } from "@vercel/sdk";
+import { getIdentityData } from "actions/getIdentityData";
+
+let supabase = createServerClient(
+  process.env.NEXT_PUBLIC_SUPABASE_API_URL as string,
+  process.env.SUPABASE_SERVICE_ROLE_KEY as string,
+  { cookies: {} },
+);
+
+const vercel = new Vercel({
+  bearerToken: process.env.VERCEL_TOKEN,
+});
+
+const VERCEL_PROJECT = "prj_9jX4tmYCISnm176frFxk07fF74kG";
+const VERCEL_TEAM = "team_42xaJiZMTw9Sr7i0DcLTae9d";
+
+// Shared helpers
+// ==============
+
+async function assertOwnsDomain(domain: string) {
+  let identity = await getIdentityData();
+  if (!identity || !identity.custom_domains.find((d) => d.domain === domain))
+    return null;
+  return identity;
+}
+
+// Clear all assignments (routes + publication links) for a domain,
+// without deleting the domain itself.
+async function clearAllAssignments(domain: string) {
+  await Promise.all([
+    supabase.from("custom_domain_routes").delete().eq("domain", domain),
+    supabase.from("publication_domains").delete().eq("domain", domain),
+  ]);
+}
+
+// Adding domains
+// ==============
+
+export async function addDomain(domain: string) {
+  let identity = await getIdentityData();
+  if (!identity || (!identity.email && !identity.atp_did)) return {};
+  if (
+    domain.includes("leaflet.pub") &&
+    (!identity.email ||
+      ![
+        "celine@hyperlink.academy",
+        "brendan@hyperlink.academy",
+        "jared@hyperlink.academy",
+        "brendan.schlagel@gmail.com",
+      ].includes(identity.email))
+  )
+    return {};
+  return await createDomain(domain, identity.email, identity.id);
+}
+
+async function createDomain(
+  domain: string,
+  email: string | null,
+  identity_id: string,
+) {
+  try {
+    await vercel.projects.addProjectDomain({
+      idOrName: VERCEL_PROJECT,
+      teamId: VERCEL_TEAM,
+      requestBody: { name: domain },
+    });
+  } catch (e) {
+    console.log(e);
+    let error: "unknown-error" | "invalid_domain" | "domain_already_in_use" =
+      "unknown-error";
+    if ((e as any).rawValue) {
+      error =
+        (e as { rawValue?: { error?: { code?: "invalid_domain" } } })?.rawValue
+          ?.error?.code || "unknown-error";
+    }
+    if ((e as any).body) {
+      try {
+        error = JSON.parse((e as any).body)?.error?.code || "unknown-error";
+      } catch (e) {}
+    }
+    return { error };
+  }
+
+  await supabase.from("custom_domains").insert({
+    domain,
+    identity: email,
+    confirmed: false,
+    identity_id,
+  });
+  return {};
+}
+
+// Assigning domains
+// =================
+
+// Point a domain at a leaflet document. Clears any existing assignment first,
+// since a domain can only point to one thing at a time.
+export async function assignDomainToDocument({
+  domain,
+  route,
+  view_permission_token,
+  edit_permission_token,
+}: {
+  domain: string;
+  route: string;
+  view_permission_token: string;
+  edit_permission_token: string;
+}) {
+  if (!(await assertOwnsDomain(domain))) return null;
+
+  await Promise.all([
+    supabase.from("publication_domains").delete().eq("domain", domain),
+    supabase
+      .from("custom_domain_routes")
+      .delete()
+      .eq("edit_permission_token", edit_permission_token),
+  ]);
+
+  await supabase.from("custom_domain_routes").insert({
+    domain,
+    route,
+    view_permission_token,
+    edit_permission_token,
+  });
+
+  return true;
+}
+
+// Point a domain at a publication. Clears any existing assignment first.
+export async function assignDomainToPublication({
+  domain,
+  publication_uri,
+}: {
+  domain: string;
+  publication_uri: string;
+}) {
+  let identity = await getIdentityData();
+  if (!identity || !identity.atp_did) return null;
+  if (!identity.custom_domains.find((d) => d.domain === domain)) return null;
+
+  let { data: publication } = await supabase
+    .from("publications")
+    .select("*")
+    .eq("uri", publication_uri)
+    .single();
+  if (publication?.identity_did !== identity.atp_did) return null;
+
+  await clearAllAssignments(domain);
+
+  await supabase.from("publication_domains").insert({
+    publication: publication_uri,
+    identity: identity.atp_did,
+    domain,
+  });
+
+  return true;
+}
+
+// Removing assignments
+// ====================
+
+// Remove all assignments from a domain (routes + publication links),
+// but keep the domain itself registered.
+export async function removeDomainAssignment({
+  domain,
+}: {
+  domain: string;
+}) {
+  if (!(await assertOwnsDomain(domain))) return null;
+  await clearAllAssignments(domain);
+  return true;
+}
+
+// Remove a single route assignment by ID.
+export async function removeDomainRoute({ routeId }: { routeId: string }) {
+  let identity = await getIdentityData();
+  if (!identity) return null;
+
+  let allRoutes = identity.custom_domains.flatMap(
+    (d) => d.custom_domain_routes,
+  );
+  if (!allRoutes.find((r) => r.id === routeId)) return null;
+
+  await supabase.from("custom_domain_routes").delete().eq("id", routeId);
+
+  return true;
+}
+
+// Deleting domains
+// ================
+
+// Fully delete a domain: clear all assignments, remove from DB, and remove from Vercel.
+export async function deleteDomain({ domain }: { domain: string }) {
+  if (!(await assertOwnsDomain(domain))) return null;
+
+  await clearAllAssignments(domain);
+  await Promise.all([
+    supabase.from("custom_domains").delete().eq("domain", domain),
+    vercel.projects.removeProjectDomain({
+      idOrName: VERCEL_PROJECT,
+      teamId: VERCEL_TEAM,
+      domain,
+    }),
+  ]);
+
+  return true;
+}
diff --git a/actions/getIdentityData.ts b/actions/getIdentityData.ts
index 2c7cd1ab..a2c05347 100644
--- a/actions/getIdentityData.ts
+++ b/actions/getIdentityData.ts
@@ -20,7 +20,7 @@ export async function uncachedGetIdentityData() {
             bsky_profiles(*),
             notifications(count),
             publication_subscriptions(*),
-            custom_domains!custom_domains_identity_id_fkey(publication_domains(*), *),
+            custom_domains!custom_domains_identity_id_fkey(publication_domains(*, publications(name)), custom_domain_routes(*), *),
             home_leaflet:permission_tokens!identities_home_page_fkey(*, permission_token_rights(*,
                               entity_sets(entities(facts(*)))
             )),
diff --git a/app/[leaflet_id]/actions/PublishButton.tsx b/app/[leaflet_id]/actions/PublishButton.tsx
index 5991f28c..f7f99ff4 100644
--- a/app/[leaflet_id]/actions/PublishButton.tsx
+++ b/app/[leaflet_id]/actions/PublishButton.tsx
@@ -188,7 +188,7 @@ const PublishToPublicationButton = (props: { entityID: string }) => {
       onOpenChange={(o) => setOpen(o)}
       side={isMobile ? "top" : "right"}
       align={isMobile ? "center" : "start"}
-      className="sm:max-w-sm w-[1000px]"
+      className="sm:max-w-sm w-[1000px] p-0!"
       trigger={
          {
       }
     >
       {!identity || !identity.atp_did ? (
-        
+
@@ -222,7 +222,7 @@ const PublishToPublicationButton = (props: { entityID: string }) => {
) : ( -
+
void; }) => { return ( -
+
Post Details
diff --git a/app/[leaflet_id]/actions/ShareOptions/DomainOptions.tsx b/app/[leaflet_id]/actions/ShareOptions/DomainOptions.tsx index 1221c788..0800a7a7 100644 --- a/app/[leaflet_id]/actions/ShareOptions/DomainOptions.tsx +++ b/app/[leaflet_id]/actions/ShareOptions/DomainOptions.tsx @@ -1,34 +1,34 @@ import { useState } from "react"; import { ButtonPrimary } from "components/Buttons"; - -import { useSmoker, useToaster } from "components/Toast"; -import { Input, InputWithLabel } from "components/Input"; -import useSWR from "swr"; -import { useIdentityData } from "components/IdentityProvider"; -import { addDomain } from "actions/domains/addDomain"; -import { callRPC } from "app/api/rpc/client"; +import { useToaster } from "components/Toast"; +import { Input } from "components/Input"; +import { + useIdentityData, + mutateIdentityData, +} from "components/IdentityProvider"; +import { useDomainStatus } from "components/Domains/useDomainStatus"; +import { CustomDomain } from "components/Domains/DomainList"; import { useLeafletDomains } from "components/PageSWRDataProvider"; import { useReadOnlyShareLink } from "."; -import { addDomainPath } from "actions/domains/addDomainPath"; +import { + assignDomainToDocument, + removeDomainRoute, +} from "actions/domains"; import { useReplicache } from "src/replicache"; -import { deleteDomain } from "actions/domains/deleteDomain"; -import { AddTiny } from "components/Icons/AddTiny"; +import { AddDomainForm } from "components/Domains/AddDomainForm"; +import { DomainSettingsView } from "components/Domains/DomainSettingsView"; +import { DotLoader } from "components/utils/DotLoader"; +import { GoToArrow } from "components/Icons/GoToArrow"; +import { LoadingTiny } from "components/Icons/LoadingTiny"; +import { UnlinkTiny } from "components/Icons/UnlinkTiny"; +import Link from "next/link"; type DomainMenuState = - | { - state: "default"; - } - | { - state: "domain-settings"; - domain: string; - } - | { - state: "add-domain"; - } - | { - state: "has-domain"; - domain: string; - }; + | { state: "default" } + | { state: "domain-settings"; domain: string } + | { state: "add-domain" } + | { state: "has-domain"; domain: string }; + export function CustomDomainMenu(props: { setShareMenuState: (s: "default") => void; }) { @@ -44,351 +44,314 @@ export function CustomDomainMenu(props: { return ( ); case "domain-settings": return ( - +
+ setState({ state: "default" })} + onRemoveAssignment={() => setState({ state: "default" })} + onDeleteDomain={() => setState({ state: "default" })} + /> +
); case "add-domain": - return ; + return ( + + setState({ state: "domain-settings", domain }) + } + onBack={() => setState({ state: "default" })} + /> + ); } } -export const DomainOptions = (props: { +const DomainOptions = (props: { setShareMenuState: (s: "default") => void; setDomainMenuState: (state: DomainMenuState) => void; - domainConnected: boolean; }) => { let { data: domains, mutate: mutateDomains } = useLeafletDomains(); let [selectedDomain, setSelectedDomain] = useState( - domains?.[0]?.domain, - ); - let [selectedRoute, setSelectedRoute] = useState( - domains?.[0]?.route.slice(1) || "", + undefined, ); - let { identity } = useIdentityData(); + let [selectedRoute, setSelectedRoute] = useState(""); + let { identity, mutate: mutateIdentity } = useIdentityData(); let { permission_token } = useReplicache(); + let [loading, setLoading] = useState(false); let toaster = useToaster(); - let smoker = useSmoker(); let publishLink = useReadOnlyShareLink(); - return ( -
-

Choose a Domain

-
- {identity?.custom_domains - .filter((d) => !d.publication_domains.length) - .map((domain) => { - return ( - - ); - })} - -
- - {/* ONLY SHOW IF A DOMAIN IS CURRENTLY CONNECTED */} -
- {props.domainConnected && ( - - )} + // Filter out domains assigned to publications + let allDomains = (identity?.custom_domains || []).filter( + (d: CustomDomain) => d.publication_domains.length === 0, + ); - { - // let rect = document - // .getElementById("publish-to-domain") - // ?.getBoundingClientRect(); - // smoker({ - // error: true, - // text: "url already in use!", - // position: { - // x: rect ? rect.left : 0, - // y: rect ? rect.top + 26 : 0, - // }, - // }); - if (!selectedDomain || !publishLink) return; - await addDomainPath({ - domain: selectedDomain, - route: "/" + selectedRoute, - view_permission_token: publishLink, - edit_permission_token: permission_token.id, - }); + // Categorize domains + let linkedDomains = allDomains.filter((d: CustomDomain) => + d.custom_domain_routes.some( + (r) => r.edit_permission_token === permission_token.id, + ), + ); + let pendingDomainsList: CustomDomain[] = []; + let availableDomainsList: CustomDomain[] = []; - toaster({ - content: ( -
- Published to custom domain!{" "} - - View - -
- ), - type: "success", - }); - mutateDomains(); - props.setShareMenuState("default"); - }} - > - Publish! -
-
-
+ // We'll categorize in the render since pending requires a hook per domain + let nonLinkedDomains = allDomains.filter( + (d: CustomDomain) => + !d.custom_domain_routes.some( + (r) => r.edit_permission_token === permission_token.id, + ), ); -}; -const DomainOption = (props: { - selectedRoute: string; - setSelectedRoute: (s: string) => void; - checked: boolean; - setChecked: (checked: string) => void; - domain: string; - setDomainMenuState: (state: DomainMenuState) => void; -}) => { - let [value, setValue] = useState(""); - let { data } = useSWR(props.domain, async (domain) => { - return await callRPC("get_domain_status", { domain }); - }); - let pending = data?.config?.misconfigured || data?.error; - return ( -