diff --git a/backend/src/index.ts b/backend/src/index.ts index b221b55..eb3689a 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -9,6 +9,7 @@ import { tagsRoutes } from './routes/tags.js'; import { authRoutes } from './routes/auth.js'; import { collectionsRoutes } from './routes/collections.js'; import { adminRoutes } from './routes/admin.js'; +import { shareRoutes } from './routes/share.js'; // Ensure data dirs exist ensureImagesDir(); @@ -44,9 +45,11 @@ await app.register(memesRoutes); await app.register(tagsRoutes); await app.register(adminRoutes); +await app.register(shareRoutes); + // SPA fallback — serve index.html for all non-API, non-image routes app.setNotFoundHandler(async (req, reply) => { - if (req.url.startsWith('/api/') || req.url.startsWith('/images/')) { + if (req.url.startsWith('/api/') || req.url.startsWith('/images/') || req.url.startsWith('/m/')) { return reply.status(404).send({ error: 'Not found' }); } return reply.sendFile('index.html', frontendDist); diff --git a/backend/src/routes/share.ts b/backend/src/routes/share.ts new file mode 100644 index 0000000..9c0fa84 --- /dev/null +++ b/backend/src/routes/share.ts @@ -0,0 +1,121 @@ +import type { FastifyInstance } from 'fastify'; +import db from '../db.js'; +import type { Meme } from '../types.js'; + +function getMemeById(id: string): Meme | null { + const row = db.prepare('SELECT * FROM memes WHERE id = ?').get(id) as Meme | undefined; + if (!row) return null; + return { ...row, tags: [] }; +} + +function getBaseUrl(req: { headers: { host?: string }; protocol?: string }): string { + const env = process.env.PUBLIC_URL; + if (env) return env.replace(/\/$/, ''); + const proto = process.env.NODE_ENV === 'production' ? 'https' : 'http'; + return `${proto}://${req.headers.host}`; +} + +function escapeHtml(str: string): string { + return str + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +export async function shareRoutes(app: FastifyInstance) { + app.get<{ Params: { id: string } }>('/m/:id', async (req, reply) => { + const meme = getMemeById(req.params.id); + + if (!meme) { + return reply.status(404).send('Not found'); + } + + const base = getBaseUrl(req as any); + const pageUrl = `${base}/m/${meme.id}`; + const imageUrl = `${base}/images/${meme.file_path}`; + const galleryUrl = `${base}/?open=${meme.id}`; + const title = escapeHtml(meme.title); + const description = escapeHtml( + meme.description ?? (meme.ocr_text ? meme.ocr_text.slice(0, 160).trim() : 'View this meme on Memer') + ); + + const html = ` + +
+ + +${title}
+ + + Open in Memer + + +`; + + return reply.type('text/html').send(html); + }); +} diff --git a/frontend/src/components/SharePanel.tsx b/frontend/src/components/SharePanel.tsx index 8366b78..635ce76 100644 --- a/frontend/src/components/SharePanel.tsx +++ b/frontend/src/components/SharePanel.tsx @@ -9,23 +9,26 @@ interface Props { export function SharePanel({ meme }: Props) { const [copied, setCopied] = useState(false); + + // /m/:id gives crawlers OG meta tags so SMS/Telegram show a rich preview card + const shareUrl = `${window.location.origin}/m/${meme.id}`; const imageUrl = `${window.location.origin}${api.imageUrl(meme.file_path)}`; async function copyLink() { - await navigator.clipboard.writeText(imageUrl); + await navigator.clipboard.writeText(shareUrl); setCopied(true); setTimeout(() => setCopied(false), 2000); } - const telegramUrl = `https://t.me/share/url?url=${encodeURIComponent(imageUrl)}&text=${encodeURIComponent(meme.title)}`; - const smsUrl = `sms:?body=${encodeURIComponent(imageUrl)}`; + const telegramUrl = `https://t.me/share/url?url=${encodeURIComponent(shareUrl)}&text=${encodeURIComponent(meme.title)}`; + const smsUrl = `sms:?body=${encodeURIComponent(shareUrl)}`; return (