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 ( -