build preview card
This commit is contained in:
@@ -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);
|
||||
|
||||
121
backend/src/routes/share.ts
Normal file
121
backend/src/routes/share.ts
Normal 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, '<')
|
||||
.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 = `<!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);
|
||||
});
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
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"
|
||||
title="Copy image link"
|
||||
title="Copy share link"
|
||||
>
|
||||
{copied ? (
|
||||
<Check size={14} className="text-green-400" />
|
||||
@@ -56,7 +59,7 @@ export function SharePanel({ meme }: Props) {
|
||||
</a>
|
||||
|
||||
<a
|
||||
href={api.imageUrl(meme.file_path)}
|
||||
href={imageUrl}
|
||||
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"
|
||||
title="Download original"
|
||||
|
||||
@@ -19,7 +19,9 @@ export function Gallery() {
|
||||
const [activeTag, setActiveTag] = useState<string | null>(null);
|
||||
const [activeCollectionId, setActiveCollectionId] = useState<number | null>(null);
|
||||
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 [showUpload, setShowUpload] = useState(false);
|
||||
const [showLogin, setShowLogin] = useState(false);
|
||||
|
||||
Reference in New Issue
Block a user