Add social media sharing previews for videos

Implement a server-side API to generate dynamic social media shareable image thumbnails for videos and update the client to utilize this new API for open graph and Twitter meta tags.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: ab9cd02a-d0b2-4288-9ceb-1964d0059648
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/8cc42625-c1f5-4e43-99bd-77f2c4dedee2/ab9cd02a-d0b2-4288-9ceb-1964d0059648/mbX83Mu
This commit is contained in:
sebastjanartic 2025-08-31 20:44:31 +00:00
parent 87e07be40f
commit 0425cb899a
3 changed files with 172 additions and 4 deletions

View File

@ -127,9 +127,12 @@ export default function VideoPage() {
updateMetaTag('og:title', currentVideo.title);
updateMetaTag('og:description', currentVideo.description || `Watch ${currentVideo.title} on go4.video`);
// Use custom thumbnail if available, otherwise video thumbnail, fallback to social share image
const thumbnailForSharing = currentVideo.customThumbnailUrl || currentVideo.thumbnailUrl || '/go4-video-social-share.png';
updateMetaTag('og:image', thumbnailForSharing);
// Uporabljamo nov javni thumbnail API za social media deljenje
const socialThumbnail = `${window.location.origin}/api/video-thumbnail/${currentVideo.id}`;
updateMetaTag('og:image', socialThumbnail);
updateMetaTag('og:image:width', '1200');
updateMetaTag('og:image:height', '630');
updateMetaTag('og:image:type', 'image/png');
updateMetaTag('og:url', window.location.href);
updateMetaTag('og:type', 'video.other');
updateMetaTag('og:video:duration', currentVideo.duration.toString());
@ -145,9 +148,12 @@ export default function VideoPage() {
meta.setAttribute('content', content);
};
updateTwitterTag('twitter:card', 'summary_large_image');
updateTwitterTag('twitter:title', currentVideo.title);
updateTwitterTag('twitter:description', currentVideo.description || `Watch ${currentVideo.title} on go4.video`);
updateTwitterTag('twitter:image', thumbnailForSharing);
updateTwitterTag('twitter:image', socialThumbnail);
updateTwitterTag('twitter:image:width', '1200');
updateTwitterTag('twitter:image:height', '630');
}
}, [currentVideo]);

View File

