a tool for shared writing and social publishing

Merge branch 'main' of https://github.com/hyperlink-academy/minilink into feature/email

+258 -42
+30 -11
actions/publishToPublication.ts
··· 182 182 credentialSession.did!, 183 183 ); 184 184 185 - let existingRecord: Partial<PubLeafletDocument.Record> = {}; 185 + let existingRecord: Partial<SiteStandardDocument.Record> = {}; 186 186 const normalizedDoc = normalizeDocumentRecord(draft?.documents?.data); 187 187 if (normalizedDoc) { 188 188 // When reading existing data, use normalized format to extract fields ··· 194 194 tags: normalizedDoc.tags, 195 195 coverImage: normalizedDoc.coverImage, 196 196 theme: normalizedDoc.theme, 197 + bskyPostRef: normalizedDoc.bskyPostRef, 197 198 }; 198 199 } 199 200 ··· 249 250 // Determine the rkey early since we need it for the path field 250 251 const rkey = existingDocUri ? new AtUri(existingDocUri).rkey : TID.nextStr(); 251 252 253 + // Resolve fields: use new values if provided, otherwise preserve existing 254 + const resolvedDescription = 255 + description !== undefined ? description : existingRecord.description; 256 + const resolvedTags = tags !== undefined ? tags : existingRecord.tags; 257 + const resolvedCoverImage = coverImageBlob ?? existingRecord.coverImage; 258 + const resolvedPublishedAt = 259 + publishedAt || existingRecord.publishedAt || new Date().toISOString(); 260 + 252 261 // Create record based on the document type 253 262 let record: PubLeafletDocument.Record | SiteStandardDocument.Record; 254 263 ··· 263 272 title: title || "", 264 273 site: siteUri, 265 274 path: "/" + rkey, 266 - publishedAt: 267 - publishedAt || existingRecord.publishedAt || new Date().toISOString(), 268 - ...(description && { description }), 269 - ...(tags !== undefined && { tags }), 270 - ...(coverImageBlob && { coverImage: coverImageBlob }), 275 + publishedAt: resolvedPublishedAt, 276 + ...(resolvedDescription !== undefined && { 277 + description: resolvedDescription, 278 + }), 279 + ...(resolvedTags !== undefined && { tags: resolvedTags }), 280 + ...(resolvedCoverImage && { coverImage: resolvedCoverImage }), 281 + ...(existingRecord.bskyPostRef && { 282 + bskyPostRef: existingRecord.bskyPostRef, 283 + }), 271 284 // Include theme for standalone documents (not for publication documents) 272 285 ...(!publication_uri && theme && { theme }), 273 286 ...(preferences && { ··· 295 308 }, 296 309 }), 297 310 title: title || "", 298 - description: description || "", 299 - ...(tags !== undefined && { tags }), 300 - ...(coverImageBlob && { coverImage: coverImageBlob }), 311 + description: resolvedDescription || "", 312 + ...(resolvedTags !== undefined && { tags: resolvedTags }), 313 + ...(resolvedCoverImage && { coverImage: resolvedCoverImage }), 314 + ...(existingRecord.bskyPostRef && { 315 + postRef: existingRecord.bskyPostRef, 316 + }), 301 317 pages: pagesArray, 302 - publishedAt: 303 - publishedAt || existingRecord.publishedAt || new Date().toISOString(), 318 + publishedAt: resolvedPublishedAt, 304 319 } satisfies PubLeafletDocument.Record; 305 320 } 306 321 ··· 332 347 publication: publication_uri, 333 348 title: title, 334 349 description: description, 350 + tags: resolvedTags ?? [], 351 + cover_image: cover_image ?? null, 335 352 }), 336 353 ]); 337 354 } else { ··· 341 358 document: result.uri, 342 359 title: title || "", 343 360 description: description || "", 361 + tags: resolvedTags ?? [], 362 + cover_image: cover_image ?? null, 344 363 }); 345 364 346 365 // Heuristic: Remove title entities if this is the first time publishing standalone
+4 -2
app/(home-pages)/p/[didOrHandle]/ProfileHeader.tsx
··· 43 43 </div> 44 44 ); 45 45 46 - 47 46 return ( 48 47 <div 49 48 className={`profileHeader flex flex-col relative `} ··· 67 66 did={props.profile.did} 68 67 /> 69 68 70 - <pre className="profileDescription pt-1 px-3 sm:px-4 whitespace-pre-wrap"> 69 + <pre 70 + className="profileDescription pt-1 px-3 sm:px-4 whitespace-pre-wrap" 71 + style={{ fontFamily: "inherit" }} 72 + > 71 73 {profileRecord.description 72 74 ? parseDescription(profileRecord.description) 73 75 : null}
+5 -2
app/[leaflet_id]/actions/PublishButton.tsx
··· 65 65 66 66 const UpdateButton = () => { 67 67 let [isLoading, setIsLoading] = useState(false); 68 - let { data: pub, mutate } = useLeafletPublicationData(); 68 + let { data: pub, mutate, normalizedDocument } = useLeafletPublicationData(); 69 69 let { permission_token, rootEntity, rep } = useReplicache(); 70 70 let { identity } = useIdentityData(); 71 71 let toaster = useToaster(); ··· 88 88 : pub?.description || ""; 89 89 90 90 // Get tags from Replicache state (same as draft editor) 91 + // Fall back to normalized document tags if Replicache hasn't pulled yet 91 92 let tags = useSubscribe(rep, (tx) => tx.get<string[]>("publication_tags")); 92 - const currentTags = Array.isArray(tags) ? tags : []; 93 + const currentTags = Array.isArray(tags) 94 + ? tags 95 + : normalizedDocument?.tags ?? []; 93 96 94 97 // Get cover image from Replicache state 95 98 let coverImage = useSubscribe(rep, (tx) =>
+1 -1
app/globals.css
··· 338 338 @apply box-decoration-clone; 339 339 } 340 340 341 - .selected .selection-highlight { 341 + .selection-highlight { 342 342 background-color: Highlight; 343 343 @apply py-[1.5px]; 344 344 }
+12 -1
app/lish/[did]/[publication]/generateFeed.ts
··· 93 93 } 94 94 } 95 95 96 + let content = chunks.join(""); 97 + // Strip <link> preload tags injected by React SSR — they trigger 98 + // security warnings in RSS validators and aren't useful in feeds. 99 + content = content.replace(/<link\b[^>]*>/gi, ""); 100 + // Convert relative URLs to absolute so RSS readers can resolve them. 101 + const baseUrl = pubRecord.url.replace(/\/$/, ""); 102 + content = content.replace( 103 + /(src|href)="\/(?!\/)/g, 104 + `$1="${baseUrl}/`, 105 + ); 106 + 96 107 const docUrl = getDocumentURL(record, doc.documents.uri, pubRecord); 97 108 feed.addItem({ 98 109 title: record.title, ··· 100 111 date: record.publishedAt ? new Date(record.publishedAt) : new Date(), 101 112 id: docUrl, 102 113 link: docUrl, 103 - content: chunks.join(""), 114 + content, 104 115 }); 105 116 } 106 117
-1
appview/index.ts
··· 375 375 if (evt.collection === "parts.page.mention.service") { 376 376 if (evt.event === "create" || evt.event === "update") { 377 377 let record = evt.record as any; 378 - if (!record?.name || !record?.endpoint) return; 379 378 let { error } = await supabase.from("mention_services").upsert({ 380 379 uri: evt.uri.toString(), 381 380 identity_did: evt.did,
+40 -4
components/Blocks/Block.tsx
··· 9 9 import { focusBlock } from "src/utils/focusBlock"; 10 10 import { useHandleDrop } from "./useHandleDrop"; 11 11 import { useEntitySetContext } from "components/EntitySetProvider"; 12 - 12 + import { indent, outdent } from "src/utils/list-operations"; 13 + import { useDrag } from "@use-gesture/react"; 13 14 import { TextBlock } from "./TextBlock/index"; 14 15 import { ImageBlock } from "./ImageBlock"; 15 16 import { PageLinkBlock } from "./PageLinkBlock"; ··· 36 37 import { Separator } from "components/Layout"; 37 38 import { moveBlockUp, moveBlockDown } from "src/utils/moveBlock"; 38 39 import { deleteBlock } from "src/utils/deleteBlock"; 40 + 41 + const SWIPE_THRESHOLD = 50; 39 42 40 43 export type Block = { 41 44 factID: string; ··· 76 79 nextPosition: props.nextPosition, 77 80 }); 78 81 let entity_set = useEntitySetContext(); 82 + let isMobile = useIsMobile(); 83 + let { rep } = useReplicache(); 79 84 80 85 let { isLongPress, longPressHandlers } = useLongPress(() => { 81 86 if (isTextBlock[props.type]) return; ··· 115 120 // THIS IS WHERE YOU SET WHETHER OR NOT AREYOUSURE IS TRIGGERED ON THE DELETE KEY 116 121 useBlockKeyboardHandlers(props, areYouSure, setAreYouSure); 117 122 123 + const bindSwipe = useDrag( 124 + ({ last, movement: [mx], event }) => { 125 + if (!last) return; 126 + if (!rep || !props.listData || !entity_set.permissions.write) return; 127 + if (Math.abs(mx) < SWIPE_THRESHOLD) return; 128 + event?.preventDefault(); 129 + let { foldedBlocks, toggleFold } = useUIState.getState(); 130 + if (mx > 0) { 131 + if (props.previousBlock) { 132 + indent(props, props.previousBlock, rep, { 133 + foldedBlocks, 134 + toggleFold, 135 + }); 136 + } 137 + } else { 138 + outdent(props, props.previousBlock, rep, { 139 + foldedBlocks, 140 + toggleFold, 141 + }); 142 + } 143 + }, 144 + { 145 + axis: "x", 146 + filterTaps: true, 147 + pointer: { touch: true }, 148 + enabled: isMobile && !!props.listData, 149 + }, 150 + ); 151 + 118 152 return ( 119 153 <div 120 - {...(!props.preview ? { ...mouseHandlers, ...longPressHandlers } : {})} 154 + {...(!props.preview 155 + ? { ...mouseHandlers, ...longPressHandlers, ...bindSwipe() } 156 + : {})} 121 157 id={ 122 158 !props.preview ? elementId.block(props.entityID).container : undefined 123 159 } ··· 137 173 flex flex-row gap-2 138 174 px-3 sm:px-4 139 175 z-1 w-full 176 + ${props.listData ? "touch-pan-y" : ""} 140 177 ${alignmentStyle} 141 178 ${ 142 179 !props.nextBlock ··· 496 533 className?: string; 497 534 }, 498 535 ) => { 499 - let isMobile = useIsMobile(); 500 536 let checklist = useEntity(props.value, "block/check-list"); 501 537 let listStyle = useEntity(props.value, "block/list-style"); 502 538 let headingLevel = useEntity(props.value, "block/heading-level")?.data.value; ··· 511 547 512 548 let [editingNumber, setEditingNumber] = useState(false); 513 549 let [numberInputValue, setNumberInputValue] = useState(""); 514 - 515 550 useEffect(() => { 516 551 if (!editingNumber) { 517 552 setNumberInputValue(""); ··· 545 580 546 581 setEditingNumber(false); 547 582 }; 583 + 548 584 return ( 549 585 <div 550 586 className={`shrink-0 flex justify-end items-center h-3 z-1
+16
components/Blocks/TextBlock/plugins.ts
··· 1 1 import { Decoration, DecorationSet } from "prosemirror-view"; 2 2 import { Plugin } from "prosemirror-state"; 3 + 3 4 export const highlightSelectionPlugin = new Plugin({ 4 5 state: { 5 6 init(_, { doc }) { 6 7 return DecorationSet.empty; 7 8 }, 8 9 apply(tr, oldDecorations, oldState, newState) { 10 + // Skip selection-only changes to avoid DOM mutations that break 11 + // native selection handle dragging. On blur, we force an update 12 + // so the highlight is visible when focus moves to the toolbar. 13 + if (!tr.docChanged && !tr.getMeta("updateSelectionHighlight")) { 14 + return oldDecorations; 15 + } 16 + 9 17 let decorations = []; 10 18 11 19 // Check if there's a selection ··· 20 28 }, 21 29 }, 22 30 props: { 31 + handleDOMEvents: { 32 + blur(view) { 33 + view.dispatch( 34 + view.state.tr.setMeta("updateSelectionHighlight", true), 35 + ); 36 + return false; 37 + }, 38 + }, 23 39 decorations(state) { 24 40 return this.getState(state); 25 41 },
+30 -20
middleware.ts
··· 1 1 import { AtUri } from "@atproto/syntax"; 2 2 import { createClient } from "@supabase/supabase-js"; 3 + import { getCache } from "@vercel/functions"; 3 4 import { NextRequest, NextResponse } from "next/server"; 4 5 import { Database } from "supabase/database.types"; 5 6 ··· 19 20 let supabase = createClient<Database>( 20 21 process.env.NEXT_PUBLIC_SUPABASE_API_URL as string, 21 22 process.env.SUPABASE_SERVICE_ROLE_KEY as string, 22 - { 23 - global: { 24 - fetch: async (...args) => { 25 - const response = await fetch(args[0], { 26 - ...args[1], 27 - next: { 28 - revalidate: 60, 29 - }, 30 - }); 31 - return response; 32 - }, 33 - }, 34 - }, 35 23 ); 36 24 25 + const cache = getCache(); 26 + 27 + async function getDomainRoutes(hostname: string) { 28 + let { data } = await supabase 29 + .from("custom_domains") 30 + .select( 31 + "*, custom_domain_routes(*), publication_domains(*, publications(*))", 32 + ) 33 + .eq("domain", hostname) 34 + .single(); 35 + return data; 36 + } 37 + type DomainRoutes = Awaited<ReturnType<typeof getDomainRoutes>>; 38 + 37 39 const auth_callback_route = "/auth_callback"; 38 40 const receive_auth_callback_route = "/receive_auth_callback"; 39 41 export default async function middleware(req: NextRequest) { ··· 44 46 45 47 if (hostname === "leaflet.pub") return; 46 48 if (req.nextUrl.pathname === "/not-found") return; 47 - let { data: routes } = await supabase 48 - .from("custom_domains") 49 - .select( 50 - "*, custom_domain_routes(*), publication_domains(*, publications(*))", 51 - ) 52 - .eq("domain", hostname) 53 - .single(); 49 + let routes: DomainRoutes = null; 50 + try { 51 + routes = (await cache.get(`domain:${hostname}`)) as DomainRoutes; 52 + } catch {} 53 + if (!routes) { 54 + routes = await getDomainRoutes(hostname); 55 + if (routes) { 56 + try { 57 + await cache.set(`domain:${hostname}`, routes, { 58 + ttl: 60, 59 + tags: [`domain:${hostname}`], 60 + }); 61 + } catch {} 62 + } 63 + } 54 64 55 65 let pub = routes?.publication_domains[0]?.publications; 56 66 if (pub) {
+19
package-lock.json
··· 36 36 "@tinybirdco/sdk": "^0.0.55", 37 37 "@tiptap/core": "^2.11.5", 38 38 "@types/mdx": "^2.0.13", 39 + "@use-gesture/react": "^10.3.1", 39 40 "@vercel/analytics": "^1.5.0", 40 41 "@vercel/functions": "^2.2.12", 41 42 "@vercel/sdk": "^1.11.4", ··· 9008 9009 "version": "1.2.0", 9009 9010 "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.2.0.tgz", 9010 9011 "integrity": "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==" 9012 + }, 9013 + "node_modules/@use-gesture/core": { 9014 + "version": "10.3.1", 9015 + "resolved": "https://registry.npmjs.org/@use-gesture/core/-/core-10.3.1.tgz", 9016 + "integrity": "sha512-WcINiDt8WjqBdUXye25anHiNxPc0VOrlT8F6LLkU6cycrOGUDyY/yyFmsg3k8i5OLvv25llc0QC45GhR/C8llw==", 9017 + "license": "MIT" 9018 + }, 9019 + "node_modules/@use-gesture/react": { 9020 + "version": "10.3.1", 9021 + "resolved": "https://registry.npmjs.org/@use-gesture/react/-/react-10.3.1.tgz", 9022 + "integrity": "sha512-Yy19y6O2GJq8f7CHf7L0nxL8bf4PZCPaVOCgJrusOeFHY1LvHgYXnmnXg6N5iwAnbgbZCDjo60SiM6IPJi9C5g==", 9023 + "license": "MIT", 9024 + "dependencies": { 9025 + "@use-gesture/core": "10.3.1" 9026 + }, 9027 + "peerDependencies": { 9028 + "react": ">= 16.8.0" 9029 + } 9011 9030 }, 9012 9031 "node_modules/@vercel/analytics": { 9013 9032 "version": "1.5.0",
+1
package.json
··· 47 47 "@tinybirdco/sdk": "^0.0.55", 48 48 "@tiptap/core": "^2.11.5", 49 49 "@types/mdx": "^2.0.13", 50 + "@use-gesture/react": "^10.3.1", 50 51 "@vercel/analytics": "^1.5.0", 51 52 "@vercel/functions": "^2.2.12", 52 53 "@vercel/sdk": "^1.11.4",
+100
supabase/migrations/20260325000000_add_get_leaflet_page_data.sql
··· 1 + CREATE OR REPLACE FUNCTION public.get_leaflet_page_data(p_token_id uuid) 2 + RETURNS TABLE ( 3 + permission_token json, 4 + permission_token_rights json, 5 + leaflets_in_publications json, 6 + leaflets_to_documents json, 7 + custom_domain_routes json, 8 + facts json 9 + ) 10 + LANGUAGE sql STABLE 11 + AS $$ 12 + WITH token AS ( 13 + SELECT pt.* 14 + FROM permission_tokens pt 15 + WHERE pt.id = p_token_id 16 + ), 17 + token_rights AS ( 18 + SELECT json_agg(row_to_json(ptr)) AS rights, array_agg(ptr.entity_set) AS entity_sets 19 + FROM permission_token_rights ptr 20 + WHERE ptr.token = p_token_id 21 + ), 22 + related_tokens AS ( 23 + SELECT array_agg(pt2.id) AS ids 24 + FROM permission_token_rights ptr2 25 + JOIN permission_tokens pt2 ON pt2.id = ptr2.token 26 + WHERE ptr2.entity_set IN (SELECT unnest(entity_sets) FROM token_rights) 27 + AND pt2.id != p_token_id 28 + ), 29 + lip_direct AS ( 30 + SELECT json_agg(row_to_json(sub)) AS data 31 + FROM ( 32 + SELECT lip.*, 33 + row_to_json(pub) AS publications, 34 + row_to_json(d) AS documents 35 + FROM leaflets_in_publications lip 36 + LEFT JOIN publications pub ON pub.uri = lip.publication 37 + LEFT JOIN documents d ON d.uri = lip.doc 38 + WHERE lip.leaflet = p_token_id 39 + ) sub 40 + ), 41 + lip_fallback AS ( 42 + SELECT json_agg(row_to_json(sub)) AS data 43 + FROM ( 44 + SELECT lip.*, 45 + row_to_json(pub) AS publications, 46 + row_to_json(d) AS documents 47 + FROM leaflets_in_publications lip 48 + LEFT JOIN publications pub ON pub.uri = lip.publication 49 + LEFT JOIN documents d ON d.uri = lip.doc 50 + WHERE lip.leaflet IN (SELECT unnest(ids) FROM related_tokens) 51 + ) sub 52 + ), 53 + ltd_direct AS ( 54 + SELECT json_agg(row_to_json(sub)) AS data 55 + FROM ( 56 + SELECT ltd.*, 57 + row_to_json(doc) AS documents 58 + FROM leaflets_to_documents ltd 59 + LEFT JOIN documents doc ON doc.uri = ltd.document 60 + WHERE ltd.leaflet = p_token_id 61 + ) sub 62 + ), 63 + ltd_fallback AS ( 64 + SELECT json_agg(row_to_json(sub)) AS data 65 + FROM ( 66 + SELECT ltd.*, 67 + row_to_json(doc) AS documents 68 + FROM leaflets_to_documents ltd 69 + LEFT JOIN documents doc ON doc.uri = ltd.document 70 + WHERE ltd.leaflet IN (SELECT unnest(ids) FROM related_tokens) 71 + ) sub 72 + ), 73 + cdr AS ( 74 + SELECT json_agg(row_to_json(c)) AS data 75 + FROM custom_domain_routes c 76 + WHERE c.edit_permission_token = p_token_id 77 + ), 78 + facts AS ( 79 + SELECT json_agg(row_to_json(f)) AS data 80 + FROM get_facts((SELECT root_entity FROM token)) f 81 + ) 82 + SELECT 83 + row_to_json(token) AS permission_token, 84 + (SELECT rights FROM token_rights) AS permission_token_rights, 85 + COALESCE((SELECT data FROM lip_direct), (SELECT data FROM lip_fallback)) AS leaflets_in_publications, 86 + COALESCE((SELECT data FROM ltd_direct), (SELECT data FROM ltd_fallback)) AS leaflets_to_documents, 87 + (SELECT data FROM cdr) AS custom_domain_routes, 88 + (SELECT data FROM facts) AS facts 89 + FROM token; 90 + $$; 91 + 92 + -- Indexes for get_leaflet_page_data query patterns 93 + CREATE INDEX IF NOT EXISTS leaflets_in_publications_leaflet_idx 94 + ON public.leaflets_in_publications(leaflet); 95 + 96 + CREATE INDEX IF NOT EXISTS permission_token_rights_entity_set_idx 97 + ON public.permission_token_rights(entity_set); 98 + 99 + CREATE INDEX IF NOT EXISTS custom_domain_routes_edit_permission_token_idx 100 + ON public.custom_domain_routes(edit_permission_token);