a tool for shared writing and social publishing

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

+179 -112
+49 -18
app/[leaflet_id]/publish/BskyPostEditorProsemirror.tsx
··· 148 148 const pos = view.state.selection.from; 149 149 setMentionInsertPos(pos); 150 150 const coords = view.coordsAtPos(pos - 1); 151 - setMentionCoords({ 152 - top: coords.bottom + window.scrollY, 153 - left: coords.left + window.scrollX, 154 - }); 151 + 152 + // Get coordinates relative to the positioned parent container 153 + const editorEl = view.dom; 154 + const container = editorEl.closest(".relative") as HTMLElement | null; 155 + 156 + if (container) { 157 + const containerRect = container.getBoundingClientRect(); 158 + setMentionCoords({ 159 + top: coords.bottom - containerRect.top, 160 + left: coords.left - containerRect.left, 161 + }); 162 + } else { 163 + setMentionCoords({ 164 + top: coords.bottom, 165 + left: coords.left, 166 + }); 167 + } 155 168 setMentionOpen(true); 156 169 }, []); 157 170 158 171 const handleMentionSelect = useCallback( 159 172 (mention: Mention) => { 160 - if (mention.type !== "did") return; 161 173 if (!viewRef.current || mentionInsertPos === null) return; 162 174 const view = viewRef.current; 163 175 const from = mentionInsertPos - 1; ··· 167 179 // Delete the @ symbol 168 180 tr.delete(from, to); 169 181 170 - // Insert @handle 171 - const mentionText = "@" + mention.handle; 172 - tr.insertText(mentionText, from); 173 - 174 - // Apply mention mark 175 - tr.addMark( 176 - from, 177 - from + mentionText.length, 178 - bskyPostSchema.marks.mention.create({ did: mention.did }), 179 - ); 180 - 181 - // Add a space after the mention 182 - tr.insertText(" ", from + mentionText.length); 182 + if (mention.type === "did") { 183 + // Insert @handle with mention mark 184 + const mentionText = "@" + mention.handle; 185 + tr.insertText(mentionText, from); 186 + tr.addMark( 187 + from, 188 + from + mentionText.length, 189 + bskyPostSchema.marks.mention.create({ did: mention.did }), 190 + ); 191 + tr.insertText(" ", from + mentionText.length); 192 + } else if (mention.type === "publication") { 193 + // Insert publication name as a link 194 + const linkText = mention.name; 195 + tr.insertText(linkText, from); 196 + tr.addMark( 197 + from, 198 + from + linkText.length, 199 + bskyPostSchema.marks.link.create({ href: mention.url }), 200 + ); 201 + tr.insertText(" ", from + linkText.length); 202 + } else if (mention.type === "post") { 203 + // Insert post title as a link 204 + const linkText = mention.title; 205 + tr.insertText(linkText, from); 206 + tr.addMark( 207 + from, 208 + from + linkText.length, 209 + bskyPostSchema.marks.link.create({ href: mention.url }), 210 + ); 211 + tr.insertText(" ", from + linkText.length); 212 + } 183 213 184 214 view.dispatch(tr); 185 215 view.focus(); ··· 270 300 view={viewRef} 271 301 onSelect={handleMentionSelect} 272 302 coords={mentionCoords} 303 + placeholder="Search people..." 273 304 /> 274 305 {editorState?.doc.textContent.length === 0 && ( 275 306 <div className="italic text-tertiary absolute top-0 left-0 pointer-events-none">
+16 -5
app/api/rpc/[command]/search_publication_documents.ts
··· 1 + import { AtUri } from "@atproto/api"; 1 2 import { z } from "zod"; 2 3 import { makeRoute } from "../lib"; 3 4 import type { Env } from "./route"; 5 + import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; 4 6 5 7 export type SearchPublicationDocumentsReturnType = Awaited< 6 8 ReturnType<(typeof search_publication_documents)["handler"]> ··· 18 20 { supabase }: Pick<Env, "supabase">, 19 21 ) => { 20 22 // Get documents in the publication, filtering by title using JSON operator 23 + // Also join with publications to get the record for URL construction 21 24 const { data: documents, error } = await supabase 22 25 .from("documents_in_publications") 23 - .select("document, documents!inner(uri, data)") 26 + .select( 27 + "document, documents!inner(uri, data), publications!inner(uri, record)", 28 + ) 24 29 .eq("publication", publication_uri) 25 30 .ilike("documents.data->>title", `%${query}%`) 26 31 .limit(limit); ··· 31 36 ); 32 37 } 33 38 34 - const result = documents.map((d) => ({ 35 - uri: d.documents.uri, 36 - title: (d.documents.data as { title?: string })?.title || "Untitled", 37 - })); 39 + const result = documents.map((d) => { 40 + const docUri = new AtUri(d.documents.uri); 41 + const pubUrl = getPublicationURL(d.publications); 42 + 43 + return { 44 + uri: d.documents.uri, 45 + title: (d.documents.data as { title?: string })?.title || "Untitled", 46 + url: `${pubUrl}/${docUri.rkey}`, 47 + }; 48 + }); 38 49 39 50 return { result: { documents: result } }; 40 51 },
+10 -8
app/api/rpc/[command]/search_publication_names.ts
··· 1 1 import { z } from "zod"; 2 2 import { makeRoute } from "../lib"; 3 3 import type { Env } from "./route"; 4 + import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; 4 5 5 6 export type SearchPublicationNamesReturnType = Awaited< 6 7 ReturnType<(typeof search_publication_names)["handler"]> ··· 12 13 query: z.string(), 13 14 limit: z.number().optional().default(10), 14 15 }), 15 - handler: async ( 16 - { query, limit }, 17 - { supabase }: Pick<Env, "supabase">, 18 - ) => { 16 + handler: async ({ query, limit }, { supabase }: Pick<Env, "supabase">) => { 19 17 // Search publications by name in record (case-insensitive partial match) 20 18 const { data: publications, error } = await supabase 21 19 .from("publications") ··· 27 25 throw new Error(`Failed to search publications: ${error.message}`); 28 26 } 29 27 30 - const result = publications.map((p) => ({ 31 - uri: p.uri, 32 - name: (p.record as { name?: string })?.name || "Untitled", 33 - })); 28 + const result = publications.map((p) => { 29 + const record = p.record as { name?: string }; 30 + return { 31 + uri: p.uri, 32 + name: record.name || "Untitled", 33 + url: getPublicationURL(p), 34 + }; 35 + }); 34 36 35 37 return { result: { publications: result } }; 36 38 },
+8
app/lish/[did]/[publication]/[rkey]/page.tsx
··· 25 25 26 26 return { 27 27 icons: { 28 + icon: { 29 + url: 30 + process.env.NODE_ENV === "development" 31 + ? `/lish/${did}/${params.publication}/icon` 32 + : "/icon", 33 + sizes: "32x32", 34 + type: "image/png", 35 + }, 28 36 other: { 29 37 rel: "alternate", 30 38 url: document.uri,
+7 -8
app/lish/[did]/[publication]/icon.ts app/lish/[did]/[publication]/icon/route.ts
··· 8 8 9 9 let idResolver = new IdResolver(); 10 10 11 - export const size = { 12 - width: 32, 13 - height: 32, 14 - }; 11 + export const dynamic = "force-dynamic"; 15 12 16 - export const contentType = "image/png"; 17 - export default async function Icon(props: { 18 - params: Promise<{ did: string; publication: string }>; 19 - }) { 13 + export async function GET( 14 + request: NextRequest, 15 + props: { params: Promise<{ did: string; publication: string }> } 16 + ) { 17 + console.log("are we getting here?"); 20 18 const params = await props.params; 21 19 try { 22 20 let did = decodeURIComponent(params.did); ··· 63 61 }, 64 62 }); 65 63 } catch (e) { 64 + console.log(e); 66 65 return redirect("/icon.png"); 67 66 } 68 67 }
+8
app/lish/[did]/[publication]/layout.tsx
··· 47 47 title: pubRecord?.name || "Untitled Publication", 48 48 description: pubRecord?.description || "", 49 49 icons: { 50 + icon: { 51 + url: 52 + process.env.NODE_ENV === "development" 53 + ? `/lish/${did}/${publication_name}/icon` 54 + : "/icon", 55 + sizes: "32x32", 56 + type: "image/png", 57 + }, 50 58 other: { 51 59 rel: "alternate", 52 60 url: publication.uri,
+71 -68
components/Mention.tsx
··· 18 18 view: React.RefObject<EditorView | null>; 19 19 onSelect: (mention: Mention) => void; 20 20 coords: { top: number; left: number } | null; 21 + placeholder?: string; 21 22 }) { 22 23 const [searchQuery, setSearchQuery] = useState(""); 23 24 const [noResults, setNoResults] = useState(false); ··· 207 208 placeholder={ 208 209 scope.type === "publication" 209 210 ? "Search posts..." 210 - : "Search people & publications..." 211 + : props.placeholder ?? "Search people & publications..." 211 212 } 212 213 className="flex-1 w-full min-w-0 bg-transparent border-none outline-none text-sm placeholder:text-tertiary" 213 214 /> ··· 219 220 No results found 220 221 </div> 221 222 )} 222 - <ul className="list-none p-0 text-sm flex flex-col group-data-[side=top]/mention-menu:flex-col-reverse"> 223 - {sortedSuggestions.map((result, index) => { 224 - const prevResult = sortedSuggestions[index - 1]; 225 - const showHeader = 226 - index === 0 || 227 - (prevResult && prevResult.type !== result.type); 223 + <ul className="list-none p-0 text-sm flex flex-col group-data-[side=top]/mention-menu:flex-col-reverse"> 224 + {sortedSuggestions.map((result, index) => { 225 + const prevResult = sortedSuggestions[index - 1]; 226 + const showHeader = 227 + index === 0 || 228 + (prevResult && prevResult.type !== result.type); 228 229 229 - return ( 230 - <Fragment 231 - key={result.type === "did" ? result.did : result.uri} 232 - > 233 - {showHeader && ( 234 - <> 235 - {index > 0 && ( 236 - <hr className="border-border-light mx-1 my-1" /> 237 - )} 238 - <div className="text-xs text-tertiary font-bold pt-1 px-2"> 239 - {getHeader(result.type, scope)} 240 - </div> 241 - </> 242 - )} 243 - {result.type === "did" ? ( 244 - <DidResult 245 - onClick={() => { 246 - props.onSelect(result); 247 - props.onOpenChange(false); 248 - }} 249 - onMouseDown={(e) => e.preventDefault()} 250 - displayName={result.displayName} 251 - handle={result.handle} 252 - avatar={result.avatar} 253 - selected={index === suggestionIndex} 254 - /> 255 - ) : result.type === "publication" ? ( 256 - <PublicationResult 257 - onClick={() => { 258 - props.onSelect(result); 259 - props.onOpenChange(false); 260 - }} 261 - onMouseDown={(e) => e.preventDefault()} 262 - pubName={result.name} 263 - uri={result.uri} 264 - selected={index === suggestionIndex} 265 - onPostsClick={() => { 266 - handleScopeChange({ 267 - type: "publication", 268 - uri: result.uri, 269 - name: result.name, 270 - }); 271 - }} 272 - /> 273 - ) : ( 274 - <PostResult 275 - onClick={() => { 276 - props.onSelect(result); 277 - props.onOpenChange(false); 278 - }} 279 - onMouseDown={(e) => e.preventDefault()} 280 - title={result.title} 281 - selected={index === suggestionIndex} 282 - /> 283 - )} 284 - </Fragment> 285 - ); 286 - })} 287 - </ul> 230 + return ( 231 + <Fragment 232 + key={result.type === "did" ? result.did : result.uri} 233 + > 234 + {showHeader && ( 235 + <> 236 + {index > 0 && ( 237 + <hr className="border-border-light mx-1 my-1" /> 238 + )} 239 + <div className="text-xs text-tertiary font-bold pt-1 px-2"> 240 + {getHeader(result.type, scope)} 241 + </div> 242 + </> 243 + )} 244 + {result.type === "did" ? ( 245 + <DidResult 246 + onClick={() => { 247 + props.onSelect(result); 248 + props.onOpenChange(false); 249 + }} 250 + onMouseDown={(e) => e.preventDefault()} 251 + displayName={result.displayName} 252 + handle={result.handle} 253 + avatar={result.avatar} 254 + selected={index === suggestionIndex} 255 + /> 256 + ) : result.type === "publication" ? ( 257 + <PublicationResult 258 + onClick={() => { 259 + props.onSelect(result); 260 + props.onOpenChange(false); 261 + }} 262 + onMouseDown={(e) => e.preventDefault()} 263 + pubName={result.name} 264 + uri={result.uri} 265 + selected={index === suggestionIndex} 266 + onPostsClick={() => { 267 + handleScopeChange({ 268 + type: "publication", 269 + uri: result.uri, 270 + name: result.name, 271 + }); 272 + }} 273 + /> 274 + ) : ( 275 + <PostResult 276 + onClick={() => { 277 + props.onSelect(result); 278 + props.onOpenChange(false); 279 + }} 280 + onMouseDown={(e) => e.preventDefault()} 281 + title={result.title} 282 + selected={index === suggestionIndex} 283 + /> 284 + )} 285 + </Fragment> 286 + ); 287 + })} 288 + </ul> 288 289 </div> 289 290 </Popover.Content> 290 291 </Popover.Portal> ··· 456 457 displayName?: string; 457 458 avatar?: string; 458 459 } 459 - | { type: "publication"; uri: string; name: string } 460 - | { type: "post"; uri: string; title: string }; 460 + | { type: "publication"; uri: string; name: string; url: string } 461 + | { type: "post"; uri: string; title: string; url: string }; 461 462 462 463 export type MentionScope = 463 464 | { type: "default" } ··· 492 493 type: "post" as const, 493 494 uri: d.uri, 494 495 title: d.title, 496 + url: d.url, 495 497 })), 496 498 ); 497 499 } else { ··· 516 518 type: "publication" as const, 517 519 uri: p.uri, 518 520 name: p.name, 521 + url: p.url, 519 522 })), 520 523 ]); 521 524 }
+3 -1
components/ThemeManager/PublicationThemeProvider.tsx
··· 84 84 <div 85 85 className="PubBackgroundWrapper w-full bg-bg-leaflet text-primary h-full flex flex-col bg-cover bg-center bg-no-repeat items-stretch" 86 86 style={{ 87 - backgroundImage: `url(${backgroundImage})`, 87 + backgroundImage: backgroundImage 88 + ? `url(${backgroundImage})` 89 + : undefined, 88 90 backgroundRepeat: backgroundImageRepeat ? "repeat" : "no-repeat", 89 91 backgroundSize: `${backgroundImageRepeat ? `${backgroundImageSize}px` : "cover"}`, 90 92 }}
+3 -1
src/hooks/useLocalizedDate.ts
··· 28 28 29 29 // On initial page load, use header timezone. After hydration, use system timezone 30 30 const effectiveTimezone = !hasPageLoaded 31 - ? timezone 31 + ? timezone || "UTC" 32 32 : Intl.DateTimeFormat().resolvedOptions().timeZone; 33 + 34 + console.log("tz", effectiveTimezone); 33 35 34 36 // Apply timezone if available 35 37 if (effectiveTimezone) {
+4 -3
src/hooks/usePreserveScroll.ts
··· 6 6 useEffect(() => { 7 7 if (!ref.current || !key) return; 8 8 9 - window.requestAnimationFrame(() => { 10 - ref.current?.scrollTo({ top: scrollPositions[key] || 0 }); 11 - }); 9 + if (scrollPositions[key] !== undefined) 10 + window.requestAnimationFrame(() => { 11 + ref.current?.scrollTo({ top: scrollPositions[key] || 0 }); 12 + }); 12 13 13 14 const listener = () => { 14 15 if (!ref.current?.scrollTop) return;