@ -2,6 +2,9 @@ import express, { type Request, Response, NextFunction } from "express";
import { registerRoutes } from "./routes";
import { videoSyncService } from "./videoSync";
import { setupVite, serveStatic, log } from "./vite";
import { storage } from "./storage";
import fs from "fs";
import path from "path";
const app = express();
app.use(express.json());
@ -41,6 +44,94 @@ app.use((req, res, next) => {
// Initialize video sync service for automatic Bunny.net updates
await videoSyncService.initialize();
// Social media prerendering middleware - mora biti PRED setupVite
app.get('/video/:videoId', async (req, res, next) => {
const { videoId } = req.params;
const userAgent = req.get('User-Agent') || '';
// Če je to Facebook, Twitter, WhatsApp ali podoben scraper
const isSocialBot = /facebookexternalhit|twitterbot|whatsapp|telegrambot|discordbot|slackbot|linkedinbot/i.test(userAgent);
if (isSocialBot) {
try {
const video = await storage.getVideo(videoId);
if (!video) {
return next(); // Če video ne obstaja, preusmerimo na običajno SPA
}
// Preberemo osnovni HTML template
const clientTemplate = path.resolve(import.meta.dirname, "..", "client", "index.html");
let template = await fs.promises.readFile(clientTemplate, "utf-8");
// Escape special characters v naslovih in opisih
const escapeHtml = (text: string) => text.replace(/"/g, '"').replace(/'/g, ''');
// Zamenimo meta oznake z video specifičnimi
const baseUrl = req.protocol + '://' + req.get('host');
const videoUrl = `${baseUrl}/video/${video.id}`;
const thumbnailUrl = `${baseUrl}/api/video-thumbnail/${video.id}`;
// Zamenjamo osnovne meta oznake
template = template.replace(
/<title>.*<\/title>/,
`<title>${escapeHtml(video.title)} | go4.video</title>`
);
template = template.replace(
/<meta name="description" content="[^"]*"/,
`<meta name="description" content="${escapeHtml(video.description || `Watch ${video.title} on go4.video`)}"`
);
// Zamenjamo Open Graph meta oznake
template = template.replace(
/<meta property="og:title" content="[^"]*"/,
`<meta property="og:title" content="${escapeHtml(video.title)}"`
);
template = template.replace(
/<meta property="og:description" content="[^"]*"/,
`<meta property="og:description" content="${escapeHtml(video.description || `Watch ${video.title} on go4.video`)}"`
);
template = template.replace(
/<meta property="og:image" content="[^"]*"/,
`<meta property="og:image" content="${thumbnailUrl}"`
);
// Dodamo dodatne OG oznake za video
const additionalMeta = `
<meta property="og:url" content="${videoUrl}">
<meta property="og:type" content="video.other">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta property="og:image:type" content="image/png">
<meta property="og:video:duration" content="${video.duration}">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="${escapeHtml(video.title)}">
<meta name="twitter:description" content="${escapeHtml(video.description || `Watch ${video.title} on go4.video`)}">
<meta name="twitter:image" content="${thumbnailUrl}">
<meta name="twitter:image:width" content="1200">
<meta name="twitter:image:height" content="630">`;
template = template.replace('</head>', `${additionalMeta}\n</head>`);
res.setHeader('Content-Type', 'text/html');
res.send(template);
return;
} catch (error) {
console.error('Error rendering video page for social bot:', error);
// Če se zgodi napaka, nadaljujemo z običajno SPA
return next();
}
}
// Za običajne uporabnike nadaljujemo z SPA
next();
});
const server = await registerRoutes(app);
app.use((err: any, _req: Request, res: Response, _next: NextFunction) => {

View File

@ -12,6 +12,7 @@ import {
import multer from "multer";
import { randomUUID } from "crypto";
import path from "path";
import fs from "fs";
import session from "express-session";
import sharp from "sharp";
@ -755,6 +756,76 @@ export async function registerRoutes(app: Express): Promise<Server> {
}
});
// Social media thumbnail generation endpoint
app.get('/api/video-thumbnail/:videoId', async (req, res) => {
try {
const { videoId } = req.params;
const video = await storage.getVideo(videoId);
if (!video) {
return res.status(404).send('Video not found');
}
// Ustvarimo social media thumbnail sliko z video informacijami
const width = 1200;
const height = 630; // Facebook/Twitter optimalne dimenzije
const svg = `<svg width="${width}" height="${height}" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="bgGradient" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#2D1B69;stop-opacity:1" />
<stop offset="50%" style="stop-color:#6366f1;stop-opacity:1" />
<stop offset="100%" style="stop-color:#8b5cf6;stop-opacity:1" />
</linearGradient>
<linearGradient id="logoGradient" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#ffffff;stop-opacity:0.9" />
<stop offset="100%" style="stop-color:#ffffff;stop-opacity:0.7" />
</linearGradient>
</defs>
<!-- Ozadje -->
<rect width="${width}" height="${height}" fill="url(#bgGradient)"/>
<!-- Dekorativni elementi -->
<polygon points="100,100 180,180 70,180" fill="#ffffff" opacity="0.1"/>
<polygon points="1000,200 1120,280 880,280" fill="#ffffff" opacity="0.08"/>
<polygon points="200,450 260,510 140,510" fill="#ffffff" opacity="0.12"/>
<polygon points="950,500 1040,590 860,590" fill="#ffffff" opacity="0.06"/>
<!-- Glavni play button v levi strani -->
<circle cx="300" cy="315" r="80" fill="url(#logoGradient)" opacity="0.9"/>
<polygon points="270,285 270,345 330,315" fill="#6366f1"/>
<!-- Video naslov -->
<text x="450" y="250" font-family="Arial, sans-serif" font-size="42" font-weight="bold" fill="white" text-anchor="start">${video.title.length > 35 ? video.title.substring(0, 32) + '...' : video.title}</text>
<!-- Video opis -->
<text x="450" y="300" font-family="Arial, sans-serif" font-size="24" fill="rgba(255,255,255,0.8)" text-anchor="start">${video.description && video.description.length > 60 ? video.description.substring(0, 57) + '...' : video.description || 'Watch this video on go4.video'}</text>
<!-- go4.video logo -->
<text x="450" y="400" font-family="Arial, sans-serif" font-size="32" font-weight="bold" fill="white" text-anchor="start">go4.video</text>
<text x="450" y="430" font-family="Arial, sans-serif" font-size="18" fill="rgba(255,255,255,0.7)" text-anchor="start">Professional Video Streaming Platform</text>
<!-- Video dolžina -->
<rect x="950" y="500" width="200" height="40" rx="20" fill="rgba(0,0,0,0.7)"/>
<text x="1050" y="525" font-family="Arial, sans-serif" font-size="18" font-weight="bold" fill="white" text-anchor="middle">${Math.floor(video.duration / 60)}:${(video.duration % 60).toString().padStart(2, '0')}</text>
</svg>`;
// Pretvorimo SVG v PNG
const pngBuffer = await sharp(Buffer.from(svg))
.png({ quality: 90, compressionLevel: 6 })
.toBuffer();
res.setHeader('Content-Type', 'image/png');
res.setHeader('Cache-Control', 'public, max-age=3600'); // Cache za 1 uro
res.send(pngBuffer);
} catch (error) {
console.error('Error generating video thumbnail:', error);
res.status(500).send('Error generating thumbnail');
}
});
const httpServer = createServer(app);
return httpServer;
}