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)
))}
)}
+ {post.linkPreviewUrl && (
+
+ {post.linkPreviewImage && (
+
+

+
+ )}
+
+
{post.linkPreviewTitle}
+ {post.linkPreviewDescription && (
+
{post.linkPreviewDescription}
+ )}
+
+ {new URL(post.linkPreviewUrl.startsWith('http') ? post.linkPreviewUrl : `https://${post.linkPreviewUrl}`).hostname}
+
+
+
+ )}
)}
+
+ {linkPreview && (
+
+
+
+ {linkPreview.image && (
+
+

+
+ )}
+
+
{linkPreview.title}
+
{new URL(linkPreview.url.startsWith('http') ? linkPreview.url : `https://${linkPreview.url}`).hostname}
+
+
+
+ )}
+
{uploadError && (
{uploadError}
)}
@@ -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) => [