From 790125ef88bf06f0d841ef90677aad42187cab25 Mon Sep 17 00:00:00 2001 From: celine Date: Wed, 25 Mar 2026 16:59:22 -0500 Subject: [PATCH] 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 ( -