Rich media link previews
This commit is contained in:
@@ -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(`<meta[^>]+(?:property|name)=["'](?:og:)?${property}["'][^>]+content=["']([^"']+)["']`, 'i');
|
||||||
|
const match = html.match(regex);
|
||||||
|
if (match) return match[1];
|
||||||
|
|
||||||
|
// Try different order
|
||||||
|
const regexRev = new RegExp(`<meta[^>]+content=["']([^"']+)["'][^>]+(?:property|name)=["'](?:og:)?${property}["']`, 'i');
|
||||||
|
const matchRev = html.match(regexRev);
|
||||||
|
return matchRev ? matchRev[1] : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const title = getMeta('title') || html.match(/<title>([^<]+)<\/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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,6 +20,12 @@ const createPostSchema = z.object({
|
|||||||
content: z.string().min(1).max(POST_MAX_LENGTH),
|
content: z.string().min(1).max(POST_MAX_LENGTH),
|
||||||
replyToId: z.string().uuid().optional(),
|
replyToId: z.string().uuid().optional(),
|
||||||
mediaIds: z.array(z.string().uuid()).max(4).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
|
// Create a new post
|
||||||
@@ -41,6 +47,10 @@ export async function POST(request: Request) {
|
|||||||
replyToId: data.replyToId,
|
replyToId: data.replyToId,
|
||||||
apId: `https://${nodeDomain}/posts/${crypto.randomUUID()}`,
|
apId: `https://${nodeDomain}/posts/${crypto.randomUUID()}`,
|
||||||
apUrl: `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();
|
}).returning();
|
||||||
|
|
||||||
let attachedMedia: typeof media.$inferSelect[] = [];
|
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
|
// Get posts from people the user follows + their own posts
|
||||||
// For now, just return all posts (we'll add following filter later)
|
// 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,
|
where: baseFilter,
|
||||||
with: {
|
with: {
|
||||||
author: true,
|
author: true,
|
||||||
|
|||||||
+98
-1
@@ -1335,4 +1335,101 @@ a.btn-primary:visited {
|
|||||||
|
|
||||||
::-webkit-scrollbar-thumb:hover {
|
::-webkit-scrollbar-thumb:hover {
|
||||||
background-color: var(--foreground-secondary);
|
background-color: var(--foreground-secondary);
|
||||||
}
|
}
|
||||||
|
/* 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;
|
||||||
|
}
|
||||||
|
|||||||
+87
-4
@@ -38,6 +38,10 @@ interface Post {
|
|||||||
author: User;
|
author: User;
|
||||||
media?: MediaItem[];
|
media?: MediaItem[];
|
||||||
feedMeta?: FeedMeta;
|
feedMeta?: FeedMeta;
|
||||||
|
linkPreviewUrl?: string | null;
|
||||||
|
linkPreviewTitle?: string | null;
|
||||||
|
linkPreviewDescription?: string | null;
|
||||||
|
linkPreviewImage?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Icons as simple SVG components
|
// Icons as simple SVG components
|
||||||
@@ -166,6 +170,24 @@ function PostCard({ post, onLike, onRepost }: { post: Post; onLike: (id: string)
|
|||||||
))}
|
))}
|
||||||
</div>
|
</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">
|
<div className="post-actions">
|
||||||
<button className="post-action" onClick={() => { }}>
|
<button className="post-action" onClick={() => { }}>
|
||||||
<MessageIcon />
|
<MessageIcon />
|
||||||
@@ -194,21 +216,58 @@ type Attachment = {
|
|||||||
altText?: string | null;
|
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 [content, setContent] = useState('');
|
||||||
const [isPosting, setIsPosting] = useState(false);
|
const [isPosting, setIsPosting] = useState(false);
|
||||||
const [attachments, setAttachments] = useState<Attachment[]>([]);
|
const [attachments, setAttachments] = useState<Attachment[]>([]);
|
||||||
const [isUploading, setIsUploading] = useState(false);
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
const [uploadError, setUploadError] = useState<string | null>(null);
|
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 maxLength = 400;
|
||||||
const remaining = maxLength - content.length;
|
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 () => {
|
const handleSubmit = async () => {
|
||||||
if (!content.trim() || isPosting || isUploading) return;
|
if (!content.trim() || isPosting || isUploading) return;
|
||||||
setIsPosting(true);
|
setIsPosting(true);
|
||||||
await onPost(content, attachments.map((item) => item.id).filter(Boolean));
|
await onPost(content, attachments.map((item) => item.id).filter(Boolean), linkPreview);
|
||||||
setContent('');
|
setContent('');
|
||||||
setAttachments([]);
|
setAttachments([]);
|
||||||
|
setLinkPreview(null);
|
||||||
|
setLastDetectedUrl(null);
|
||||||
setIsPosting(false);
|
setIsPosting(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -284,6 +343,30 @@ function Compose({ onPost }: { onPost: (content: string, mediaIds: string[]) =>
|
|||||||
))}
|
))}
|
||||||
</div>
|
</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 && (
|
{uploadError && (
|
||||||
<div className="compose-media-error">{uploadError}</div>
|
<div className="compose-media-error">{uploadError}</div>
|
||||||
)}
|
)}
|
||||||
@@ -353,11 +436,11 @@ export default function Home() {
|
|||||||
loadFeed(feedType);
|
loadFeed(feedType);
|
||||||
}, [feedType]);
|
}, [feedType]);
|
||||||
|
|
||||||
const handlePost = async (content: string, mediaIds: string[]) => {
|
const handlePost = async (content: string, mediaIds: string[], linkPreview?: any) => {
|
||||||
const res = await fetch('/api/posts', {
|
const res = await fetch('/api/posts', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ content, mediaIds }),
|
body: JSON.stringify({ content, mediaIds, linkPreview }),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
|
|||||||
@@ -88,6 +88,11 @@ export const posts = pgTable('posts', {
|
|||||||
// ActivityPub
|
// ActivityPub
|
||||||
apId: text('ap_id').unique(), // https://node.com/posts/uuid
|
apId: text('ap_id').unique(), // https://node.com/posts/uuid
|
||||||
apUrl: text('ap_url'), // Public URL for the post
|
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(),
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||||
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
||||||
}, (table) => [
|
}, (table) => [
|
||||||
|
|||||||
Reference in New Issue
Block a user