a tool for shared writing and social publishing
0

Configure Feed

Select the types of activity you want to include in your feed.

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 <jared@awarm.space>

authored by

celine
Jared Pereira
and committed by
GitHub
(Mar 25, 2026, 5:59 PM EDT) 790125ef eed0d157

+1782 -871
+1
CLAUDE.md
··· 70 70 - **Replicache mutations**: Named handlers in `src/replicache/mutations.ts`, keep server mutations idempotent 71 71 - **React contexts**: `DocumentProvider`, `LeafletContentProvider` for page-level data 72 72 - **Inngest functions**: Async jobs in `app/api/inngest/functions/` 73 + - **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 `<svg>` element, and uses `fill="currentColor"` instead of hardcoded colors like `fill="black"`.
-98
actions/domains/addDomain.ts
··· 1 - "use server"; 2 - import { Vercel } from "@vercel/sdk"; 3 - import { cookies } from "next/headers"; 4 - 5 - import { Database } from "supabase/database.types"; 6 - import { createServerClient } from "@supabase/ssr"; 7 - import { getIdentityData } from "actions/getIdentityData"; 8 - 9 - const VERCEL_TOKEN = process.env.VERCEL_TOKEN; 10 - const vercel = new Vercel({ 11 - bearerToken: VERCEL_TOKEN, 12 - }); 13 - 14 - let supabase = createServerClient<Database>( 15 - process.env.NEXT_PUBLIC_SUPABASE_API_URL as string, 16 - process.env.SUPABASE_SERVICE_ROLE_KEY as string, 17 - { cookies: {} }, 18 - ); 19 - 20 - export async function addDomain(domain: string) { 21 - let identity = await getIdentityData(); 22 - if (!identity || (!identity.email && !identity.atp_did)) return {}; 23 - if ( 24 - domain.includes("leaflet.pub") && 25 - (!identity.email || 26 - ![ 27 - "celine@hyperlink.academy", 28 - "brendan@hyperlink.academy", 29 - "jared@hyperlink.academy", 30 - "brendan.schlagel@gmail.com", 31 - ].includes(identity.email)) 32 - ) 33 - return {}; 34 - return await createDomain(domain, identity.email, identity.id); 35 - } 36 - 37 - export async function addPublicationDomain( 38 - domain: string, 39 - publication_uri: string, 40 - ) { 41 - let identity = await getIdentityData(); 42 - if (!identity || !identity.atp_did) return {}; 43 - let { data: publication } = await supabase 44 - .from("publications") 45 - .select("*") 46 - .eq("uri", publication_uri) 47 - .single(); 48 - 49 - if (publication?.identity_did !== identity.atp_did) return {}; 50 - let { error } = await createDomain(domain, null, identity.id); 51 - if (error) return { error }; 52 - await supabase.from("publication_domains").insert({ 53 - publication: publication_uri, 54 - identity: identity.atp_did, 55 - domain, 56 - }); 57 - return {}; 58 - } 59 - 60 - async function createDomain( 61 - domain: string, 62 - email: string | null, 63 - identity_id: string, 64 - ) { 65 - try { 66 - await vercel.projects.addProjectDomain({ 67 - idOrName: "prj_9jX4tmYCISnm176frFxk07fF74kG", 68 - teamId: "team_42xaJiZMTw9Sr7i0DcLTae9d", 69 - requestBody: { 70 - name: domain, 71 - }, 72 - }); 73 - } catch (e) { 74 - console.log(e); 75 - let error: "unknown-error" | "invalid_domain" | "domain_already_in_use" = 76 - "unknown-error"; 77 - if ((e as any).rawValue) { 78 - error = 79 - (e as { rawValue?: { error?: { code?: "invalid_domain" } } })?.rawValue 80 - ?.error?.code || "unknown-error"; 81 - } 82 - if ((e as any).body) { 83 - try { 84 - error = JSON.parse((e as any).body)?.error?.code || "unknown-error"; 85 - } catch (e) {} 86 - } 87 - 88 - return { error }; 89 - } 90 - 91 - await supabase.from("custom_domains").insert({ 92 - domain, 93 - identity: email, 94 - confirmed: false, 95 - identity_id, 96 - }); 97 - return {}; 98 - }
-39
actions/domains/addDomainPath.ts
··· 1 - "use server"; 2 - import { cookies } from "next/headers"; 3 - import { Database } from "supabase/database.types"; 4 - import { createServerClient } from "@supabase/ssr"; 5 - import { getIdentityData } from "actions/getIdentityData"; 6 - 7 - let supabase = createServerClient<Database>( 8 - process.env.NEXT_PUBLIC_SUPABASE_API_URL as string, 9 - process.env.SUPABASE_SERVICE_ROLE_KEY as string, 10 - { cookies: {} }, 11 - ); 12 - export async function addDomainPath({ 13 - domain, 14 - view_permission_token, 15 - edit_permission_token, 16 - route, 17 - }: { 18 - domain: string; 19 - view_permission_token: string; 20 - edit_permission_token: string; 21 - route: string; 22 - }) { 23 - let auth_data = await getIdentityData(); 24 - if (!auth_data || !auth_data.custom_domains.find((d) => d.domain === domain)) 25 - return null; 26 - 27 - await supabase 28 - .from("custom_domain_routes") 29 - .delete() 30 - .eq("edit_permission_token", edit_permission_token); 31 - 32 - await supabase.from("custom_domain_routes").insert({ 33 - domain, 34 - route, 35 - view_permission_token, 36 - edit_permission_token, 37 - }); 38 - return true; 39 - }
-48
actions/domains/deleteDomain.ts
··· 1 - "use server"; 2 - import { cookies } from "next/headers"; 3 - import { Database } from "supabase/database.types"; 4 - import { createServerClient } from "@supabase/ssr"; 5 - import { Vercel } from "@vercel/sdk"; 6 - 7 - let supabase = createServerClient<Database>( 8 - process.env.NEXT_PUBLIC_SUPABASE_API_URL as string, 9 - process.env.SUPABASE_SERVICE_ROLE_KEY as string, 10 - { cookies: {} }, 11 - ); 12 - 13 - const VERCEL_TOKEN = process.env.VERCEL_TOKEN; 14 - const vercel = new Vercel({ 15 - bearerToken: VERCEL_TOKEN, 16 - }); 17 - export async function deleteDomain({ domain }: { domain: string }) { 18 - let auth_token = (await cookies()).get("auth_token")?.value; 19 - if (!auth_token) return null; 20 - let { data: auth_data } = await supabase 21 - .from("email_auth_tokens") 22 - .select( 23 - `*, 24 - identities( 25 - *, 26 - custom_domains!custom_domains_identity_fkey(*) 27 - )`, 28 - ) 29 - .eq("id", auth_token) 30 - .eq("confirmed", true) 31 - .single(); 32 - if ( 33 - !auth_data || 34 - !auth_data.email || 35 - !auth_data.identities?.custom_domains.find((d) => d.domain === domain) 36 - ) 37 - return null; 38 - 39 - await supabase.from("custom_domain_routes").delete().eq("domain", domain); 40 - await supabase.from("custom_domains").delete().eq("domain", domain); 41 - await vercel.projects.removeProjectDomain({ 42 - idOrName: "prj_9jX4tmYCISnm176frFxk07fF74kG", 43 - teamId: "team_42xaJiZMTw9Sr7i0DcLTae9d", 44 - domain, 45 - }); 46 - 47 - return true; 48 - }
+210
actions/domains/index.ts
··· 1 + "use server"; 2 + import { Database } from "supabase/database.types"; 3 + import { createServerClient } from "@supabase/ssr"; 4 + import { Vercel } from "@vercel/sdk"; 5 + import { getIdentityData } from "actions/getIdentityData"; 6 + 7 + let supabase = createServerClient<Database>( 8 + process.env.NEXT_PUBLIC_SUPABASE_API_URL as string, 9 + process.env.SUPABASE_SERVICE_ROLE_KEY as string, 10 + { cookies: {} }, 11 + ); 12 + 13 + const vercel = new Vercel({ 14 + bearerToken: process.env.VERCEL_TOKEN, 15 + }); 16 + 17 + const VERCEL_PROJECT = "prj_9jX4tmYCISnm176frFxk07fF74kG"; 18 + const VERCEL_TEAM = "team_42xaJiZMTw9Sr7i0DcLTae9d"; 19 + 20 + // Shared helpers 21 + // ============== 22 + 23 + async function assertOwnsDomain(domain: string) { 24 + let identity = await getIdentityData(); 25 + if (!identity || !identity.custom_domains.find((d) => d.domain === domain)) 26 + return null; 27 + return identity; 28 + } 29 + 30 + // Clear all assignments (routes + publication links) for a domain, 31 + // without deleting the domain itself. 32 + async function clearAllAssignments(domain: string) { 33 + await Promise.all([ 34 + supabase.from("custom_domain_routes").delete().eq("domain", domain), 35 + supabase.from("publication_domains").delete().eq("domain", domain), 36 + ]); 37 + } 38 + 39 + // Adding domains 40 + // ============== 41 + 42 + export async function addDomain(domain: string) { 43 + let identity = await getIdentityData(); 44 + if (!identity || (!identity.email && !identity.atp_did)) return {}; 45 + if ( 46 + domain.includes("leaflet.pub") && 47 + (!identity.email || 48 + ![ 49 + "celine@hyperlink.academy", 50 + "brendan@hyperlink.academy", 51 + "jared@hyperlink.academy", 52 + "brendan.schlagel@gmail.com", 53 + ].includes(identity.email)) 54 + ) 55 + return {}; 56 + return await createDomain(domain, identity.email, identity.id); 57 + } 58 + 59 + async function createDomain( 60 + domain: string, 61 + email: string | null, 62 + identity_id: string, 63 + ) { 64 + try { 65 + await vercel.projects.addProjectDomain({ 66 + idOrName: VERCEL_PROJECT, 67 + teamId: VERCEL_TEAM, 68 + requestBody: { name: domain }, 69 + }); 70 + } catch (e) { 71 + console.log(e); 72 + let error: "unknown-error" | "invalid_domain" | "domain_already_in_use" = 73 + "unknown-error"; 74 + if ((e as any).rawValue) { 75 + error = 76 + (e as { rawValue?: { error?: { code?: "invalid_domain" } } })?.rawValue 77 + ?.error?.code || "unknown-error"; 78 + } 79 + if ((e as any).body) { 80 + try { 81 + error = JSON.parse((e as any).body)?.error?.code || "unknown-error"; 82 + } catch (e) {} 83 + } 84 + return { error }; 85 + } 86 + 87 + await supabase.from("custom_domains").insert({ 88 + domain, 89 + identity: email, 90 + confirmed: false, 91 + identity_id, 92 + }); 93 + return {}; 94 + } 95 + 96 + // Assigning domains 97 + // ================= 98 + 99 + // Point a domain at a leaflet document. Clears any existing assignment first, 100 + // since a domain can only point to one thing at a time. 101 + export async function assignDomainToDocument({ 102 + domain, 103 + route, 104 + view_permission_token, 105 + edit_permission_token, 106 + }: { 107 + domain: string; 108 + route: string; 109 + view_permission_token: string; 110 + edit_permission_token: string; 111 + }) { 112 + if (!(await assertOwnsDomain(domain))) return null; 113 + 114 + await Promise.all([ 115 + supabase.from("publication_domains").delete().eq("domain", domain), 116 + supabase 117 + .from("custom_domain_routes") 118 + .delete() 119 + .eq("edit_permission_token", edit_permission_token), 120 + ]); 121 + 122 + await supabase.from("custom_domain_routes").insert({ 123 + domain, 124 + route, 125 + view_permission_token, 126 + edit_permission_token, 127 + }); 128 + 129 + return true; 130 + } 131 + 132 + // Point a domain at a publication. Clears any existing assignment first. 133 + export async function assignDomainToPublication({ 134 + domain, 135 + publication_uri, 136 + }: { 137 + domain: string; 138 + publication_uri: string; 139 + }) { 140 + let identity = await getIdentityData(); 141 + if (!identity || !identity.atp_did) return null; 142 + if (!identity.custom_domains.find((d) => d.domain === domain)) return null; 143 + 144 + let { data: publication } = await supabase 145 + .from("publications") 146 + .select("*") 147 + .eq("uri", publication_uri) 148 + .single(); 149 + if (publication?.identity_did !== identity.atp_did) return null; 150 + 151 + await clearAllAssignments(domain); 152 + 153 + await supabase.from("publication_domains").insert({ 154 + publication: publication_uri, 155 + identity: identity.atp_did, 156 + domain, 157 + }); 158 + 159 + return true; 160 + } 161 + 162 + // Removing assignments 163 + // ==================== 164 + 165 + // Remove all assignments from a domain (routes + publication links), 166 + // but keep the domain itself registered. 167 + export async function removeDomainAssignment({ 168 + domain, 169 + }: { 170 + domain: string; 171 + }) { 172 + if (!(await assertOwnsDomain(domain))) return null; 173 + await clearAllAssignments(domain); 174 + return true; 175 + } 176 + 177 + // Remove a single route assignment by ID. 178 + export async function removeDomainRoute({ routeId }: { routeId: string }) { 179 + let identity = await getIdentityData(); 180 + if (!identity) return null; 181 + 182 + let allRoutes = identity.custom_domains.flatMap( 183 + (d) => d.custom_domain_routes, 184 + ); 185 + if (!allRoutes.find((r) => r.id === routeId)) return null; 186 + 187 + await supabase.from("custom_domain_routes").delete().eq("id", routeId); 188 + 189 + return true; 190 + } 191 + 192 + // Deleting domains 193 + // ================ 194 + 195 + // Fully delete a domain: clear all assignments, remove from DB, and remove from Vercel. 196 + export async function deleteDomain({ domain }: { domain: string }) { 197 + if (!(await assertOwnsDomain(domain))) return null; 198 + 199 + await clearAllAssignments(domain); 200 + await Promise.all([ 201 + supabase.from("custom_domains").delete().eq("domain", domain), 202 + vercel.projects.removeProjectDomain({ 203 + idOrName: VERCEL_PROJECT, 204 + teamId: VERCEL_TEAM, 205 + domain, 206 + }), 207 + ]); 208 + 209 + return true; 210 + }
+1 -1
actions/getIdentityData.ts
··· 20 20 bsky_profiles(*), 21 21 notifications(count), 22 22 publication_subscriptions(*), 23 - custom_domains!custom_domains_identity_id_fkey(publication_domains(*), *), 23 + custom_domains!custom_domains_identity_id_fkey(publication_domains(*, publications(name)), custom_domain_routes(*), *), 24 24 home_leaflet:permission_tokens!identities_home_page_fkey(*, permission_token_rights(*, 25 25 entity_sets(entities(facts(*))) 26 26 )),
+4 -4
app/[leaflet_id]/actions/PublishButton.tsx
··· 188 188 onOpenChange={(o) => setOpen(o)} 189 189 side={isMobile ? "top" : "right"} 190 190 align={isMobile ? "center" : "start"} 191 - className="sm:max-w-sm w-[1000px]" 191 + className="sm:max-w-sm w-[1000px] p-0!" 192 192 trigger={ 193 193 <ActionButton 194 194 primary ··· 198 198 } 199 199 > 200 200 {!identity || !identity.atp_did ? ( 201 - <div className="-mx-2 -my-1"> 201 + <div className="p-1"> 202 202 <div 203 203 className={`bg-[var(--accent-light)] w-full rounded-md flex flex-col text-center justify-center p-2 pb-4 text-sm`} 204 204 > ··· 222 222 </div> 223 223 </div> 224 224 ) : ( 225 - <div className="flex flex-col"> 225 + <div className="publishToPubForm p-3 flex flex-col overflow-scroll max-h-full min-h-0"> 226 226 <PostDetailsForm 227 227 title={title} 228 228 description={description} ··· 313 313 setDescription: (d: string) => void; 314 314 }) => { 315 315 return ( 316 - <div className=" flex flex-col gap-1"> 316 + <div className="postDetailsContent flex flex-col gap-1 "> 317 317 <div className="text-sm text-tertiary">Post Details</div> 318 318 <div className="flex flex-col gap-2"> 319 319 <InputWithLabel label="Title" value={props.title} disabled />
+284 -321
app/[leaflet_id]/actions/ShareOptions/DomainOptions.tsx
··· 1 1 import { useState } from "react"; 2 2 import { ButtonPrimary } from "components/Buttons"; 3 - 4 - import { useSmoker, useToaster } from "components/Toast"; 5 - import { Input, InputWithLabel } from "components/Input"; 6 - import useSWR from "swr"; 7 - import { useIdentityData } from "components/IdentityProvider"; 8 - import { addDomain } from "actions/domains/addDomain"; 9 - import { callRPC } from "app/api/rpc/client"; 3 + import { useToaster } from "components/Toast"; 4 + import { Input } from "components/Input"; 5 + import { 6 + useIdentityData, 7 + mutateIdentityData, 8 + } from "components/IdentityProvider"; 9 + import { useDomainStatus } from "components/Domains/useDomainStatus"; 10 + import { CustomDomain } from "components/Domains/DomainList"; 10 11 import { useLeafletDomains } from "components/PageSWRDataProvider"; 11 12 import { useReadOnlyShareLink } from "."; 12 - import { addDomainPath } from "actions/domains/addDomainPath"; 13 + import { 14 + assignDomainToDocument, 15 + removeDomainRoute, 16 + } from "actions/domains"; 13 17 import { useReplicache } from "src/replicache"; 14 - import { deleteDomain } from "actions/domains/deleteDomain"; 15 - import { AddTiny } from "components/Icons/AddTiny"; 18 + import { AddDomainForm } from "components/Domains/AddDomainForm"; 19 + import { DomainSettingsView } from "components/Domains/DomainSettingsView"; 20 + import { DotLoader } from "components/utils/DotLoader"; 21 + import { GoToArrow } from "components/Icons/GoToArrow"; 22 + import { LoadingTiny } from "components/Icons/LoadingTiny"; 23 + import { UnlinkTiny } from "components/Icons/UnlinkTiny"; 24 + import Link from "next/link"; 16 25 17 26 type DomainMenuState = 18 - | { 19 - state: "default"; 20 - } 21 - | { 22 - state: "domain-settings"; 23 - domain: string; 24 - } 25 - | { 26 - state: "add-domain"; 27 - } 28 - | { 29 - state: "has-domain"; 30 - domain: string; 31 - }; 27 + | { state: "default" } 28 + | { state: "domain-settings"; domain: string } 29 + | { state: "add-domain" } 30 + | { state: "has-domain"; domain: string }; 31 + 32 32 export function CustomDomainMenu(props: { 33 33 setShareMenuState: (s: "default") => void; 34 34 }) { ··· 44 44 return ( 45 45 <DomainOptions 46 46 setDomainMenuState={setState} 47 - domainConnected={false} 48 47 setShareMenuState={props.setShareMenuState} 49 48 /> 50 49 ); 51 50 case "domain-settings": 52 51 return ( 53 - <DomainSettings domain={state.domain} setDomainMenuState={setState} /> 52 + <div className=""> 53 + <DomainSettingsView 54 + domain={state.domain} 55 + onBack={() => setState({ state: "default" })} 56 + onRemoveAssignment={() => setState({ state: "default" })} 57 + onDeleteDomain={() => setState({ state: "default" })} 58 + /> 59 + </div> 54 60 ); 55 61 case "add-domain": 56 - return <AddDomain setDomainMenuState={setState} />; 62 + return ( 63 + <AddDomainForm 64 + onDomainAdded={(domain) => 65 + setState({ state: "domain-settings", domain }) 66 + } 67 + onBack={() => setState({ state: "default" })} 68 + /> 69 + ); 57 70 } 58 71 } 59 72 60 - export const DomainOptions = (props: { 73 + const DomainOptions = (props: { 61 74 setShareMenuState: (s: "default") => void; 62 75 setDomainMenuState: (state: DomainMenuState) => void; 63 - domainConnected: boolean; 64 76 }) => { 65 77 let { data: domains, mutate: mutateDomains } = useLeafletDomains(); 66 78 let [selectedDomain, setSelectedDomain] = useState<string | undefined>( 67 - domains?.[0]?.domain, 79 + undefined, 68 80 ); 69 - let [selectedRoute, setSelectedRoute] = useState( 70 - domains?.[0]?.route.slice(1) || "", 71 - ); 72 - let { identity } = useIdentityData(); 81 + let [selectedRoute, setSelectedRoute] = useState(""); 82 + let { identity, mutate: mutateIdentity } = useIdentityData(); 73 83 let { permission_token } = useReplicache(); 74 84 85 + let [loading, setLoading] = useState(false); 75 86 let toaster = useToaster(); 76 - let smoker = useSmoker(); 77 87 let publishLink = useReadOnlyShareLink(); 78 88 79 - return ( 80 - <div className="px-3 py-1 flex flex-col gap-3 max-w-full w-[600px]"> 81 - <h3 className="text-secondary">Choose a Domain</h3> 82 - <div className="flex flex-col gap-1 text-secondary"> 83 - {identity?.custom_domains 84 - .filter((d) => !d.publication_domains.length) 85 - .map((domain) => { 86 - return ( 87 - <DomainOption 88 - selectedRoute={selectedRoute} 89 - setSelectedRoute={setSelectedRoute} 90 - key={domain.domain} 91 - domain={domain.domain} 92 - checked={selectedDomain === domain.domain} 93 - setChecked={setSelectedDomain} 94 - setDomainMenuState={props.setDomainMenuState} 95 - /> 96 - ); 97 - })} 98 - <button 99 - onMouseDown={() => { 100 - props.setDomainMenuState({ state: "add-domain" }); 101 - }} 102 - className="text-accent-contrast flex gap-2 items-center px-1 py-0.5" 103 - > 104 - <AddTiny /> Add a New Domain 105 - </button> 106 - </div> 89 + // Filter out domains assigned to publications 90 + let allDomains = (identity?.custom_domains || []).filter( 91 + (d: CustomDomain) => d.publication_domains.length === 0, 92 + ); 93 + 94 + // Categorize domains 95 + let linkedDomains = allDomains.filter((d: CustomDomain) => 96 + d.custom_domain_routes.some( 97 + (r) => r.edit_permission_token === permission_token.id, 98 + ), 99 + ); 100 + let pendingDomainsList: CustomDomain[] = []; 101 + let availableDomainsList: CustomDomain[] = []; 102 + 103 + // We'll categorize in the render since pending requires a hook per domain 104 + let nonLinkedDomains = allDomains.filter( 105 + (d: CustomDomain) => 106 + !d.custom_domain_routes.some( 107 + (r) => r.edit_permission_token === permission_token.id, 108 + ), 109 + ); 110 + 111 + // Check if the selected route is already assigned to a different leaflet 112 + let routeConflict = (() => { 113 + if (!selectedDomain) return false; 114 + let domain = allDomains.find( 115 + (d: CustomDomain) => d.domain === selectedDomain, 116 + ); 117 + if (!domain) return false; 118 + let route = "/" + selectedRoute; 119 + return domain.custom_domain_routes.some( 120 + (r) => 121 + r.route === route && r.edit_permission_token !== permission_token.id, 122 + ); 123 + })(); 124 + 125 + async function doPublish() { 126 + if (!selectedDomain || !publishLink) return; 127 + setLoading(true); 128 + let route = "/" + selectedRoute; 129 + 130 + mutateIdentityData(mutateIdentity, (draft) => { 131 + let domain = draft.custom_domains.find( 132 + (d) => d.domain === selectedDomain, 133 + ); 134 + if (domain) { 135 + domain.custom_domain_routes = [ 136 + ...domain.custom_domain_routes.filter( 137 + (r) => r.edit_permission_token !== permission_token.id, 138 + ), 139 + { 140 + id: crypto.randomUUID(), 141 + domain: selectedDomain, 142 + route, 143 + view_permission_token: publishLink, 144 + edit_permission_token: permission_token.id, 145 + created_at: new Date().toISOString(), 146 + }, 147 + ]; 148 + } 149 + }); 107 150 108 - {/* ONLY SHOW IF A DOMAIN IS CURRENTLY CONNECTED */} 109 - <div className="flex gap-3 items-center justify-end"> 110 - {props.domainConnected && ( 111 - <button 112 - onMouseDown={() => { 113 - props.setShareMenuState("default"); 114 - toaster({ 115 - content: ( 116 - <div className="font-bold"> 117 - Unpublished from custom domain! 118 - </div> 119 - ), 120 - type: "error", 121 - }); 122 - }} 151 + toaster({ 152 + content: ( 153 + <div className="font-bold"> 154 + Published to custom domain!{" "} 155 + <a 156 + className="underline text-accent-2" 157 + href={`https://${selectedDomain}/${selectedRoute}`} 158 + target="_blank" 123 159 > 124 - Unpublish 125 - </button> 126 - )} 160 + View 161 + </a> 162 + </div> 163 + ), 164 + type: "success", 165 + }); 166 + mutateDomains(); 167 + props.setShareMenuState("default"); 127 168 128 - <ButtonPrimary 129 - id="publish-to-domain" 130 - disabled={ 131 - domains?.[0] 132 - ? domains[0].domain === selectedDomain && 133 - domains[0].route.slice(1) === selectedRoute 134 - : !selectedDomain 135 - } 136 - onClick={async () => { 137 - // let rect = document 138 - // .getElementById("publish-to-domain") 139 - // ?.getBoundingClientRect(); 140 - // smoker({ 141 - // error: true, 142 - // text: "url already in use!", 143 - // position: { 144 - // x: rect ? rect.left : 0, 145 - // y: rect ? rect.top + 26 : 0, 146 - // }, 147 - // }); 148 - if (!selectedDomain || !publishLink) return; 149 - await addDomainPath({ 150 - domain: selectedDomain, 151 - route: "/" + selectedRoute, 152 - view_permission_token: publishLink, 153 - edit_permission_token: permission_token.id, 154 - }); 169 + await assignDomainToDocument({ 170 + domain: selectedDomain, 171 + route, 172 + view_permission_token: publishLink, 173 + edit_permission_token: permission_token.id, 174 + }); 175 + } 155 176 156 - toaster({ 157 - content: ( 158 - <div className="font-bold"> 159 - Published to custom domain!{" "} 160 - <a 161 - className="underline text-accent-2" 162 - href={`https://${selectedDomain}/${selectedRoute}`} 163 - target="_blank" 164 - > 165 - View 166 - </a> 167 - </div> 168 - ), 169 - type: "success", 170 - }); 171 - mutateDomains(); 172 - props.setShareMenuState("default"); 173 - }} 177 + return ( 178 + <div className="px-2 py-1 flex flex-col gap-2 max-w-full w-[600px]"> 179 + <div className="flex justify-between"> 180 + <h4>Choose a Domain</h4> 181 + <button 182 + className="text-accent-contrast rotate-180" 183 + onClick={() => props.setShareMenuState("default")} 174 184 > 175 - Publish! 176 - </ButtonPrimary> 185 + <GoToArrow /> 186 + </button> 177 187 </div> 178 - </div> 179 - ); 180 - }; 181 - 182 - const DomainOption = (props: { 183 - selectedRoute: string; 184 - setSelectedRoute: (s: string) => void; 185 - checked: boolean; 186 - setChecked: (checked: string) => void; 187 - domain: string; 188 - setDomainMenuState: (state: DomainMenuState) => void; 189 - }) => { 190 - let [value, setValue] = useState(""); 191 - let { data } = useSWR(props.domain, async (domain) => { 192 - return await callRPC("get_domain_status", { domain }); 193 - }); 194 - let pending = data?.config?.misconfigured || data?.error; 195 - return ( 196 - <label htmlFor={props.domain}> 197 - <input 198 - type="radio" 199 - name={props.domain} 200 - id={props.domain} 201 - value={props.domain} 202 - checked={props.checked} 203 - className="hidden appearance-none" 204 - onChange={() => { 205 - if (pending) return; 206 - props.setChecked(props.domain); 207 - }} 208 - /> 209 - <div 210 - className={` 211 - px-[6px] py-1 212 - flex 213 - border rounded-md 214 - ${ 215 - pending 216 - ? "border-border-light text-secondary justify-between gap-2 items-center " 217 - : !props.checked 218 - ? "flex-wrap border-border-light" 219 - : "flex-wrap border-accent-1 bg-accent-1 text-accent-2 font-bold" 220 - } `} 221 - > 222 - <div className={`w-max truncate ${pending && "animate-pulse"}`}> 223 - {props.domain} 224 - </div> 225 - {props.checked && ( 226 - <div className="flex gap-0 w-full"> 227 - <span 228 - className="font-normal" 229 - style={value === "" ? { opacity: "0.5" } : {}} 230 - > 231 - / 232 - </span> 233 - 234 - <Input 235 - type="text" 236 - autoFocus 237 - className="appearance-none focus:outline-hidden font-normal text-accent-2 w-full bg-transparent placeholder:text-accent-2 placeholder:opacity-50" 238 - placeholder="add-optional-path" 239 - onChange={(e) => props.setSelectedRoute(e.target.value)} 240 - value={props.selectedRoute} 241 - /> 188 + <hr className="border-border-light -mx-3" /> 189 + <div className="flex flex-col gap-1 text-secondary"> 190 + {linkedDomains.length > 0 && ( 191 + <div className="flex flex-col gap-0.5"> 192 + {linkedDomains.map((domain: CustomDomain) => ( 193 + <LinkedDomainOption 194 + key={domain.domain} 195 + domain={domain} 196 + permission_token_id={permission_token.id} 197 + onUnlink={async (routeId: string) => { 198 + mutateIdentityData(mutateIdentity, (draft) => { 199 + let d = draft.custom_domains.find( 200 + (dd) => dd.domain === domain.domain, 201 + ); 202 + if (d) { 203 + d.custom_domain_routes = d.custom_domain_routes.filter( 204 + (r) => r.id !== routeId, 205 + ); 206 + } 207 + }); 208 + mutateDomains(); 209 + toaster({ 210 + content: ( 211 + <div className="font-bold"> 212 + Unpublished from custom domain! 213 + </div> 214 + ), 215 + type: "success", 216 + }); 217 + await removeDomainRoute({ routeId }); 218 + }} 219 + /> 220 + ))} 242 221 </div> 243 222 )} 244 - {pending && ( 245 - <button 246 - className="text-accent-contrast text-sm" 247 - onMouseDown={() => { 248 - props.setDomainMenuState({ 249 - state: "domain-settings", 250 - domain: props.domain, 251 - }); 252 - }} 253 - > 254 - pending 255 - </button> 223 + {nonLinkedDomains.length > 0 && ( 224 + <NonLinkedDomains 225 + domains={nonLinkedDomains} 226 + selectedDomain={selectedDomain} 227 + setSelectedDomain={setSelectedDomain} 228 + selectedRoute={selectedRoute} 229 + setSelectedRoute={setSelectedRoute} 230 + setDomainMenuState={props.setDomainMenuState} 231 + /> 256 232 )} 257 - </div> 258 - </label> 259 - ); 260 - }; 261 - 262 - export const AddDomain = (props: { 263 - setDomainMenuState: (state: DomainMenuState) => void; 264 - }) => { 265 - let [value, setValue] = useState(""); 266 - let { mutate } = useIdentityData(); 267 - let smoker = useSmoker(); 268 - return ( 269 - <div className="flex flex-col gap-1 px-3 py-1 max-w-full w-[600px]"> 270 - <div> 271 - <h3 className="text-secondary">Add a New Domain</h3> 272 - <div className="text-xs italic text-secondary"> 273 - Don't include the protocol or path, just the base domain name for now 233 + <div className="text-sm text-tertiary leading-snug pt-1"> 234 + You can add or delete domains from profile settings on{" "} 235 + <Link href="/home" className="text-accent-contrast"> 236 + home 237 + </Link> 274 238 </div> 275 239 </div> 276 240 277 - <Input 278 - className="input-with-border text-primary" 279 - placeholder="www.example.com" 280 - value={value} 281 - onChange={(e) => setValue(e.target.value)} 282 - /> 283 - 241 + {routeConflict && ( 242 + <p className="text-error text-sm">Route already assigned</p> 243 + )} 284 244 <ButtonPrimary 285 - disabled={!value} 286 - className="place-self-end mt-2" 287 - onMouseDown={async (e) => { 288 - // call the vercel api, set the thing... 289 - let { error } = await addDomain(value); 290 - if (error) { 291 - smoker({ 292 - error: true, 293 - text: 294 - error === "invalid_domain" 295 - ? "Invalid domain! Use just the base domain" 296 - : error === "domain_already_in_use" 297 - ? "That domain is already in use!" 298 - : "An unknown error occured", 299 - position: { 300 - y: e.clientY, 301 - x: e.clientX - 5, 302 - }, 303 - }); 304 - return; 305 - } 306 - mutate(); 307 - props.setDomainMenuState({ state: "domain-settings", domain: value }); 308 - }} 245 + id="publish-to-domain" 246 + className="place-self-end" 247 + disabled={loading || routeConflict || !selectedDomain} 248 + onClick={doPublish} 309 249 > 310 - Verify Domain 250 + {loading ? <DotLoader /> : "Publish!"} 311 251 </ButtonPrimary> 312 252 </div> 313 253 ); 314 254 }; 315 255 316 - const DomainSettings = (props: { 317 - domain: string; 318 - setDomainMenuState: (s: DomainMenuState) => void; 256 + const LinkedDomainOption = (props: { 257 + domain: CustomDomain; 258 + permission_token_id: string; 259 + onUnlink: (routeId: string) => void; 319 260 }) => { 320 - let isSubdomain = props.domain.split(".").length > 2; 261 + let route = props.domain.custom_domain_routes.find( 262 + (r) => r.edit_permission_token === props.permission_token_id, 263 + ); 264 + if (!route) return null; 265 + 321 266 return ( 322 - <div className="flex flex-col gap-1 px-3 py-1 max-w-full w-[600px]"> 323 - <h3 className="text-secondary">Verify Domain</h3> 267 + <div className="text-sm flex justify-between px-1 py-0.5 bg-accent-1 text-accent-2 rounded-md"> 268 + <div className="font-bold w-full truncate"> 269 + {props.domain.domain} 270 + <span className="font-normal">{route.route}</span> 271 + </div> 272 + <button onClick={() => props.onUnlink(route.id)}> 273 + <UnlinkTiny /> 274 + </button> 275 + </div> 276 + ); 277 + }; 324 278 325 - <div className="text-secondary text-sm flex flex-col gap-3"> 326 - <div className="flex flex-col gap-[6px]"> 327 - <div> 328 - To verify this domain, add the following record to your DNS provider 329 - for <strong>{props.domain}</strong>. 330 - </div> 279 + function NonLinkedDomains(props: { 280 + domains: CustomDomain[]; 281 + selectedDomain: string | undefined; 282 + setSelectedDomain: (d: string) => void; 283 + selectedRoute: string; 284 + setSelectedRoute: (r: string) => void; 285 + setDomainMenuState: (state: DomainMenuState) => void; 286 + }) { 287 + // Separate pending vs available using hooks rendered per-domain 288 + return ( 289 + <> 290 + {props.domains.map((domain) => ( 291 + <NonLinkedDomainRow 292 + key={domain.domain} 293 + domain={domain} 294 + selectedDomain={props.selectedDomain} 295 + setSelectedDomain={props.setSelectedDomain} 296 + selectedRoute={props.selectedRoute} 297 + setSelectedRoute={props.setSelectedRoute} 298 + setDomainMenuState={props.setDomainMenuState} 299 + /> 300 + ))} 301 + </> 302 + ); 303 + } 331 304 332 - {isSubdomain ? ( 333 - <div className="flex gap-3 p-1 border border-border-light rounded-md py-1"> 334 - <div className="flex flex-col "> 335 - <div className="text-tertiary">Type</div> 336 - <div>CNAME</div> 337 - </div> 338 - <div className="flex flex-col"> 339 - <div className="text-tertiary">Name</div> 340 - <div style={{ wordBreak: "break-word" }}> 341 - {props.domain.split(".").slice(0, -2).join(".")} 342 - </div> 343 - </div> 344 - <div className="flex flex-col"> 345 - <div className="text-tertiary">Value</div> 346 - <div style={{ wordBreak: "break-word" }}> 347 - cname.vercel-dns.com 348 - </div> 349 - </div> 350 - </div> 351 - ) : ( 352 - <div className="flex gap-3 p-1 border border-border-light rounded-md py-1"> 353 - <div className="flex flex-col "> 354 - <div className="text-tertiary">Type</div> 355 - <div>A</div> 356 - </div> 357 - <div className="flex flex-col"> 358 - <div className="text-tertiary">Name</div> 359 - <div>@</div> 360 - </div> 361 - <div className="flex flex-col"> 362 - <div className="text-tertiary">Value</div> 363 - <div>76.76.21.21</div> 364 - </div> 365 - </div> 366 - )} 367 - </div> 368 - <div> 369 - Once you do this, the status may be pending for up to a few hours. 370 - </div> 371 - <div>Check back later to see if verification was successful.</div> 372 - </div> 305 + function NonLinkedDomainRow(props: { 306 + domain: CustomDomain; 307 + selectedDomain: string | undefined; 308 + setSelectedDomain: (d: string) => void; 309 + selectedRoute: string; 310 + setSelectedRoute: (r: string) => void; 311 + setDomainMenuState: (state: DomainMenuState) => void; 312 + }) { 313 + let { pending } = useDomainStatus(props.domain.domain); 373 314 374 - <div className="flex gap-3 justify-between items-center mt-2"> 315 + if (pending) { 316 + return ( 317 + <div className="text-sm flex px-1 py-0.5 border block-border !rounded-md text-secondary"> 318 + <div className="font-bold">{props.domain.domain}/</div> 319 + <Input 320 + disabled 321 + value="" 322 + className="w-full bg-transparent appearance-none focus:!outline-none" 323 + placeholder="" 324 + /> 375 325 <button 376 - className="text-accent-contrast font-bold " 377 - onMouseDown={async () => { 378 - await deleteDomain({ domain: props.domain }); 379 - props.setDomainMenuState({ state: "default" }); 380 - }} 326 + className="text-accent-contrast text-sm" 327 + onMouseDown={() => 328 + props.setDomainMenuState({ 329 + state: "domain-settings", 330 + domain: props.domain.domain, 331 + }) 332 + } 381 333 > 382 - Delete Domain 334 + <LoadingTiny className="animate-spin text-accent-contrast" /> 383 335 </button> 384 - <ButtonPrimary 385 - onMouseDown={() => { 386 - props.setDomainMenuState({ state: "default" }); 387 - }} 388 - > 389 - Back to Domains 390 - </ButtonPrimary> 391 336 </div> 337 + ); 338 + } 339 + 340 + let isSelected = props.selectedDomain === props.domain.domain; 341 + 342 + return ( 343 + <div 344 + className={`text-sm flex px-1 py-0.5 border block-border !rounded-md cursor-pointer ${isSelected ? "!outline-accent-contrast !border-accent-contrast" : ""}`} 345 + onClick={() => props.setSelectedDomain(props.domain.domain)} 346 + > 347 + <div className="font-bold">{props.domain.domain}/</div> 348 + <Input 349 + value={isSelected ? props.selectedRoute : ""} 350 + onChange={(e) => props.setSelectedRoute(e.currentTarget.value)} 351 + className="w-full bg-transparent appearance-none focus:!outline-none" 352 + placeholder={isSelected ? "optional path" : ""} 353 + onFocus={() => props.setSelectedDomain(props.domain.domain)} 354 + /> 392 355 </div> 393 356 ); 394 - }; 357 + }
+2 -2
app/lish/Subscribe.tsx
··· 262 262 <Dialog.Root open={open} onOpenChange={setOpen}> 263 263 <Dialog.Trigger asChild></Dialog.Trigger> 264 264 <Dialog.Portal> 265 - <Dialog.Overlay className="fixed z-[100] inset-0 bg-primary data-[state=open]:animate-overlayShow opacity-10 blur-xs" /> 265 + <Dialog.Overlay className="fixed z-50 inset-0 bg-primary data-[state=open]:animate-overlayShow opacity-10 blur-xs" /> 266 266 <Dialog.Content 267 267 className={` 268 - z-[100] opaque-container 268 + z-50 opaque-container 269 269 fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 270 270 w-96 px-3 py-4 271 271 max-w-(--radix-popover-content-available-width)
+9 -8
app/lish/[did]/[publication]/[rkey]/DocumentPageRenderer.tsx
··· 3 3 import { 4 4 PubLeafletBlocksBskyPost, 5 5 PubLeafletBlocksOrderedList, 6 - PubLeafletBlocksPoll, 7 6 PubLeafletBlocksUnorderedList, 8 7 PubLeafletPagesLinearDocument, 9 8 PubLeafletPagesCanvas, 9 + PubLeafletBlocksPoll, 10 10 } from "lexicons/api"; 11 11 import { type $Typed } from "lexicons/api/util"; 12 12 import { QuoteHandler } from "./QuoteHandler"; ··· 85 85 bskyPostBatches.map((batch) => 86 86 agent.getPosts( 87 87 { 88 - uris: batch.map((p) => p.block.postRef.uri), 88 + uris: batch.map((p) => { 89 + let block = p?.block as unknown as PubLeafletBlocksBskyPost.Main; 90 + return block.postRef.uri; 91 + }), 89 92 }, 90 93 { headers: {} }, 91 94 ), ··· 201 204 if (item.children) { 202 205 extractFromListItems(item.children, type, results); 203 206 } 204 - let orderedChildren = ( 205 - item as PubLeafletBlocksUnorderedList.ListItem 206 - ).orderedListChildren; 207 + let orderedChildren = (item as PubLeafletBlocksUnorderedList.ListItem) 208 + .orderedListChildren; 207 209 if (orderedChildren) { 208 210 extractFromListItems(orderedChildren.children, type, results); 209 211 } 210 - let unorderedChildren = ( 211 - item as PubLeafletBlocksOrderedList.ListItem 212 - ).unorderedListChildren; 212 + let unorderedChildren = (item as PubLeafletBlocksOrderedList.ListItem) 213 + .unorderedListChildren; 213 214 if (unorderedChildren) { 214 215 extractFromListItems(unorderedChildren.children, type, results); 215 216 }
+25 -1
app/lish/[did]/[publication]/dashboard/settings/PublicationSettings.tsx
··· 13 13 import { DotLoader } from "components/utils/DotLoader"; 14 14 import { ArrowRightTiny } from "components/Icons/ArrowRightTiny"; 15 15 import { PostOptions } from "./PostOptions"; 16 + import { PublicationDomains } from "components/Domains/PublicationDomains"; 17 + import { usePublicationData } from "../PublicationSWRProvider"; 16 18 17 - type menuState = "menu" | "pub-settings" | "theme" | "post-settings"; 19 + type menuState = "menu" | "pub-settings" | "theme" | "post-settings" | "domains"; 18 20 19 21 export function PublicationSettingsButton(props: { publication: string }) { 20 22 let isMobile = useIsMobile(); ··· 56 58 loading={loading} 57 59 setLoading={setLoading} 58 60 /> 61 + ) : state === "domains" ? ( 62 + <PublicationDomainsView backToMenu={() => setState("menu")} /> 59 63 ) : ( 60 64 <PubSettingsMenu 61 65 state={state} ··· 107 111 Theme and Layout 108 112 <ArrowRightTiny /> 109 113 </button> 114 + <button 115 + className={menuItemClassName} 116 + type="button" 117 + onClick={() => props.setState("domains")} 118 + > 119 + Domains 120 + <ArrowRightTiny /> 121 + </button> 110 122 </div> 111 123 ); 112 124 }; 125 + 126 + function PublicationDomainsView(props: { backToMenu: () => void }) { 127 + let { data } = usePublicationData(); 128 + let { publication: pubData } = data || {}; 129 + if (!pubData) return null; 130 + return ( 131 + <PublicationDomains 132 + backToMenu={props.backToMenu} 133 + publication_uri={pubData.uri} 134 + /> 135 + ); 136 + } 113 137 114 138 export const PubSettingsHeader = (props: { 115 139 backToMenuAction?: () => void;
+4 -335
app/lish/createPub/UpdatePubForm.tsx
··· 1 1 "use client"; 2 - import { callRPC } from "app/api/rpc/client"; 3 2 import { ButtonPrimary } from "components/Buttons"; 4 3 import { Input } from "components/Input"; 5 - import React, { useState, useRef, useEffect } from "react"; 6 - import { 7 - updatePublication, 8 - updatePublicationBasePath, 9 - } from "./updatePublication"; 4 + import { useState, useRef, useEffect } from "react"; 5 + import { updatePublication } from "./updatePublication"; 10 6 import { 11 7 usePublicationData, 12 8 useNormalizedPublicationRecord, 13 9 } from "../[did]/[publication]/dashboard/PublicationSWRProvider"; 14 - import useSWR, { mutate } from "swr"; 10 + import { mutate } from "swr"; 15 11 import { AddTiny } from "components/Icons/AddTiny"; 16 - import { DotLoader } from "components/utils/DotLoader"; 17 - import { useSmoker, useToaster } from "components/Toast"; 18 - import { addPublicationDomain } from "actions/domains/addDomain"; 19 - import { LoadingTiny } from "components/Icons/LoadingTiny"; 20 - import { PinTiny } from "components/Icons/PinTiny"; 21 - import { Verification } from "@vercel/sdk/esm/models/getprojectdomainop"; 22 - import Link from "next/link"; 23 - import { Checkbox } from "components/Checkbox"; 24 - import type { GetDomainConfigResponseBody } from "@vercel/sdk/esm/models/getdomainconfigop"; 12 + import { useToaster } from "components/Toast"; 25 13 import { PubSettingsHeader } from "../[did]/[publication]/dashboard/settings/PublicationSettings"; 26 14 import { Toggle } from "components/Toggle"; 27 15 ··· 172 160 /> 173 161 </label> 174 162 175 - <CustomDomainForm /> 176 - <hr className="border-border-light" /> 177 - 178 163 <Toggle 179 164 toggle={showInDiscover} 180 165 onToggle={() => setShowInDiscover(!showInDiscover)} ··· 195 180 ); 196 181 }; 197 182 198 - export function CustomDomainForm() { 199 - let { data } = usePublicationData(); 200 - let { publication: pubData } = data || {}; 201 - let record = useNormalizedPublicationRecord(); 202 - if (!pubData) return null; 203 - if (!record) return null; 204 - let [state, setState] = useState< 205 - | { type: "default" } 206 - | { type: "addDomain" } 207 - | { 208 - type: "domainSettings"; 209 - domain: string; 210 - verification?: Verification[]; 211 - config?: GetDomainConfigResponseBody; 212 - } 213 - >({ type: "default" }); 214 - let domains = pubData?.publication_domains || []; 215 - 216 - return ( 217 - <div className="flex flex-col gap-0.5"> 218 - <p className="text-tertiary italic text-sm font-bold"> 219 - Publication Domain{domains.length > 1 && "s"} 220 - </p> 221 - 222 - <div className="opaque-container px-[6px] py-1"> 223 - {state.type === "addDomain" ? ( 224 - <AddDomain 225 - publication_uri={pubData.uri} 226 - goBack={() => setState({ type: "default" })} 227 - setDomain={(d) => setState({ type: "domainSettings", domain: d })} 228 - /> 229 - ) : state.type === "domainSettings" ? ( 230 - <DomainSettings 231 - verification={state.verification} 232 - config={state.config} 233 - domain={state.domain} 234 - goBack={() => setState({ type: "default" })} 235 - /> 236 - ) : ( 237 - <div className="flex flex-col gap-1 py-1"> 238 - {domains.map((d) => ( 239 - <React.Fragment key={d.domain}> 240 - <Domain 241 - domain={d.domain} 242 - publication_uri={pubData.uri} 243 - base_path={record.url.replace(/^https?:\/\//, "")} 244 - setDomain={(v) => { 245 - setState({ 246 - type: "domainSettings", 247 - domain: d.domain, 248 - verification: v?.verification, 249 - config: v?.config, 250 - }); 251 - }} 252 - /> 253 - <hr className="border-border-light last:hidden" /> 254 - </React.Fragment> 255 - ))} 256 - <button 257 - className="text-accent-contrast text-sm w-fit " 258 - onClick={() => setState({ type: "addDomain" })} 259 - type="button" 260 - > 261 - Add custom domain 262 - </button> 263 - </div> 264 - )} 265 - </div> 266 - </div> 267 - ); 268 - } 269 - 270 - function AddDomain(props: { 271 - publication_uri: string; 272 - goBack: () => void; 273 - setDomain: (d: string) => void; 274 - }) { 275 - let [domain, setDomain] = useState(""); 276 - let smoker = useSmoker(); 277 - 278 - return ( 279 - <div className="w-full flex flex-col gap-0.5 py-1"> 280 - <label> 281 - <p className="pl-0.5 text-tertiary italic text-sm"> 282 - Add a Custom Domain 283 - </p> 284 - <Input 285 - className="w-full input-with-border" 286 - placeholder="domain" 287 - value={domain} 288 - onChange={(e) => setDomain(e.currentTarget.value)} 289 - /> 290 - </label> 291 - <div className="flex flex-row justify-between text-sm pt-2"> 292 - <button className="text-accent-contrast" onClick={() => props.goBack()}> 293 - Back 294 - </button> 295 - <button 296 - className="place-self-end font-bold text-accent-contrast text-sm" 297 - onClick={async (e) => { 298 - let { error } = await addPublicationDomain( 299 - domain, 300 - props.publication_uri, 301 - ); 302 - if (error) { 303 - smoker({ 304 - error: true, 305 - text: 306 - error === "invalid_domain" 307 - ? "Invalid domain! Use just the base domain" 308 - : error === "domain_already_in_use" 309 - ? "That domain is already in use!" 310 - : "An unknown error occured", 311 - position: { 312 - y: e.clientY, 313 - x: e.clientX - 5, 314 - }, 315 - }); 316 - } 317 - 318 - mutate("publication-data"); 319 - props.setDomain(domain); 320 - }} 321 - type="button" 322 - > 323 - Add Domain 324 - </button> 325 - </div> 326 - </div> 327 - ); 328 - } 329 - 330 - // OKay so... You hit this button, it gives you a form. You type in the form, and then hit add. We create a record, and a the record link it to your publiction. Then we show you the stuff to set. ) 331 - // We don't want to switch it, until it works. 332 - // There's a checkbox to say that this is hosted somewhere else 333 - 334 - function Domain(props: { 335 - domain: string; 336 - base_path: string; 337 - publication_uri: string; 338 - setDomain: (domain?: { 339 - verification?: Verification[]; 340 - config?: GetDomainConfigResponseBody; 341 - }) => void; 342 - }) { 343 - let { data } = useSWR(props.domain, async (domain) => { 344 - return await callRPC("get_domain_status", { domain }); 345 - }); 346 - 347 - let pending = data?.config?.misconfigured || data?.verification; 348 - 349 - return ( 350 - <div className="text-sm text-secondary relative w-full "> 351 - <div className="pr-8 truncate">{props.domain}</div> 352 - <div className="absolute right-0 top-0 bottom-0 flex justify-end items-center w-4 "> 353 - {pending ? ( 354 - <button 355 - className="group/pending px-1 py-0.5 flex gap-1 items-center rounded-full hover:bg-accent-1 hover:text-accent-2 hover:outline-accent-1 border-transparent outline-solid outline-transparent selected-outline" 356 - onClick={() => { 357 - props.setDomain(data); 358 - }} 359 - > 360 - <p className="group-hover/pending:block hidden w-max pl-1 font-bold"> 361 - pending 362 - </p> 363 - <LoadingTiny className="animate-spin text-accent-contrast group-hover/pending:text-accent-2 " /> 364 - </button> 365 - ) : props.base_path === props.domain ? ( 366 - <div className="group/default-domain flex gap-1 items-center rounded-full bg-none w-max px-1 py-0.5 hover:bg-bg-page border border-transparent hover:border-border-light "> 367 - <p className="group-hover/default-domain:block hidden w-max pl-1"> 368 - current default domain 369 - </p> 370 - <PinTiny className="text-accent-contrast shrink-0" /> 371 - </div> 372 - ) : ( 373 - <button 374 - type="button" 375 - onClick={async () => { 376 - await updatePublicationBasePath({ 377 - uri: props.publication_uri, 378 - base_path: props.domain, 379 - }); 380 - mutate("publication-data"); 381 - }} 382 - className="group/domain flex gap-1 items-center rounded-full bg-none w-max font-bold px-1 py-0.5 hover:bg-accent-1 hover:text-accent-2 border-transparent outline-solid outline-transparent hover:outline-accent-1 selected-outline" 383 - > 384 - <p className="group-hover/domain:block hidden w-max pl-1"> 385 - set as default 386 - </p> 387 - <PinTiny className="text-secondary group-hover/domain:text-accent-2 shrink-0" /> 388 - </button> 389 - )} 390 - </div> 391 - </div> 392 - ); 393 - } 394 - 395 - const DomainSettings = (props: { 396 - domain: string; 397 - config?: GetDomainConfigResponseBody; 398 - goBack: () => void; 399 - verification?: Verification[]; 400 - }) => { 401 - let { data, mutate } = useSWR(props.domain, async (domain) => { 402 - return await callRPC("get_domain_status", { domain }); 403 - }); 404 - let isSubdomain = props.domain.split(".").length > 2; 405 - if (!data) return; 406 - let { config, verification } = data; 407 - if (!config?.misconfigured && !verification) 408 - return <div>This domain is verified!</div>; 409 - return ( 410 - <div className="flex flex-col gap-[6px] text-sm text-primary"> 411 - <div> 412 - To verify this domain, add the following record to your DNS provider for{" "} 413 - <strong>{props.domain}</strong>. 414 - </div> 415 - <table className="border border-border-light rounded-md"> 416 - <thead> 417 - <tr> 418 - <th className="p-1 py-1 text-tertiary">Type</th> 419 - <th className="p-1 py-1 text-tertiary">Name</th> 420 - <th className="p-1 py-1 text-tertiary">Value</th> 421 - </tr> 422 - </thead> 423 - <tbody> 424 - {verification && ( 425 - <tr> 426 - <td className="p-1 py-1"> 427 - <div>{verification[0].type}</div> 428 - </td> 429 - <td className="p-1 py-1"> 430 - <div style={{ wordBreak: "break-word" }}> 431 - {verification[0].domain} 432 - </div> 433 - </td> 434 - <td className="p-1 py-1"> 435 - <div style={{ wordBreak: "break-word" }}> 436 - {verification?.[0].value} 437 - </div> 438 - </td> 439 - </tr> 440 - )} 441 - {config && 442 - (isSubdomain ? ( 443 - <tr> 444 - <td className="p-1 py-1"> 445 - <div>CNAME</div> 446 - </td> 447 - <td className="p-1 py-1"> 448 - <div style={{ wordBreak: "break-word" }}> 449 - {props.domain.split(".").slice(0, -2).join(".")} 450 - </div> 451 - </td> 452 - <td className="p-1 py-1"> 453 - <div style={{ wordBreak: "break-word" }}> 454 - { 455 - config?.recommendedCNAME.sort( 456 - (a, b) => a.rank - b.rank, 457 - )[0].value 458 - } 459 - </div> 460 - </td> 461 - </tr> 462 - ) : ( 463 - <tr> 464 - <td className="p-1 py-1"> 465 - <div>A</div> 466 - </td> 467 - <td className="p-1 py-1"> 468 - <div style={{ wordBreak: "break-word" }}>@</div> 469 - </td> 470 - <td className="p-1 py-1"> 471 - <div style={{ wordBreak: "break-word" }}> 472 - { 473 - config?.recommendedIPv4.sort((a, b) => a.rank - b.rank)[0] 474 - .value[0] 475 - } 476 - </div> 477 - </td> 478 - </tr> 479 - ))} 480 - {config?.configuredBy === "CNAME" && config.recommendedCNAME[0] && ( 481 - <tr></tr> 482 - )} 483 - </tbody> 484 - </table> 485 - <div className="flex flex-row justify-between"> 486 - <button 487 - className="text-accent-contrast w-fit" 488 - onClick={() => props.goBack()} 489 - > 490 - Back 491 - </button> 492 - <VerifyButton verify={() => mutate()} /> 493 - </div> 494 - </div> 495 - ); 496 - }; 497 - 498 - const VerifyButton = (props: { verify: () => Promise<any> }) => { 499 - let [loading, setLoading] = useState(false); 500 - return ( 501 - <button 502 - className="text-accent-contrast w-fit" 503 - onClick={async (e) => { 504 - e.preventDefault(); 505 - setLoading(true); 506 - await props.verify(); 507 - setLoading(false); 508 - }} 509 - > 510 - {loading ? <DotLoader /> : "verify"} 511 - </button> 512 - ); 513 - };
+1 -1
app/lish/createPub/page.tsx
··· 26 26 <div className="createPubContent h-full flex items-center max-w-sm w-full mx-auto"> 27 27 <div className="createPubFormWrapper h-fit w-full flex flex-col gap-4"> 28 28 <h2 className="text-center">Create Your Publication!</h2> 29 - <div className="opaque-container w-full sm:py-4 p-3"> 29 + <div className="opaque-container rounded-lg! w-full sm:py-4 p-3"> 30 30 <CreatePubForm /> 31 31 </div> 32 32 </div>
+6 -1
components/ActionBar/ProfileButton.tsx
··· 8 8 import { mutate } from "swr"; 9 9 import { SpeedyLink } from "components/SpeedyLink"; 10 10 import { Popover } from "components/Popover"; 11 - import { ArrowRightTiny } from "components/Icons/ArrowRightTiny"; 12 11 import { Modal } from "components/Modal"; 13 12 import { InlineUpgrade } from "app/lish/[did]/[publication]/UpgradeModal"; 14 13 import { ManageProSubscription } from "app/lish/[did]/[publication]/dashboard/settings/ManageProSubscription"; 14 + import { ManageDomains } from "components/Domains/ManageDomains"; 15 + import { WebSmall } from "components/Icons/WebSmall"; 15 16 import { useIsPro, useCanSeePro } from "src/hooks/useEntitlement"; 16 17 import { useState } from "react"; 17 18 import { LeafletPro } from "components/Icons/LeafletPro"; ··· 62 63 </SpeedyLink> 63 64 </> 64 65 )} 66 + 67 + <ManageDomains /> 68 + <hr className="border-border-light border-dashed" /> 69 + 65 70 {canSeePro && isPro && ( 66 71 <> 67 72 <Modal
+3 -3
components/Buttons.tsx
··· 37 37 ${fullWidth ? "w-full" : fullWidthOnMobile ? "w-full sm:w-max" : "w-max"} 38 38 ${compact ? "py-0 px-1" : "px-2 py-0.5 "} 39 39 bg-accent-1 disabled:bg-border-light 40 - border border-accent-1 rounded-md disabled:border-border-light 40 + border border-accent-1 rounded-md disabled:border-border-light disabled:outline-none! disabled:cursor-not-allowed! 41 41 outline-2 outline-transparent outline-offset-1 focus:outline-accent-1 hover:outline-accent-1 42 42 text-base font-bold text-accent-2 disabled:text-border disabled:hover:text-border 43 43 flex gap-2 items-center justify-center shrink-0 ··· 77 77 ${compact ? "py-0 px-1" : "px-2 py-0.5 "} 78 78 bg-bg-page disabled:bg-border-light 79 79 border border-accent-contrast rounded-md 80 - outline-2 outline-transparent focus:outline-accent-contrast hover:outline-accent-contrast outline-offset-1 80 + outline-2 outline-transparent focus:outline-accent-contrast hover:outline-accent-contrast outline-offset-1 disabled:outline-none! disabled:cursor-not-allowed! 81 81 text-base font-bold text-accent-contrast disabled:text-border disabled:hover:text-border 82 82 flex gap-2 items-center justify-center shrink-0 83 83 ${props.className} ··· 116 116 ${compact ? "py-0 px-1" : "px-2 py-0.5 "} 117 117 bg-transparent hover:bg-[var(--accent-light)] 118 118 border border-transparent rounded-md hover:border-[var(--accent-light)] 119 - outline-2 outline-transparent focus:outline-[var(--accent-light)] hover:outline-[var(--accent-light)] outline-offset-1 119 + outline-2 outline-transparent focus:outline-[var(--accent-light)] hover:outline-[var(--accent-light)] outline-offset-1 disabled:outline-none! disabled:cursor-not-allowed! 120 120 text-base font-bold text-accent-contrast disabled:text-border 121 121 flex gap-2 items-center justify-center shrink-0 122 122 ${props.className}
+87
components/Domains/AddDomainForm.tsx
··· 1 + "use client"; 2 + import { useState } from "react"; 3 + import { ButtonPrimary } from "components/Buttons"; 4 + import { Input } from "components/Input"; 5 + import { useSmoker } from "components/Toast"; 6 + import { useIdentityData } from "components/IdentityProvider"; 7 + import { addDomain } from "actions/domains"; 8 + import { DotLoader } from "components/utils/DotLoader"; 9 + import { GoToArrow } from "components/Icons/GoToArrow"; 10 + 11 + export function AddDomainForm(props: { 12 + onDomainAdded: (domain: string) => void; 13 + onBack: () => void; 14 + }) { 15 + let [value, setValue] = useState(""); 16 + let [loading, setLoading] = useState(false); 17 + let { mutate } = useIdentityData(); 18 + let smoker = useSmoker(); 19 + let rect = document 20 + .getElementById("add-domain-button") 21 + ?.getBoundingClientRect(); 22 + 23 + return ( 24 + <form 25 + className="flex flex-col gap-1 max-w-full" 26 + onSubmit={async (e) => { 27 + e.preventDefault(); 28 + setLoading(true); 29 + let { error } = await addDomain(value); 30 + if (error) { 31 + setLoading(false); 32 + smoker({ 33 + error: true, 34 + text: 35 + error === "invalid_domain" 36 + ? "Invalid domain! Use just the base domain" 37 + : error === "domain_already_in_use" 38 + ? "That domain is already in use!" 39 + : "An unknown error occured", 40 + position: { 41 + y: rect ? rect?.right - rect?.width / 2 : 0, 42 + x: rect ? rect?.bottom - rect?.height / 2 : 0, 43 + }, 44 + }); 45 + return; 46 + } 47 + mutate(); 48 + props.onDomainAdded(value); 49 + }} 50 + > 51 + <div className="flex justify-between"> 52 + <h3>Add a Domain</h3> 53 + <button 54 + className="text-accent-contrast" 55 + onMouseDown={() => props.onBack()} 56 + type="button" 57 + > 58 + <GoToArrow className="rotate-180" /> 59 + </button> 60 + </div> 61 + <div className="text-sm text-secondary"> 62 + <div className="font-bold">Just include the base domain</div> 63 + Don't include the protocol{" "} 64 + <span className="text-tertiary">(like https://) </span> 65 + or path <span className="text-tertiary">(you can add that later)</span> 66 + </div> 67 + 68 + <Input 69 + className="input-with-border text-primary" 70 + autoFocus 71 + placeholder="www.example.com" 72 + value={value} 73 + onChange={(e) => setValue(e.target.value)} 74 + /> 75 + 76 + <div className="flex justify-end items-center mt-2"> 77 + <ButtonPrimary 78 + id="add-domain-button" 79 + type="submit" 80 + disabled={!value || loading} 81 + > 82 + {loading ? <DotLoader /> : "Add Domain"} 83 + </ButtonPrimary> 84 + </div> 85 + </form> 86 + ); 87 + }
+152
components/Domains/DomainList.tsx
··· 1 + "use client"; 2 + import { useIdentityData } from "components/IdentityProvider"; 3 + import { useDomainStatus } from "./useDomainStatus"; 4 + import { getDomainAssignment } from "./domainAssignment"; 5 + import { Identity } from "components/IdentityProvider"; 6 + import { GoToArrow } from "components/Icons/GoToArrow"; 7 + 8 + export type CustomDomain = NonNullable<Identity>["custom_domains"][number]; 9 + 10 + export function DomainList(props: { 11 + onSelectDomain: (domain: string) => void; 12 + filter?: (domain: CustomDomain) => boolean; 13 + }) { 14 + let { identity } = useIdentityData(); 15 + let domains = identity?.custom_domains || []; 16 + if (props.filter) domains = domains.filter(props.filter); 17 + 18 + let pubDomains = domains.filter( 19 + (d) => getDomainAssignment(d).type === "publication", 20 + ); 21 + let leafletDomains = domains.filter( 22 + (d) => getDomainAssignment(d).type === "document", 23 + ); 24 + let unassignedDomains = domains.filter( 25 + (d) => getDomainAssignment(d).type === "unassigned", 26 + ); 27 + 28 + return ( 29 + <div className="flex flex-col gap-2 text-secondary"> 30 + {pubDomains.length > 0 && ( 31 + <div className="flex flex-col gap-0.5"> 32 + <div className="font-bold text-secondary">Publications</div> 33 + {pubDomains.map((domain) => ( 34 + <DomainGroup 35 + key={domain.domain} 36 + domain={domain} 37 + onSelect={() => props.onSelectDomain(domain.domain)} 38 + showBase={false} 39 + /> 40 + ))} 41 + </div> 42 + )} 43 + {pubDomains.length > 0 && leafletDomains.length > 0 && ( 44 + <hr className="border-border-light" /> 45 + )} 46 + {leafletDomains.length > 0 && ( 47 + <div className="flex flex-col gap-0.5"> 48 + <div className="font-bold text-secondary">Leaflets</div> 49 + {leafletDomains.map((domain) => ( 50 + <DomainGroup 51 + key={domain.domain} 52 + domain={domain} 53 + onSelect={() => props.onSelectDomain(domain.domain)} 54 + showBase 55 + /> 56 + ))} 57 + </div> 58 + )} 59 + {unassignedDomains.length > 0 && ( 60 + <> 61 + {(pubDomains.length > 0 || leafletDomains.length > 0) && ( 62 + <hr className="border-border-light my-1" /> 63 + )} 64 + <div className="flex flex-col gap-0.5"> 65 + <div className="font-bold text-secondary">Unassigned</div> 66 + {unassignedDomains.map((domain) => ( 67 + <UnassignedDomainRow 68 + key={domain.domain} 69 + domain={domain} 70 + onSelect={() => props.onSelectDomain(domain.domain)} 71 + /> 72 + ))} 73 + </div> 74 + </> 75 + )} 76 + </div> 77 + ); 78 + } 79 + 80 + function DomainGroup(props: { 81 + domain: CustomDomain; 82 + onSelect: () => void; 83 + showBase: boolean; 84 + }) { 85 + let assignment = getDomainAssignment(props.domain); 86 + 87 + return ( 88 + <div className="flex flex-col gap-0.5"> 89 + {props.showBase && ( 90 + <button 91 + type="button" 92 + className="text-secondary font-normal! text-left flex gap-2 menuItem -mx-[8px] items-center py-0.5!" 93 + onClick={props.onSelect} 94 + > 95 + <div className="grow truncate min-w-0">{props.domain.domain}</div> 96 + <div className="text-sm text-tertiary font-normal shrink-0"> 97 + {props.domain.custom_domain_routes.length} leaflet 98 + {props.domain.custom_domain_routes.length === 1 ? "" : "s"} 99 + </div> 100 + <GoToArrow className="shrink-0" /> 101 + </button> 102 + )} 103 + {assignment.type === "publication" && ( 104 + <SubDomainRow 105 + path="/" 106 + label={props.domain.domain} 107 + onSelect={props.onSelect} 108 + /> 109 + )} 110 + </div> 111 + ); 112 + } 113 + 114 + function SubDomainRow(props: { 115 + path: string; 116 + label: string; 117 + onSelect: () => void; 118 + }) { 119 + return ( 120 + <button 121 + type="button" 122 + className="text-secondary font-normal! text-left flex gap-2 menuItem -mx-[8px] items-center py-0.5!" 123 + onClick={props.onSelect} 124 + > 125 + <div className="grow flex gap-2 items-center justify-between w-full truncate"> 126 + <div className="grow truncate">{props.label}</div> 127 + <GoToArrow /> 128 + </div> 129 + </button> 130 + ); 131 + } 132 + 133 + function UnassignedDomainRow(props: { 134 + domain: CustomDomain; 135 + onSelect: () => void; 136 + }) { 137 + let { pending } = useDomainStatus(props.domain.domain); 138 + 139 + return ( 140 + <button 141 + type="button" 142 + className="py-0! flex gap-2 font-normal! items-center text-left menuItem -mx-[8px]" 143 + onClick={props.onSelect} 144 + > 145 + <div className="grow truncate min-w-0">{props.domain.domain}</div> 146 + {pending && ( 147 + <div className="text-sm text-tertiary animate-pulse">unverified</div> 148 + )} 149 + <GoToArrow /> 150 + </button> 151 + ); 152 + }
+426
components/Domains/DomainSettingsView.tsx
··· 1 + "use client"; 2 + import { useState } from "react"; 3 + import { useDomainStatus } from "./useDomainStatus"; 4 + import { DotLoader } from "components/utils/DotLoader"; 5 + import { 6 + deleteDomain, 7 + removeDomainAssignment, 8 + removeDomainRoute, 9 + } from "actions/domains"; 10 + import { 11 + useIdentityData, 12 + mutateIdentityData, 13 + } from "components/IdentityProvider"; 14 + import { ButtonPrimary, ButtonTertiary } from "components/Buttons"; 15 + import { getDomainAssignment } from "./domainAssignment"; 16 + import { GoToArrow } from "components/Icons/GoToArrow"; 17 + import { useSmoker } from "components/Toast"; 18 + import type { CustomDomain } from "./DomainList"; 19 + import { UnlinkTiny } from "components/Icons/UnlinkTiny"; 20 + 21 + export function DomainSettingsView(props: { 22 + domain: string; 23 + onBack: () => void; 24 + onRemoveAssignment?: () => void; 25 + onDeleteDomain?: () => void; 26 + }) { 27 + let { 28 + data, 29 + pending: needsSetup, 30 + mutate: mutateDomainStatus, 31 + } = useDomainStatus(props.domain); 32 + let { identity, mutate: mutateIdentity } = useIdentityData(); 33 + let isSubdomain = props.domain.split(".").length > 2; 34 + 35 + let domainData = identity?.custom_domains.find( 36 + (d) => d.domain === props.domain, 37 + ); 38 + let assignment = domainData ? getDomainAssignment(domainData) : null; 39 + let isAssigned = assignment && assignment.type !== "unassigned"; 40 + 41 + return ( 42 + <div className="flex flex-col gap-[6px] text-sm text-primary max-w-full"> 43 + <div className="w-full flex gap-2 items-center"> 44 + <h3 className="w-full grow min-w-0 truncate"> 45 + {needsSetup ? `Verify ${props.domain}` : props.domain} 46 + </h3> 47 + <button 48 + className="text-accent-contrast rotate-180 shrink-0" 49 + onMouseDown={() => props.onBack()} 50 + type="button" 51 + > 52 + <GoToArrow /> 53 + </button> 54 + </div> 55 + 56 + {needsSetup ? ( 57 + <> 58 + <div> 59 + To verify this domain, add the following record to your DNS provider 60 + for <strong>{props.domain}</strong>. 61 + </div> 62 + <div>Verfication may take up to a few hours to process.</div> 63 + <table className="border border-border-light rounded-md text-left mt-2"> 64 + <thead> 65 + <tr> 66 + <th className="px-2 pt-1 text-tertiary">Type</th> 67 + <th className="px-2 pt-1 text-tertiary">Name</th> 68 + <th className="px-2 pt-1 text-tertiary">Value</th> 69 + </tr> 70 + </thead> 71 + <tbody> 72 + {data?.verification && ( 73 + <tr> 74 + <td className="px-2 pb-1"> 75 + <div>{data.verification[0].type}</div> 76 + </td> 77 + <td className="px-2 pb-1"> 78 + <div style={{ wordBreak: "break-word" }}> 79 + {data.verification[0].domain} 80 + </div> 81 + </td> 82 + <td className="px-2 pb-1"> 83 + <div style={{ wordBreak: "break-word" }}> 84 + {data.verification[0].value} 85 + </div> 86 + </td> 87 + </tr> 88 + )} 89 + {data?.config && 90 + (isSubdomain ? ( 91 + <tr> 92 + <td className="px-2 pb-1"> 93 + <div>CNAME</div> 94 + </td> 95 + <td className="px-2 pb-1"> 96 + <div style={{ wordBreak: "break-word" }}> 97 + {props.domain.split(".").slice(0, -2).join(".")} 98 + </div> 99 + </td> 100 + <td className="px-2 pb-1"> 101 + <div style={{ wordBreak: "break-word" }}> 102 + { 103 + data.config.recommendedCNAME.sort( 104 + (a, b) => a.rank - b.rank, 105 + )[0].value 106 + } 107 + </div> 108 + </td> 109 + </tr> 110 + ) : ( 111 + <tr> 112 + <td className="px-2 pb-1"> 113 + <div>A</div> 114 + </td> 115 + <td className="px-2 pb-1"> 116 + <div style={{ wordBreak: "break-word" }}>@</div> 117 + </td> 118 + <td className="px-2 pb-1"> 119 + <div style={{ wordBreak: "break-word" }}> 120 + { 121 + data.config.recommendedIPv4.sort( 122 + (a, b) => a.rank - b.rank, 123 + )[0].value[0] 124 + } 125 + </div> 126 + </td> 127 + </tr> 128 + ))} 129 + </tbody> 130 + </table> 131 + <VerifyButton verify={() => mutateDomainStatus()} /> 132 + </> 133 + ) : null} 134 + {/* Assignment list */} 135 + {!isAssigned && ( 136 + <div className="text-secondary"> 137 + <div className="font-bold"> 138 + You haven't assigned this domain to anything yet! 139 + </div> 140 + You can assign it to either a publication from your pub settings, or 141 + to any leaflet! 142 + </div> 143 + )} 144 + {props.onRemoveAssignment && isAssigned && domainData && ( 145 + <div className="flex flex-col gap-1 mt-1"> 146 + <h4 className="text-tertiary text-sm"> 147 + Assigned{" "} 148 + {domainData.publication_domains.length > 0 149 + ? "Publications" 150 + : "Leaflets"} 151 + </h4> 152 + {domainData.publication_domains.length > 0 ? ( 153 + <AssignedPublication 154 + domainData={domainData} 155 + domain={props.domain} 156 + onRemoveAssignment={props.onRemoveAssignment} 157 + mutateIdentity={mutateIdentity} 158 + isLastAssignment 159 + /> 160 + ) : ( 161 + domainData.custom_domain_routes.map((route) => ( 162 + <AssignedLeaflet 163 + key={route.id} 164 + route={route} 165 + domain={props.domain} 166 + onRemoveAssignment={props.onRemoveAssignment} 167 + mutateIdentity={mutateIdentity} 168 + isLastAssignment={domainData.custom_domain_routes.length === 1} 169 + /> 170 + )) 171 + )} 172 + </div> 173 + )} 174 + <div className="flex flex-col gap-2 mt-2"> 175 + <hr className="border-border-light" /> 176 + 177 + <DeleteDomainButton 178 + domain={props.domain} 179 + onDeleteDomain={props.onDeleteDomain} 180 + mutateIdentity={mutateIdentity} 181 + /> 182 + </div> 183 + </div> 184 + ); 185 + } 186 + let containerClassName = 187 + "flex items-center justify-between px-[6px] py-1 border rounded-md border-border-light"; 188 + 189 + const AssignedPublication = (props: { 190 + domainData: CustomDomain; 191 + domain: string; 192 + onRemoveAssignment?: () => void; 193 + mutateIdentity: ReturnType<typeof useIdentityData>["mutate"]; 194 + isLastAssignment: boolean; 195 + }) => { 196 + let [areYouSure, setAreYouSure] = useState(false); 197 + 198 + if (areYouSure) { 199 + return ( 200 + <UnassignAreYouSure 201 + {...props} 202 + closeAreYouSure={() => { 203 + setAreYouSure(false); 204 + }} 205 + /> 206 + ); 207 + } 208 + return ( 209 + <div className={containerClassName}> 210 + <span className="text-secondary truncate"> 211 + {props.domainData.publication_domains[0]?.publications?.name ?? 212 + "publication"} 213 + </span> 214 + <button 215 + className="text-tertiary hover:text-accent-contrast text-sm" 216 + type="button" 217 + onMouseDown={() => { 218 + setAreYouSure(true); 219 + }} 220 + > 221 + <UnlinkTiny /> 222 + </button> 223 + </div> 224 + ); 225 + }; 226 + 227 + const AssignedLeaflet = (props: { 228 + route: { 229 + id: string; 230 + edit_permission_token: string; 231 + route: string; 232 + }; 233 + domain: string; 234 + onRemoveAssignment?: () => void; 235 + mutateIdentity: ReturnType<typeof useIdentityData>["mutate"]; 236 + isLastAssignment: boolean; 237 + }) => { 238 + let [areYouSure, setAreYouSure] = useState(false); 239 + 240 + if (areYouSure) { 241 + return ( 242 + <UnassignAreYouSure 243 + {...props} 244 + routeId={props.route.id} 245 + closeAreYouSure={() => { 246 + setAreYouSure(false); 247 + }} 248 + /> 249 + ); 250 + } 251 + return ( 252 + <div className={containerClassName}> 253 + <a 254 + href={`/${props.route.edit_permission_token}`} 255 + className="text-accent-contrast no-underline! truncate" 256 + > 257 + {props.route.route} 258 + </a> 259 + <button 260 + className="text-tertiary hover:text-accent-contrast " 261 + type="button" 262 + onMouseDown={() => { 263 + setAreYouSure(true); 264 + }} 265 + > 266 + <UnlinkTiny /> 267 + </button> 268 + </div> 269 + ); 270 + }; 271 + 272 + const UnassignAreYouSure = (props: { 273 + domain: string; 274 + routeId?: string; 275 + isLastAssignment: boolean; 276 + onRemoveAssignment?: () => void; 277 + mutateIdentity: ReturnType<typeof useIdentityData>["mutate"]; 278 + closeAreYouSure: () => void; 279 + }) => { 280 + let { 281 + domain, 282 + routeId, 283 + isLastAssignment, 284 + onRemoveAssignment, 285 + mutateIdentity, 286 + } = props; 287 + return ( 288 + <div className={`${containerClassName}`}> 289 + <p className="text-secondary text-sm font-bold">Are You Sure?</p> 290 + <div className="flex gap-2 items-center"> 291 + <ButtonPrimary 292 + compact 293 + type="button" 294 + className="text-sm -my-px" 295 + onMouseDown={async () => { 296 + if (routeId) { 297 + mutateIdentityData(mutateIdentity, (draft) => { 298 + let domainData = draft.custom_domains.find( 299 + (d) => d.domain === domain, 300 + ); 301 + if (domainData) { 302 + domainData.custom_domain_routes = 303 + domainData.custom_domain_routes.filter( 304 + (r) => r.id !== routeId, 305 + ); 306 + } 307 + }); 308 + if (isLastAssignment) onRemoveAssignment?.(); 309 + await removeDomainRoute({ routeId }); 310 + } else { 311 + mutateIdentityData(mutateIdentity, (draft) => { 312 + let domainData = draft.custom_domains.find( 313 + (d) => d.domain === domain, 314 + ); 315 + if (domainData) { 316 + domainData.publication_domains = []; 317 + } 318 + }); 319 + onRemoveAssignment?.(); 320 + await removeDomainAssignment({ domain }); 321 + } 322 + }} 323 + > 324 + Confirm 325 + </ButtonPrimary> 326 + <button 327 + className="text-accent-contrast font-bold" 328 + type="button" 329 + onMouseDown={() => props.closeAreYouSure()} 330 + > 331 + Nevermind 332 + </button> 333 + </div> 334 + </div> 335 + ); 336 + }; 337 + 338 + function DeleteDomainButton(props: { 339 + domain: string; 340 + onDeleteDomain?: () => void; 341 + mutateIdentity: ReturnType<typeof useIdentityData>["mutate"]; 342 + }) { 343 + let [confirming, setConfirming] = useState(false); 344 + let [loading, setLoading] = useState(false); 345 + 346 + if (!props.onDeleteDomain) return null; 347 + 348 + if (!confirming) { 349 + return ( 350 + <ButtonTertiary 351 + fullWidth 352 + compact 353 + type="button" 354 + onMouseDown={() => setConfirming(true)} 355 + > 356 + Delete Domain 357 + </ButtonTertiary> 358 + ); 359 + } 360 + 361 + return ( 362 + <div className="flex flex-col gap-1 text-sm accent-container rounded-md p-2"> 363 + <p className="text-secondary text-center"> 364 + Are you sure you want to delete <strong>{props.domain}</strong>? This 365 + will remove all assignments and cannot be undone. 366 + </p> 367 + <div className="flex gap-2 justify-center"> 368 + <button 369 + className="text-accent-contrast font-bold" 370 + onMouseDown={() => setConfirming(false)} 371 + type="button" 372 + > 373 + Cancel 374 + </button> 375 + <ButtonPrimary 376 + compact 377 + className="text-sm" 378 + disabled={loading} 379 + onMouseDown={async () => { 380 + setLoading(true); 381 + mutateIdentityData(props.mutateIdentity, (draft) => { 382 + draft.custom_domains = draft.custom_domains.filter( 383 + (d) => d.domain !== props.domain, 384 + ); 385 + }); 386 + props.onDeleteDomain?.(); 387 + await deleteDomain({ domain: props.domain }); 388 + }} 389 + > 390 + {loading ? <DotLoader /> : "Delete"} 391 + </ButtonPrimary> 392 + </div> 393 + </div> 394 + ); 395 + } 396 + 397 + function VerifyButton(props: { verify: () => Promise<any> }) { 398 + let [loading, setLoading] = useState(false); 399 + let smoker = useSmoker(); 400 + return ( 401 + <ButtonPrimary 402 + fullWidth 403 + className="mt-2" 404 + type="button" 405 + onClick={async (e) => { 406 + e.preventDefault(); 407 + setLoading(true); 408 + let result = await props.verify(); 409 + setLoading(false); 410 + let stillPending = 411 + result?.config?.misconfigured || result?.verification; 412 + if (stillPending) { 413 + smoker({ 414 + position: { 415 + x: e.clientX, 416 + y: e.clientY - 5, 417 + }, 418 + text: "Still processing", 419 + }); 420 + } 421 + }} 422 + > 423 + {loading ? <DotLoader /> : "Check Status"} 424 + </ButtonPrimary> 425 + ); 426 + }
+58
components/Domains/ManageDomains.tsx
··· 1 + "use client"; 2 + import { useState } from "react"; 3 + import { DomainList } from "./DomainList"; 4 + import { AddDomainForm } from "./AddDomainForm"; 5 + import { DomainSettingsView } from "./DomainSettingsView"; 6 + import { Modal } from "components/Modal"; 7 + import { ButtonPrimary } from "components/Buttons"; 8 + import { WebSmall } from "components/Icons/WebSmall"; 9 + 10 + type State = 11 + | "list" 12 + | "add-domain" 13 + | { type: "domain-settings"; domain: string }; 14 + 15 + export function ManageDomains() { 16 + let [state, setState] = useState<State>("list"); 17 + return ( 18 + <Modal 19 + className="w-md" 20 + trigger={ 21 + <div className="menuItem -mx-[8px] "> 22 + <WebSmall /> 23 + Domain Settings 24 + </div> 25 + } 26 + > 27 + {state === "add-domain" ? ( 28 + <AddDomainForm 29 + onDomainAdded={(domain) => 30 + setState({ type: "domain-settings", domain }) 31 + } 32 + onBack={() => setState("list")} 33 + /> 34 + ) : typeof state === "object" && state.type === "domain-settings" ? ( 35 + <DomainSettingsView 36 + domain={state.domain} 37 + onBack={() => setState("list")} 38 + onRemoveAssignment={() => setState("list")} 39 + onDeleteDomain={() => setState("list")} 40 + /> 41 + ) : ( 42 + <div className="flex flex-col gap-2"> 43 + <div className="flex justify-between"> 44 + <h3>Domains</h3> 45 + <ButtonPrimary onClick={() => setState("add-domain")}> 46 + Add 47 + </ButtonPrimary> 48 + </div> 49 + <DomainList 50 + onSelectDomain={(domain) => 51 + setState({ type: "domain-settings", domain }) 52 + } 53 + /> 54 + </div> 55 + )} 56 + </Modal> 57 + ); 58 + }
+399
components/Domains/PublicationDomains.tsx
··· 1 + "use client"; 2 + import { useState } from "react"; 3 + import { mutate } from "swr"; 4 + import { useDomainStatus } from "./useDomainStatus"; 5 + import { getDomainAssignment, describeAssignment } from "./domainAssignment"; 6 + import { 7 + useIdentityData, 8 + mutateIdentityData, 9 + } from "components/IdentityProvider"; 10 + import { ButtonPrimary } from "components/Buttons"; 11 + import { 12 + usePublicationData, 13 + useNormalizedPublicationRecord, 14 + } from "app/lish/[did]/[publication]/dashboard/PublicationSWRProvider"; 15 + import { updatePublicationBasePath } from "app/lish/createPub/updatePublication"; 16 + import { 17 + assignDomainToPublication, 18 + removeDomainAssignment, 19 + } from "actions/domains"; 20 + import { PubSettingsHeader } from "app/lish/[did]/[publication]/dashboard/settings/PublicationSettings"; 21 + import { AddDomainForm } from "./AddDomainForm"; 22 + import { DomainSettingsView } from "./DomainSettingsView"; 23 + import { PinTiny } from "components/Icons/PinTiny"; 24 + import { LoadingTiny } from "components/Icons/LoadingTiny"; 25 + import { AddTiny } from "components/Icons/AddTiny"; 26 + import { UnlinkTiny } from "components/Icons/UnlinkTiny"; 27 + import { DotLoader } from "components/utils/DotLoader"; 28 + import { useToaster } from "components/Toast"; 29 + import type { CustomDomain } from "./DomainList"; 30 + 31 + export function PublicationDomains(props: { 32 + backToMenu: () => void; 33 + publication_uri: string; 34 + }) { 35 + let { data, mutate: mutatePubData } = usePublicationData(); 36 + let { publication: pubData } = data || {}; 37 + let record = useNormalizedPublicationRecord(); 38 + let { identity, mutate: mutateIdentity } = useIdentityData(); 39 + let toaster = useToaster(); 40 + let basePath = record?.url?.replace(/^https?:\/\//, "") || ""; 41 + 42 + let pubDomains = pubData?.publication_domains || []; 43 + let pubDomainNames = new Set(pubDomains.map((d) => d.domain)); 44 + 45 + return ( 46 + <div> 47 + <PubSettingsHeader backToMenuAction={props.backToMenu}> 48 + Domains 49 + </PubSettingsHeader> 50 + <div className="flex flex-col gap-1 py-1"> 51 + <h4 className="">This Publication's Domains</h4> 52 + <div className="text-xs text-tertiary -mb-1 ">DEFAULT</div> 53 + {pubDomains 54 + .filter((d) => d.domain === basePath) 55 + .map((d) => ( 56 + <PubDomainRow 57 + key={d.domain} 58 + domain={d.domain} 59 + publication_uri={props.publication_uri} 60 + basePath={basePath} 61 + mutatePubData={mutatePubData} 62 + mutateIdentity={mutateIdentity} 63 + toaster={toaster} 64 + onSettings={() => {}} 65 + /> 66 + ))} 67 + {pubDomains.filter((d) => d.domain !== basePath).length !== 0 && ( 68 + <> 69 + <div className="text-xs text-tertiary pt-1">ALTERNATES</div> 70 + {pubDomains 71 + .filter((d) => d.domain !== basePath) 72 + .map((d) => ( 73 + <PubDomainRow 74 + key={d.domain} 75 + domain={d.domain} 76 + publication_uri={props.publication_uri} 77 + basePath={basePath} 78 + mutatePubData={mutatePubData} 79 + mutateIdentity={mutateIdentity} 80 + toaster={toaster} 81 + onSettings={() => {}} 82 + /> 83 + ))} 84 + </> 85 + )} 86 + 87 + <hr className="my-2 border-border-light" /> 88 + <h4 className="">Available Domains</h4> 89 + {(() => { 90 + let availableDomains = (identity?.custom_domains || []) 91 + .filter((d) => !pubDomainNames.has(d.domain)) 92 + .filter( 93 + (d) => 94 + d.publication_domains.length === 0 && 95 + d.custom_domain_routes.length === 0, 96 + ); 97 + return availableDomains.length > 0 ? ( 98 + <> 99 + {availableDomains.map((d) => ( 100 + <UnassignedDomainRow 101 + key={d.domain} 102 + domainData={d} 103 + publication_uri={props.publication_uri} 104 + mutatePubData={mutatePubData} 105 + onAssigned={() => { 106 + mutateIdentity(); 107 + mutate("publication-data"); 108 + }} 109 + onSettings={() => {}} 110 + /> 111 + ))} 112 + <div className="text-sm text-tertiary pt-0.5"> 113 + Add new domains from your profile settings! 114 + </div> 115 + </> 116 + ) : ( 117 + <div className="text-sm text-tertiary"> 118 + <strong>No available domains!</strong> 119 + <br /> 120 + Add new domains from your profile settings! 121 + </div> 122 + ); 123 + })()} 124 + </div> 125 + </div> 126 + ); 127 + } 128 + 129 + function PubDomainRow(props: { 130 + domain: string; 131 + publication_uri: string; 132 + basePath: string; 133 + mutatePubData: ReturnType<typeof usePublicationData>["mutate"]; 134 + mutateIdentity: ReturnType<typeof useIdentityData>["mutate"]; 135 + toaster: ReturnType<typeof useToaster>; 136 + onSettings: () => void; 137 + }) { 138 + let { pending } = useDomainStatus(props.domain); 139 + let [loading, setLoading] = useState(false); 140 + let [unlinking, setUnlinking] = useState(false); 141 + let toaster = props.toaster; 142 + 143 + return ( 144 + <div className="text-sm text-secondary relative w-full flex items-center justify-between px-[6px] py-1 border rounded-md border-border-light"> 145 + <button 146 + type="button" 147 + className="pr-8 truncate text-left" 148 + onMouseDown={() => props.onSettings()} 149 + > 150 + {props.domain} 151 + </button> 152 + <div className="flex justify-end items-center"> 153 + {pending ? ( 154 + <button 155 + className="group/pending px-1 py-0.5 flex gap-1 items-center rounded-full hover:bg-accent-1 hover:text-accent-2 hover:outline-accent-1 border-transparent outline-solid outline-transparent selected-outline" 156 + onClick={() => props.onSettings()} 157 + type="button" 158 + > 159 + <p className="group-hover/pending:block hidden w-max pl-1 font-bold"> 160 + pending 161 + </p> 162 + <LoadingTiny className="animate-spin text-accent-contrast group-hover/pending:text-accent-2" /> 163 + </button> 164 + ) : ( 165 + <> 166 + {props.basePath !== props.domain && ( 167 + <div className="flex gap-1"> 168 + {!props.domain.endsWith(".leaflet.pub") && ( 169 + <button 170 + type="button" 171 + disabled={unlinking} 172 + className="text-tertiary hover:text-accent-contrast shrink-0" 173 + onClick={async () => { 174 + setUnlinking(true); 175 + // Optimistically remove domain from publication data 176 + props.mutatePubData( 177 + (current) => { 178 + if (!current) return current; 179 + let pub = current.publication; 180 + if (!pub) return current; 181 + return { 182 + ...current, 183 + publication: { 184 + ...pub, 185 + publication_domains: ( 186 + pub.publication_domains || [] 187 + ).filter((d) => d.domain !== props.domain), 188 + }, 189 + }; 190 + }, 191 + { revalidate: false }, 192 + ); 193 + // Optimistically remove publication assignment from identity data 194 + mutateIdentityData(props.mutateIdentity, (draft) => { 195 + let domain = draft.custom_domains.find( 196 + (d) => d.domain === props.domain, 197 + ); 198 + if (domain) { 199 + domain.publication_domains = []; 200 + } 201 + }); 202 + toaster({ 203 + content: ( 204 + <div> 205 + Unlinked <strong>{props.domain}</strong> 206 + </div> 207 + ), 208 + type: "success", 209 + }); 210 + await removeDomainAssignment({ domain: props.domain }); 211 + props.mutatePubData(); 212 + props.mutateIdentity(); 213 + setUnlinking(false); 214 + }} 215 + > 216 + {unlinking ? ( 217 + <DotLoader className="h-[16px]! text-xs" /> 218 + ) : ( 219 + <UnlinkTiny /> 220 + )} 221 + </button> 222 + )} 223 + <button 224 + type="button" 225 + disabled={loading} 226 + onClick={async () => { 227 + setLoading(true); 228 + // Optimistically update the record's url so the UI 229 + // immediately reflects the new default domain 230 + props.mutatePubData( 231 + (current) => { 232 + if (!current) return current; 233 + let pub = current.publication; 234 + if (!pub?.record) return current; 235 + let rec = pub.record as Record<string, unknown>; 236 + if (typeof rec.url !== "string") return current; 237 + let protocol = 238 + rec.url.match(/^https?:\/\//)?.[0] || "https://"; 239 + return { 240 + ...current, 241 + publication: { 242 + ...pub, 243 + record: { ...rec, url: protocol + props.domain }, 244 + }, 245 + }; 246 + }, 247 + { revalidate: false }, 248 + ); 249 + toaster({ 250 + content: ( 251 + <div> 252 + Default domain set to <strong>{props.domain}</strong> 253 + </div> 254 + ), 255 + type: "success", 256 + }); 257 + await updatePublicationBasePath({ 258 + uri: props.publication_uri, 259 + base_path: props.domain, 260 + }); 261 + props.mutatePubData(); 262 + setLoading(false); 263 + }} 264 + className="hover:text-accent-contrast" 265 + > 266 + {loading ? ( 267 + <DotLoader className="h-[18px]! text-xs" /> 268 + ) : ( 269 + <PinTiny className="text-tertiary hover:text-accent-contrast group-hover/domain:text-accent-2 shrink-0" /> 270 + )} 271 + </button> 272 + </div> 273 + )} 274 + </> 275 + )} 276 + </div> 277 + </div> 278 + ); 279 + } 280 + 281 + function UnassignedDomainRow(props: { 282 + domainData: CustomDomain; 283 + publication_uri: string; 284 + mutatePubData: ReturnType<typeof usePublicationData>["mutate"]; 285 + onAssigned: () => void; 286 + onSettings: () => void; 287 + }) { 288 + let { pending } = useDomainStatus(props.domainData.domain); 289 + let { mutate: mutateIdentity } = useIdentityData(); 290 + let assignment = getDomainAssignment(props.domainData); 291 + let [confirming, setConfirming] = useState(false); 292 + let [loading, setLoading] = useState(false); 293 + 294 + async function doAssign() { 295 + setLoading(true); 296 + mutateIdentityData(mutateIdentity, (draft) => { 297 + let domain = draft.custom_domains.find( 298 + (d) => d.domain === props.domainData.domain, 299 + ); 300 + if (domain) { 301 + domain.custom_domain_routes = []; 302 + let pub = draft.publications?.find( 303 + (p) => p.uri === props.publication_uri, 304 + ); 305 + domain.publication_domains = [ 306 + { 307 + publication: props.publication_uri, 308 + domain: props.domainData.domain, 309 + identity: "", 310 + created_at: new Date().toISOString(), 311 + publications: pub ? { name: pub.name } : null, 312 + }, 313 + ]; 314 + } 315 + }); 316 + props.mutatePubData( 317 + (current) => { 318 + if (!current) return current; 319 + let pub = current.publication; 320 + if (!pub) return current; 321 + return { 322 + ...current, 323 + publication: { 324 + ...pub, 325 + publication_domains: [ 326 + ...(pub.publication_domains || []), 327 + { 328 + publication: props.publication_uri, 329 + domain: props.domainData.domain, 330 + created_at: new Date().toISOString(), 331 + identity: "", 332 + }, 333 + ], 334 + }, 335 + }; 336 + }, 337 + { revalidate: false }, 338 + ); 339 + setConfirming(false); 340 + props.onAssigned(); 341 + await assignDomainToPublication({ 342 + domain: props.domainData.domain, 343 + publication_uri: props.publication_uri, 344 + }); 345 + } 346 + 347 + return ( 348 + <div className="text-sm text-tertiary w-full flex flex-col gap-1 px-[6px] py-1 border rounded-md border-border-light border-dashed"> 349 + <div className="flex items-center justify-between"> 350 + <button 351 + type="button" 352 + className="truncate text-left" 353 + onMouseDown={() => props.onSettings()} 354 + > 355 + {props.domainData.domain} 356 + </button> 357 + {pending ? ( 358 + <div className="text-tertiary animate-pulse text-xs">unverfied</div> 359 + ) : confirming ? null : ( 360 + <button 361 + className="text-accent-contrast text-xs font-bold" 362 + type="button" 363 + disabled={loading} 364 + onClick={() => { 365 + if (assignment.type === "document") { 366 + setConfirming(true); 367 + } else { 368 + doAssign(); 369 + } 370 + }} 371 + > 372 + {loading ? <DotLoader className="h-[18px]! text-xs" /> : "assign"} 373 + </button> 374 + )} 375 + </div> 376 + {confirming && ( 377 + <div className="text-xs border-t border-border-light pt-1 flex flex-col gap-1"> 378 + <p className="text-secondary"> 379 + This domain is currently assigned to{" "} 380 + {describeAssignment(assignment)}. Assigning it here will remove that 381 + assignment. 382 + </p> 383 + <div className="flex gap-2 justify-end"> 384 + <button 385 + className="text-accent-contrast" 386 + onMouseDown={() => setConfirming(false)} 387 + type="button" 388 + > 389 + Cancel 390 + </button> 391 + <ButtonPrimary compact disabled={loading} onMouseDown={doAssign}> 392 + {loading ? <DotLoader /> : "Reassign"} 393 + </ButtonPrimary> 394 + </div> 395 + </div> 396 + )} 397 + </div> 398 + ); 399 + }
+32
components/Domains/domainAssignment.ts
··· 1 + import type { CustomDomain } from "./DomainList"; 2 + 3 + export type DomainAssignment = 4 + | { type: "unassigned" } 5 + | { type: "publication" } 6 + | { type: "document"; routes: string[] }; 7 + 8 + export function getDomainAssignment(domain: CustomDomain): DomainAssignment { 9 + if (domain.publication_domains && domain.publication_domains.length > 0) { 10 + return { type: "publication" }; 11 + } 12 + if (domain.custom_domain_routes && domain.custom_domain_routes.length > 0) { 13 + return { 14 + type: "document", 15 + routes: domain.custom_domain_routes.map((r) => r.route), 16 + }; 17 + } 18 + return { type: "unassigned" }; 19 + } 20 + 21 + export function describeAssignment(assignment: DomainAssignment): string { 22 + switch (assignment.type) { 23 + case "publication": 24 + return "publication"; 25 + case "document": 26 + return assignment.routes.length === 1 27 + ? "1 leaflet" 28 + : `${assignment.routes.length} leaflets`; 29 + case "unassigned": 30 + return ""; 31 + } 32 + }
+10
components/Domains/useDomainStatus.ts
··· 1 + import useSWR from "swr"; 2 + import { callRPC } from "app/api/rpc/client"; 3 + 4 + export function useDomainStatus(domain: string) { 5 + let { data, mutate } = useSWR(`domain-status-${domain}`, async () => { 6 + return await callRPC("get_domain_status", { domain }); 7 + }); 8 + let pending = data?.config?.misconfigured || data?.verification; 9 + return { data, pending, mutate }; 10 + }
+1
components/Icons/GoToArrow.tsx
··· 8 8 viewBox="0 0 16 16" 9 9 fill="none" 10 10 xmlns="http://www.w3.org/2000/svg" 11 + {...props} 11 12 > 12 13 <path 13 14 d="M8 0C12.4183 0 16 3.58172 16 8C16 12.4183 12.4183 16 8 16C3.58172 16 0 12.4183 0 8C0 3.58172 3.58172 0 8 0ZM8.75781 2.69629C8.46492 2.4034 7.98918 2.4034 7.69629 2.69629C7.4034 2.98918 7.4034 3.46492 7.69629 3.75781L11.1895 7.25H3C2.58579 7.25 2.25 7.58579 2.25 8C2.25 8.41421 2.58579 8.75 3 8.75H11.1895L7.69629 12.2422C7.4034 12.5351 7.4034 13.0108 7.69629 13.3037C7.98918 13.5966 8.46492 13.5966 8.75781 13.3037L13.5303 8.53027C13.8232 8.23738 13.8232 7.76262 13.5303 7.46973L8.75781 2.69629Z"
+19
components/Icons/RefreshSmall.tsx
··· 1 + import { Props } from "./Props"; 2 + 3 + export const RefreshSmall = (props: Props) => { 4 + return ( 5 + <svg 6 + width="24" 7 + height="24" 8 + viewBox="0 0 24 24" 9 + fill="none" 10 + xmlns="http://www.w3.org/2000/svg" 11 + {...props} 12 + > 13 + <path 14 + d="M8.98383 20.9565C8.45201 20.8077 8.14158 20.2559 8.29033 19.724C8.43933 19.1925 8.99108 18.8818 9.52276 19.0305C11.3292 19.5357 13.9867 19.6991 15.9397 18.6274C17.5194 17.7603 18.5445 16.0553 18.6533 14.2845C18.7528 12.664 18.0857 10.9792 16.2923 9.83579L16.8257 12.498C16.934 13.0394 16.5828 13.5663 16.0414 13.6748C15.5001 13.7829 14.9731 13.4318 14.8647 12.8905L13.8381 7.7627C13.7299 7.22145 14.0812 6.69455 14.6223 6.58595L19.7501 5.55932C20.2915 5.45099 20.8182 5.80243 20.9269 6.3436C21.0353 6.88515 20.6841 7.41197 20.1426 7.52035L17.2772 8.09442C19.7665 9.63761 20.7945 12.0545 20.65 14.4071C20.5014 16.8268 19.1147 19.1667 16.9025 20.381C14.292 21.8136 11.0224 21.5265 8.98383 20.9565ZM3.85412 17.468C3.30272 17.4971 2.83216 17.0736 2.80287 16.5222C2.774 15.9709 3.19731 15.5001 3.74868 15.471L6.36991 15.3333C4.0138 13.7866 3.04124 11.4299 3.18206 9.13602C3.33085 6.71632 4.71736 4.37626 6.92959 3.1621C9.54014 1.72942 12.8097 2.0165 14.8483 2.5866C15.38 2.73536 15.6903 3.28729 15.5418 3.81903C15.3928 4.35058 14.841 4.66124 14.3093 4.51253C12.5029 4.00737 9.84545 3.84396 7.89238 4.91563C6.31265 5.78265 5.28773 7.48782 5.17881 9.25855C5.07299 10.9821 5.83329 12.7794 7.89468 13.9196C7.89737 13.9211 7.89978 13.9238 7.90246 13.9253L7.74891 11.0246C7.71993 10.4733 8.14354 10.0027 8.69473 9.97331C9.24601 9.9444 9.71675 10.3678 9.74598 10.9191L10.0223 16.1418C10.051 16.6929 9.62755 17.1637 9.07645 17.1931L3.85412 17.468Z" 15 + fill="currentColor" 16 + /> 17 + </svg> 18 + ); 19 + };
+18
components/Icons/UnlinkTiny.tsx
··· 1 + import { Props } from "./Props"; 2 + export const UnlinkTiny = (props: Props) => { 3 + return ( 4 + <svg 5 + width="16" 6 + height="16" 7 + viewBox="0 0 16 16" 8 + fill="none" 9 + xmlns="http://www.w3.org/2000/svg" 10 + {...props} 11 + > 12 + <path 13 + d="M8.73287 4.06775C10.1751 2.55464 12.5709 2.49555 14.0854 3.93689C15.6002 5.37888 15.6589 7.77631 14.2172 9.29138L12.434 11.1654C11.5058 12.1407 10.1815 12.5112 8.95162 12.2679L8.74752 12.7855V12.7875C7.97434 14.7306 5.77189 15.6793 3.82857 14.9066C1.88511 14.1334 0.935308 11.9302 1.70846 9.9867L2.37252 8.31873C2.5257 7.93407 2.96237 7.74583 3.34713 7.89881C3.73156 8.05194 3.91963 8.48783 3.76705 8.87244L3.10299 10.5414C2.63611 11.715 3.20883 13.045 4.38228 13.5121C5.55595 13.9789 6.88594 13.4063 7.35299 12.2328L7.56685 11.6908C7.39668 11.5753 7.23345 11.4447 7.07955 11.2982C6.50753 10.7538 6.14207 10.0703 5.98775 9.35095C5.90116 8.94609 6.15906 8.54716 6.56393 8.46033C6.96868 8.37377 7.36759 8.63181 7.45455 9.0365C7.54761 9.4702 7.76708 9.8814 8.11373 10.2113C9.02873 11.0819 10.4763 11.0459 11.3471 10.1312L13.1313 8.2572C14.0017 7.34226 13.9657 5.89465 13.0512 5.02381C12.1362 4.15297 10.6878 4.18901 9.81685 5.10388L9.8149 5.10584L8.75826 6.20642C9.41841 6.81875 9.8375 7.64617 9.94478 8.52966C9.99466 8.94079 9.70156 9.31463 9.29049 9.36463C8.87944 9.41428 8.50539 9.1214 8.45553 8.71033C8.35757 7.90421 7.83428 7.17789 7.02975 6.85779C6.82583 6.7767 6.61766 6.72718 6.41158 6.70642C5.9997 6.6647 5.69922 6.2971 5.74068 5.88513C5.78232 5.47328 6.15007 5.17293 6.56197 5.21424C6.85699 5.24394 7.15221 5.31037 7.44185 5.41248L8.73287 4.06775ZM1.91646 0.929079C2.10216 0.785149 2.3631 0.790118 2.54342 0.940798L4.59908 2.65759L5.47799 1.44177C5.59959 1.27334 5.81354 1.1974 6.01412 1.25134C6.2147 1.30555 6.36189 1.47815 6.38228 1.68494L6.59908 3.89685C6.626 4.17132 6.42616 4.41531 6.15182 4.44275C5.87704 4.46982 5.63207 4.26926 5.60494 3.99451L5.5151 3.09705L5.09713 3.67908C5.01596 3.7915 4.89094 3.86489 4.75338 3.8822C4.61577 3.89945 4.47701 3.85879 4.37057 3.7699L3.66451 3.17908L4.27486 4.4574C4.3406 4.59485 4.34053 4.75495 4.27389 4.89197C4.20723 5.02892 4.08173 5.12836 3.93307 5.1615L2.90475 5.39002L4.4565 6.33142C4.68707 6.48339 4.75105 6.79324 4.59908 7.02381C4.44708 7.25418 4.13719 7.31824 3.9067 7.16638L1.36861 5.57654C1.2014 5.46602 1.11658 5.26591 1.15279 5.06873C1.1891 4.87149 1.33989 4.71501 1.5356 4.67127L3.10103 4.32068L1.77096 1.53943C1.66983 1.32742 1.73098 1.07325 1.91646 0.929079Z" 14 + fill="currentColor" 15 + /> 16 + </svg> 17 + ); 18 + };
+19
components/Icons/WebSmall.tsx
··· 1 + import { Props } from "./Props"; 2 + 3 + export const WebSmall = (props: Props) => { 4 + return ( 5 + <svg 6 + width="24" 7 + height="25" 8 + viewBox="0 0 24 25" 9 + fill="none" 10 + xmlns="http://www.w3.org/2000/svg" 11 + {...props} 12 + > 13 + <path 14 + d="M12.2546 2.08102C14.3608 0.971554 16.5868 0.630551 18.2985 1.72946C20.1168 2.89726 20.7002 5.2651 20.4596 7.83199C20.2499 10.0691 19.4078 12.6314 17.9968 15.1484C18.8353 14.6474 19.5703 14.106 20.1784 13.5429C21.7885 12.0519 22.3631 10.5853 22.0475 9.44332C21.922 8.98963 21.6583 8.57412 21.2507 8.20309C20.9444 7.92439 20.9213 7.44892 21.1999 7.14254C21.4786 6.83623 21.9541 6.81401 22.2604 7.09274C22.8483 7.62765 23.2829 8.28082 23.4938 9.04391C24.0351 11.0027 22.9547 13.0156 21.1979 14.6425C19.9638 15.7854 18.3172 16.8196 16.4001 17.6318C14.7029 19.9493 12.7311 21.7713 10.7917 22.8886C8.55782 24.1754 6.16195 24.6309 4.34344 23.4638C3.03938 22.6265 2.36964 21.1586 2.18523 19.4785C2.14035 19.067 2.43775 18.6965 2.8493 18.6513C3.26076 18.6065 3.63116 18.904 3.67644 19.3154C3.83121 20.7248 4.3608 21.6928 5.15398 22.2021C6.25946 22.9112 7.98241 22.7757 10.0427 21.5888C11.2499 20.8934 12.5017 19.8692 13.6911 18.5742C13.6829 18.5764 13.6748 18.5797 13.6667 18.582C10.5778 19.4358 7.56627 19.5727 5.18133 19.0703C2.83847 18.5765 0.878193 17.4029 0.3366 15.4443C-0.180497 13.5725 0.784999 11.6495 2.39617 10.0703C2.69197 9.78036 3.16677 9.78526 3.45672 10.081C3.48313 10.108 3.50649 10.1373 3.52801 10.167C4.49687 7.38475 6.80796 5.13241 9.88152 4.37399C13.0016 3.60426 16.1432 4.56206 18.2966 6.6357C18.5944 6.92307 18.6023 7.39806 18.3151 7.69625C18.0278 7.99403 17.5537 8.00277 17.2555 7.71578C16.2812 6.77754 15.0653 6.11646 13.7429 5.80758C13.7217 5.86594 13.6901 5.92162 13.6462 5.97067C13.4617 6.1761 13.1446 6.19325 12.9391 6.00875C12.6345 5.73517 12.3171 5.63011 11.946 5.61031C11.3826 5.61791 10.8113 5.68933 10.2409 5.83004C9.80842 5.93676 9.39465 6.08104 9.00066 6.25485C9.00584 6.31624 9.00112 6.37985 8.98211 6.44235C8.70725 7.34445 8.79682 8.395 9.2409 9.48043C10.4569 8.5539 11.6356 8.05851 12.9186 7.79977C13.1891 7.74567 13.452 7.92094 13.5065 8.19137C13.5608 8.46176 13.3862 8.72551 13.1159 8.78024C11.9445 9.01646 10.8598 9.47376 9.69598 10.3955C10.3529 11.5185 11.3786 12.6463 12.7624 13.6552C13.0921 13.8956 13.4238 14.1123 13.7536 14.3076C14.404 13.1163 14.8884 11.7348 15.0905 10.6738C15.1422 10.4026 15.4042 10.2247 15.6755 10.2763C15.9465 10.3282 16.1245 10.5902 16.0729 10.8613C15.8542 12.0096 15.3396 13.4866 14.6354 14.7783C14.763 14.8392 14.8898 14.8988 15.0163 14.9531C15.2695 15.0622 15.3867 15.3558 15.278 15.6093C15.169 15.8626 14.8753 15.9796 14.6218 15.8711C14.4542 15.7992 14.2864 15.7208 14.1179 15.6386C14.0515 15.7379 13.9853 15.836 13.9167 15.9306C13.7186 16.2037 13.7499 16.1748 13.5407 16.4404C13.3698 16.6569 13.0554 16.694 12.8386 16.5234C12.6221 16.3526 12.5852 16.039 12.7555 15.8222C12.9496 15.5758 12.8809 15.6546 13.1071 15.3427C13.15 15.2837 13.1911 15.2224 13.2331 15.1611C12.8789 14.951 12.5238 14.7189 12.1725 14.4629C10.7877 13.453 9.68055 12.2798 8.92937 11.0586C8.70604 11.2687 8.47894 11.4968 8.24676 11.7441C7.40477 12.6409 6.9459 13.6112 6.73894 14.5927C7.05357 15.0957 7.40819 15.5897 7.79461 16.0556C7.97038 16.2681 7.9413 16.5835 7.72918 16.7597C7.51671 16.9357 7.20034 16.9055 7.0241 16.6933C6.87978 16.5193 6.73989 16.3402 6.6032 16.1591C6.60396 16.1906 6.60402 16.2224 6.60515 16.2539C6.61475 16.5295 6.39937 16.7624 6.12371 16.7724C5.84795 16.7823 5.61632 16.5657 5.60613 16.29C5.58842 15.7994 5.61306 15.2982 5.68914 14.7949C5.3123 14.1529 4.99703 13.4927 4.76629 12.8388C4.69652 12.6411 4.63414 12.4419 4.57976 12.2431C4.47705 13.1143 4.52518 14.0189 4.74773 14.9209C4.80414 15.1494 4.87049 15.3731 4.94598 15.5908C5.08136 15.9818 4.87487 16.409 4.48406 16.5449C4.09282 16.6805 3.66481 16.4731 3.52898 16.082C3.4385 15.821 3.35909 15.5533 3.29168 15.2802C2.96707 13.9647 2.95095 12.645 3.195 11.3974C1.93099 12.7348 1.49878 14.0197 1.78191 15.0449C2.09773 16.1867 3.34385 17.15 5.4909 17.6025C7.59655 18.0461 10.3611 17.939 13.2673 17.1357C14.0238 16.9266 14.7473 16.6805 15.4323 16.4072C15.6193 16.1416 15.8055 15.8703 15.985 15.5908C17.7485 12.8443 18.7474 10.0176 18.9655 7.69235C19.1874 5.32472 18.5931 3.70207 17.488 2.99215C16.4485 2.32474 14.8656 2.40111 12.9538 3.40817C12.5875 3.60097 12.1343 3.46069 11.9411 3.09469C11.7481 2.72828 11.8882 2.27412 12.2546 2.08102ZM19.1735 16.8798C19.2891 16.4823 19.7046 16.2539 20.1022 16.3691C20.4998 16.4847 20.7283 16.9002 20.613 17.2978C19.8477 19.9322 17.6652 21.5524 15.3796 21.9316C14.9713 21.9993 14.5855 21.7234 14.5173 21.3154C14.4495 20.9068 14.7258 20.5199 15.1345 20.4521C16.9112 20.1573 18.5856 18.9034 19.1735 16.8798ZM8.00066 20.0976C8.20918 19.7401 8.66836 19.6191 9.02605 19.8271C9.10812 19.875 9.1721 19.9012 9.26726 19.913C9.6779 19.9645 9.96959 20.3392 9.91863 20.75C9.86726 21.1606 9.49242 21.4532 9.08172 21.4023C8.69175 21.3538 8.47453 21.2416 8.27117 21.123C7.91364 20.9144 7.79231 20.4553 8.00066 20.0976ZM7.86687 6.87496C6.92198 7.5062 6.14314 8.33916 5.57586 9.29781C5.56979 9.33275 5.5615 9.36812 5.54754 9.40231C5.21338 10.2191 5.28455 11.3009 5.70965 12.5058C5.80926 12.7881 5.92825 13.0734 6.06219 13.3593C6.36312 12.5692 6.83018 11.7924 7.51726 11.0605C7.83012 10.7273 8.13763 10.4255 8.44012 10.1513C7.93212 9.04939 7.723 7.93039 7.86687 6.87496ZM14.9753 6.47457C15.2506 6.46197 15.4845 6.67483 15.4977 6.95016C15.5097 7.2067 15.3255 7.42491 15.0778 7.46481V7.46871C15.0909 7.53397 15.1197 7.62085 15.1677 7.72555C15.3659 8.15813 15.8192 8.7195 16.3698 9.12106C16.5926 9.28375 16.6416 9.59725 16.4792 9.82028C16.3165 10.0429 16.0039 10.092 15.7809 9.92965C15.1107 9.44097 14.5373 8.75013 14.2585 8.14156C14.126 7.85205 14.0178 7.49011 14.0964 7.15328C14.1398 6.96747 14.242 6.78789 14.4177 6.65817C14.587 6.53341 14.7859 6.4835 14.9753 6.47457Z" 15 + fill="currentColor" 16 + /> 17 + </svg> 18 + ); 19 + };
+4 -4
components/Modal.tsx
··· 23 23 <Dialog.Root open={open} onOpenChange={onOpenChange}> 24 24 <Dialog.Trigger asChild={asChild}>{trigger}</Dialog.Trigger> 25 25 <Dialog.Portal> 26 - <Dialog.Overlay className="fixed z-[100] inset-0 bg-primary data-[state=open]:animate-overlayShow opacity-60" /> 26 + <Dialog.Overlay className="fixed z-50 inset-0 bg-primary data-[state=open]:animate-overlayShow opacity-60" /> 27 27 <Dialog.Content 28 28 className={` 29 - z-[100] fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 29 + z-50 fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 30 30 overflow-y-scroll no-scrollbar w-max max-w-[calc(100vw-32px)] h-fit max-h-[calc(100dvh-32px)] p-3 flex flex-col 31 31 32 32 `} ··· 37 37 <div 38 38 className={` 39 39 opaque-container p-3 40 - flex flex-col gap-1 rounded-lg! 40 + flex flex-col rounded-lg! 41 41 ${className}`} 42 42 > 43 43 {title ? ( 44 44 <Dialog.Title> 45 - <h3>{title}</h3> 45 + <h3 className="pb-1">{title}</h3> 46 46 </Dialog.Title> 47 47 ) : ( 48 48 <Dialog.Title />
+2 -2
components/OAuthError.tsx
··· 12 12 const signInUrl = `/api/oauth/login?redirect_url=${encodeURIComponent(window.location.href)}${error.did ? `&handle=${encodeURIComponent(error.did)}` : ""}`; 13 13 14 14 return ( 15 - <div className={className}> 16 - <span>Your session has expired or is invalid. </span> 15 + <div className={`${className} leading-snug`}> 16 + <span>Your session has expired or is invalid </span> 17 17 <a href={signInUrl} className="underline font-bold whitespace-nowrap"> 18 18 Sign in again 19 19 </a>
+3 -1
components/Popover/index.tsx
··· 43 43 <RadixPopover.Content 44 44 className={` 45 45 z-20 relative bg-bg-page 46 - px-3 py-2 text-primary 46 + text-primary 47 + flex flex-col overflow-hidden 48 + px-3 py-2 47 49 max-w-(--radix-popover-content-available-width) 48 50 max-h-(--radix-popover-content-available-height) 49 51 border border-border rounded-md shadow-md
+1 -1
components/Toast.tsx
··· 139 139 > = (props) => { 140 140 return ( 141 141 <div 142 - className={`smoke w-max text-center pointer-events-none absolute z-50 rounded-full px-2 py-1 text-sm sm:-translate-x-1/2 ${ 142 + className={`smoke w-max text-center pointer-events-none absolute z-[51] rounded-full px-2 py-1 text-sm sm:-translate-x-1/2 ${ 143 143 props.alignOnMobile === "left" 144 144 ? "-translate-x-full" 145 145 : props.alignOnMobile === "right"
+1 -1
next-env.d.ts
··· 1 1 /// <reference types="next" /> 2 2 /// <reference types="next/image-types/global" /> 3 - import "./.next/types/routes.d.ts"; 3 + import "./.next/dev/types/routes.d.ts"; 4 4 5 5 // NOTE: This file should not be edited 6 6 // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.