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 { 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
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) {
|
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"
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user