From 67a97ea8c4c4c946b929907aac5542fd11546792 Mon Sep 17 00:00:00 2001 From: Christopher Date: Thu, 22 Jan 2026 13:14:58 -0800 Subject: [PATCH] Rich media link previews --- src/app/api/media/preview/route.ts | 56 +++++++++++++++++ src/app/api/posts/route.ts | 12 +++- src/app/globals.css | 99 +++++++++++++++++++++++++++++- src/app/page.tsx | 91 +++++++++++++++++++++++++-- src/db/schema.ts | 5 ++ 5 files changed, 257 insertions(+), 6 deletions(-) create mode 100644 src/app/api/media/preview/route.ts diff --git a/src/app/api/media/preview/route.ts b/src/app/api/media/preview/route.ts new file mode 100644 index 0000000..61ad057 --- /dev/null +++ b/src/app/api/media/preview/route.ts @@ -0,0 +1,56 @@ +import { NextRequest, NextResponse } from 'next/server'; + +export async function GET(req: NextRequest) { + try { + const { searchParams } = new URL(req.url); + let url = searchParams.get('url'); + + if (!url) { + return NextResponse.json({ error: 'No URL provided' }, { status: 400 }); + } + + // Normalize URL - handle synapse.social etc + if (!url.startsWith('http://') && !url.startsWith('https://')) { + url = 'https://' + url; + } + + const response = await fetch(url, { + headers: { + 'User-Agent': 'SynapsisBot/1.0', + }, + signal: AbortSignal.timeout(5000), // 5s timeout + }); + + if (!response.ok) { + throw new Error(`Failed to fetch URL: ${response.status}`); + } + + const html = await response.text(); + + // Simple regex extraction for OG tags + const getMeta = (property: string) => { + const regex = new RegExp(`]+(?:property|name)=["'](?:og:)?${property}["'][^>]+content=["']([^"']+)["']`, 'i'); + const match = html.match(regex); + if (match) return match[1]; + + // Try different order + const regexRev = new RegExp(`]+content=["']([^"']+)["'][^>]+(?:property|name)=["'](?:og:)?${property}["']`, 'i'); + const matchRev = html.match(regexRev); + return matchRev ? matchRev[1] : null; + }; + + const title = getMeta('title') || html.match(/([^<]+)<\/title>/i)?.[1]; + const description = getMeta('description'); + const image = getMeta('image'); + + return NextResponse.json({ + url, + title: title?.trim() || url, + description: description?.trim() || null, + image: image?.trim() || null, + }); + } catch (error) { + console.error('Link preview error:', error); + return NextResponse.json({ error: 'Failed to fetch preview' }, { status: 500 }); + } +} diff --git a/src/app/api/posts/route.ts b/src/app/api/posts/route.ts index a36e105..e5e5ef7 100644 --- a/src/app/api/posts/route.ts +++ b/src/app/api/posts/route.ts @@ -20,6 +20,12 @@ const createPostSchema = z.object({ content: z.string().min(1).max(POST_MAX_LENGTH), replyToId: z.string().uuid().optional(), mediaIds: z.array(z.string().uuid()).max(4).optional(), + linkPreview: z.object({ + url: z.string().url(), + title: z.string().optional(), + description: z.string().optional(), + image: z.string().url().optional().nullable(), + }).optional(), }); // Create a new post @@ -41,6 +47,10 @@ export async function POST(request: Request) { replyToId: data.replyToId, apId: `https://${nodeDomain}/posts/${crypto.randomUUID()}`, apUrl: `https://${nodeDomain}/posts/${crypto.randomUUID()}`, + linkPreviewUrl: data.linkPreview?.url, + linkPreviewTitle: data.linkPreview?.title, + linkPreviewDescription: data.linkPreview?.description, + linkPreviewImage: data.linkPreview?.image, }).returning(); let attachedMedia: typeof media.$inferSelect[] = []; @@ -276,7 +286,7 @@ export async function GET(request: Request) { // Get posts from people the user follows + their own posts // For now, just return all posts (we'll add following filter later) - feedPosts = await db.query.posts.findMany({ + feedPosts = await db.query.posts.findMany({ where: baseFilter, with: { author: true, diff --git a/src/app/globals.css b/src/app/globals.css index e075d03..d6c2590 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1335,4 +1335,101 @@ a.btn-primary:visited { ::-webkit-scrollbar-thumb:hover { background-color: var(--foreground-secondary); -} \ No newline at end of file +} +/* Link Preview Styles */ +.link-preview-card { + display: block; + margin-top: 12px; + border: 1px solid var(--border); + border-radius: var(--radius-md); + overflow: hidden; + text-decoration: none; + background: var(--background-secondary); + transition: border-color 0.2s; +} + +.link-preview-card:hover { + border-color: var(--accent); +} + +.link-preview-image img { + width: 100%; + max-height: 240px; + object-fit: cover; + border-bottom: 1px solid var(--border); +} + +.link-preview-info { + padding: 12px; +} + +.link-preview-title { + font-weight: 600; + color: var(--foreground); + margin-bottom: 4px; + font-size: 15px; +} + +.link-preview-description { + font-size: 13px; + color: var(--foreground-secondary); + margin-bottom: 8px; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.link-preview-url { + font-size: 12px; + color: var(--foreground-tertiary); + text-transform: lowercase; +} + +/* Compose Preview */ +.compose-link-preview { + margin-top: 12px; + position: relative; +} + +.compose-link-preview-remove { + position: absolute; + top: 8px; + right: 8px; + width: 20px; + height: 20px; + background: rgba(0,0,0,0.6); + color: #fff; + border: none; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + z-index: 5; + font-size: 12px; +} + +.link-preview-card.mini { + display: flex; + max-height: 80px; +} + +.link-preview-card.mini .link-preview-image { + width: 80px; + min-width: 80px; + height: 80px; +} + +.link-preview-card.mini .link-preview-image img { + height: 80px; + border-bottom: none; + border-right: 1px solid var(--border); +} + +.link-preview-card.mini .link-preview-info { + padding: 8px 12px; + display: flex; + flex-direction: column; + justify-content: center; +} diff --git a/src/app/page.tsx b/src/app/page.tsx index 26a7638..d469867 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -38,6 +38,10 @@ interface Post { author: User; media?: MediaItem[]; feedMeta?: FeedMeta; + linkPreviewUrl?: string | null; + linkPreviewTitle?: string | null; + linkPreviewDescription?: string | null; + linkPreviewImage?: string | null; } // Icons as simple SVG components @@ -166,6 +170,24 @@ function PostCard({ post, onLike, onRepost }: { post: Post; onLike: (id: string) ))} </div> )} + {post.linkPreviewUrl && ( + <a href={post.linkPreviewUrl} target="_blank" rel="noopener noreferrer" className="link-preview-card"> + {post.linkPreviewImage && ( + <div className="link-preview-image"> + <img src={post.linkPreviewImage} alt={post.linkPreviewTitle || ''} /> + </div> + )} + <div className="link-preview-info"> + <div className="link-preview-title">{post.linkPreviewTitle}</div> + {post.linkPreviewDescription && ( + <div className="link-preview-description">{post.linkPreviewDescription}</div> + )} + <div className="link-preview-url"> + {new URL(post.linkPreviewUrl.startsWith('http') ? post.linkPreviewUrl : `https://${post.linkPreviewUrl}`).hostname} + </div> + </div> + </a> + )} <div className="post-actions"> <button className="post-action" onClick={() => { }}> <MessageIcon /> @@ -194,21 +216,58 @@ type Attachment = { altText?: string | null; }; -function Compose({ onPost }: { onPost: (content: string, mediaIds: string[]) => void }) { +function Compose({ onPost }: { onPost: (content: string, mediaIds: string[], linkPreview?: any) => void }) { const [content, setContent] = useState(''); const [isPosting, setIsPosting] = useState(false); const [attachments, setAttachments] = useState<Attachment[]>([]); const [isUploading, setIsUploading] = useState(false); const [uploadError, setUploadError] = useState<string | null>(null); + const [linkPreview, setLinkPreview] = useState<any>(null); + const [fetchingPreview, setFetchingPreview] = useState(false); + const [lastDetectedUrl, setLastDetectedUrl] = useState<string | null>(null); const maxLength = 400; const remaining = maxLength - content.length; + // Detect URLs in content + useEffect(() => { + const urlRegex = /(?:https?:\/\/)?(?:www\.)?([a-zA-Z0-9-]+\.[a-zA-Z0-9.-]+\.[a-z]{2,63})\b([-a-zA-Z0-9@:%_\+.~#?&//=]*)/gi; + const matches = content.match(urlRegex); + + if (matches && matches[0]) { + const url = matches[0]; + if (url !== lastDetectedUrl) { + setLastDetectedUrl(url); + fetchPreview(url); + } + } else if (!content.trim()) { + setLinkPreview(null); + setLastDetectedUrl(null); + } + }, [content]); + + const fetchPreview = async (url: string) => { + setFetchingPreview(true); + try { + const res = await fetch(`/api/media/preview?url=${encodeURIComponent(url)}`); + if (res.ok) { + const data = await res.json(); + setLinkPreview(data); + } + } catch (err) { + console.error('Preview error', err); + } finally { + setFetchingPreview(false); + } + }; + const handleSubmit = async () => { if (!content.trim() || isPosting || isUploading) return; setIsPosting(true); - await onPost(content, attachments.map((item) => item.id).filter(Boolean)); + await onPost(content, attachments.map((item) => item.id).filter(Boolean), linkPreview); setContent(''); setAttachments([]); + setLinkPreview(null); + setLastDetectedUrl(null); setIsPosting(false); }; @@ -284,6 +343,30 @@ function Compose({ onPost }: { onPost: (content: string, mediaIds: string[]) => ))} </div> )} + + {linkPreview && ( + <div className="compose-link-preview"> + <button + type="button" + className="compose-link-preview-remove" + onClick={() => setLinkPreview(null)} + > + x + </button> + <div className="link-preview-card mini"> + {linkPreview.image && ( + <div className="link-preview-image"> + <img src={linkPreview.image} alt="" /> + </div> + )} + <div className="link-preview-info"> + <div className="link-preview-title">{linkPreview.title}</div> + <div className="link-preview-url">{new URL(linkPreview.url.startsWith('http') ? linkPreview.url : `https://${linkPreview.url}`).hostname}</div> + </div> + </div> + </div> + )} + {uploadError && ( <div className="compose-media-error">{uploadError}</div> )} @@ -353,11 +436,11 @@ export default function Home() { loadFeed(feedType); }, [feedType]); - const handlePost = async (content: string, mediaIds: string[]) => { + const handlePost = async (content: string, mediaIds: string[], linkPreview?: any) => { const res = await fetch('/api/posts', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ content, mediaIds }), + body: JSON.stringify({ content, mediaIds, linkPreview }), }); if (res.ok) { diff --git a/src/db/schema.ts b/src/db/schema.ts index 8dfa95a..d794193 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -88,6 +88,11 @@ export const posts = pgTable('posts', { // ActivityPub apId: text('ap_id').unique(), // https://node.com/posts/uuid apUrl: text('ap_url'), // Public URL for the post + // Link Preview + linkPreviewUrl: text('link_preview_url'), + linkPreviewTitle: text('link_preview_title'), + linkPreviewDescription: text('link_preview_description'), + linkPreviewImage: text('link_preview_image'), createdAt: timestamp('created_at').defaultNow().notNull(), updatedAt: timestamp('updated_at').defaultNow().notNull(), }, (table) => [