build preview card

This commit is contained in:
2026-03-28 09:25:11 -05:00
parent aea08337d1
commit 32bcdc94fc
4 changed files with 136 additions and 7 deletions

View File

@@ -9,6 +9,7 @@ import { tagsRoutes } from './routes/tags.js';
import { authRoutes } from './routes/auth.js'; import { authRoutes } from './routes/auth.js';
import { collectionsRoutes } from './routes/collections.js'; import { collectionsRoutes } from './routes/collections.js';
import { adminRoutes } from './routes/admin.js'; import { adminRoutes } from './routes/admin.js';
import { shareRoutes } from './routes/share.js';
// Ensure data dirs exist // Ensure data dirs exist
ensureImagesDir(); ensureImagesDir();
@@ -44,9 +45,11 @@ await app.register(memesRoutes);
await app.register(tagsRoutes); await app.register(tagsRoutes);
await app.register(adminRoutes); await app.register(adminRoutes);
await app.register(shareRoutes);
// SPA fallback — serve index.html for all non-API, non-image routes // SPA fallback — serve index.html for all non-API, non-image routes
app.setNotFoundHandler(async (req, reply) => { 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.status(404).send({ error: 'Not found' });
} }
return reply.sendFile('index.html', frontendDist); return reply.sendFile('index.html', frontendDist);

121
backend/src/routes/share.ts Normal file
View File

@@ -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, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
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 = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>${title}</title>
<!-- Open Graph -->
<meta property="og:type" content="website" />
<meta property="og:url" content="${escapeHtml(pageUrl)}" />
<meta property="og:title" content="${title}" />
<meta property="og:description" content="${description}" />
<meta property="og:image" content="${escapeHtml(imageUrl)}" />
<meta property="og:image:width" content="${meme.width}" />
<meta property="og:image:height" content="${meme.height}" />
<meta property="og:site_name" content="Memer" />
<!-- Twitter / X card -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="${title}" />
<meta name="twitter:description" content="${description}" />
<meta name="twitter:image" content="${escapeHtml(imageUrl)}" />
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
background: #09090b;
color: #f4f4f5;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
min-height: 100dvh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 1.5rem;
gap: 1.25rem;
}
img {
max-width: min(100%, 720px);
max-height: 80dvh;
object-fit: contain;
border-radius: 12px;
box-shadow: 0 25px 60px rgba(0,0,0,.6);
}
.title {
font-size: 1.125rem;
font-weight: 600;
text-align: center;
max-width: 600px;
}
a.btn {
display: inline-flex;
align-items: center;
gap: .5rem;
padding: .6rem 1.4rem;
border-radius: 8px;
background: #7c3aed;
color: #fff;
font-size: .875rem;
font-weight: 500;
text-decoration: none;
transition: background .15s;
}
a.btn:hover { background: #6d28d9; }
</style>
</head>
<body>
<img src="${escapeHtml(imageUrl)}" alt="${title}" />
<p class="title">${title}</p>
<a class="btn" href="${escapeHtml(galleryUrl)}">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg>
Open in Memer
</a>
</body>
</html>`;
return reply.type('text/html').send(html);
});
}

View File

@@ -9,23 +9,26 @@ interface Props {
export function SharePanel({ meme }: Props) { export function SharePanel({ meme }: Props) {
const [copied, setCopied] = useState(false); 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)}`; const imageUrl = `${window.location.origin}${api.imageUrl(meme.file_path)}`;
async function copyLink() { async function copyLink() {
await navigator.clipboard.writeText(imageUrl); await navigator.clipboard.writeText(shareUrl);
setCopied(true); setCopied(true);
setTimeout(() => setCopied(false), 2000); setTimeout(() => setCopied(false), 2000);
} }
const telegramUrl = `https://t.me/share/url?url=${encodeURIComponent(imageUrl)}&text=${encodeURIComponent(meme.title)}`; const telegramUrl = `https://t.me/share/url?url=${encodeURIComponent(shareUrl)}&text=${encodeURIComponent(meme.title)}`;
const smsUrl = `sms:?body=${encodeURIComponent(imageUrl)}`; const smsUrl = `sms:?body=${encodeURIComponent(shareUrl)}`;
return ( return (
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<button <button
onClick={copyLink} onClick={copyLink}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-zinc-800 hover:bg-zinc-700 text-sm font-medium transition-colors" className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-zinc-800 hover:bg-zinc-700 text-sm font-medium transition-colors"
title="Copy image link" title="Copy share link"
> >
{copied ? ( {copied ? (
<Check size={14} className="text-green-400" /> <Check size={14} className="text-green-400" />
@@ -56,7 +59,7 @@ export function SharePanel({ meme }: Props) {
</a> </a>
<a <a
href={api.imageUrl(meme.file_path)} href={imageUrl}
download={meme.file_name} download={meme.file_name}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-zinc-800 hover:bg-zinc-700 text-zinc-300 text-sm font-medium transition-colors" className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-zinc-800 hover:bg-zinc-700 text-zinc-300 text-sm font-medium transition-colors"
title="Download original" title="Download original"

View File

@@ -19,7 +19,9 @@ export function Gallery() {
const [activeTag, setActiveTag] = useState<string | null>(null); const [activeTag, setActiveTag] = useState<string | null>(null);
const [activeCollectionId, setActiveCollectionId] = useState<number | null>(null); const [activeCollectionId, setActiveCollectionId] = useState<number | null>(null);
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [selectedMemeId, setSelectedMemeId] = useState<string | null>(null); const [selectedMemeId, setSelectedMemeId] = useState<string | null>(() => {
return new URLSearchParams(window.location.search).get('open');
});
const [quickShareMeme, setQuickShareMeme] = useState<Meme | null>(null); const [quickShareMeme, setQuickShareMeme] = useState<Meme | null>(null);
const [showUpload, setShowUpload] = useState(false); const [showUpload, setShowUpload] = useState(false);
const [showLogin, setShowLogin] = useState(false); const [showLogin, setShowLogin] = useState(false);