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:
parent
87e07be40f
commit
0425cb899a
@ -127,9 +127,12 @@ export default function VideoPage() {
|
|||||||
|
|
||||||
updateMetaTag('og:title', currentVideo.title);
|
updateMetaTag('og:title', currentVideo.title);
|
||||||
updateMetaTag('og:description', currentVideo.description || `Watch ${currentVideo.title} on go4.video`);
|
updateMetaTag('og:description', currentVideo.description || `Watch ${currentVideo.title} on go4.video`);
|
||||||
// Use custom thumbnail if available, otherwise video thumbnail, fallback to social share image
|
// Uporabljamo nov javni thumbnail API za social media deljenje
|
||||||
const thumbnailForSharing = currentVideo.customThumbnailUrl || currentVideo.thumbnailUrl || '/go4-video-social-share.png';
|
const socialThumbnail = `${window.location.origin}/api/video-thumbnail/${currentVideo.id}`;
|
||||||
updateMetaTag('og:image', thumbnailForSharing);
|
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:url', window.location.href);
|
||||||
updateMetaTag('og:type', 'video.other');
|
updateMetaTag('og:type', 'video.other');
|
||||||
updateMetaTag('og:video:duration', currentVideo.duration.toString());
|
updateMetaTag('og:video:duration', currentVideo.duration.toString());
|
||||||
@ -145,9 +148,12 @@ export default function VideoPage() {
|
|||||||
meta.setAttribute('content', content);
|
meta.setAttribute('content', content);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
updateTwitterTag('twitter:card', 'summary_large_image');
|
||||||
updateTwitterTag('twitter:title', currentVideo.title);
|
updateTwitterTag('twitter:title', currentVideo.title);
|
||||||
updateTwitterTag('twitter:description', currentVideo.description || `Watch ${currentVideo.title} on go4.video`);
|
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]);
|
}, [currentVideo]);
|
||||||
|
|
||||||
|
|||||||
@ -2,6 +2,9 @@ import express, { type Request, Response, NextFunction } from "express";
|
|||||||
import { registerRoutes } from "./routes";
|
import { registerRoutes } from "./routes";
|
||||||
import { videoSyncService } from "./videoSync";
|
import { videoSyncService } from "./videoSync";
|
||||||
import { setupVite, serveStatic, log } from "./vite";
|
import { setupVite, serveStatic, log } from "./vite";
|
||||||
|
import { storage } from "./storage";
|
||||||
|
import fs from "fs";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
@ -41,6 +44,94 @@ app.use((req, res, next) => {
|
|||||||
// Initialize video sync service for automatic Bunny.net updates
|
// Initialize video sync service for automatic Bunny.net updates
|
||||||
await videoSyncService.initialize();
|
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);
|
const server = await registerRoutes(app);
|
||||||
|
|
||||||
app.use((err: any, _req: Request, res: Response, _next: NextFunction) => {
|
app.use((err: any, _req: Request, res: Response, _next: NextFunction) => {
|
||||||
|
|||||||
@ -12,6 +12,7 @@ import {
|
|||||||
import multer from "multer";
|
import multer from "multer";
|
||||||
import { randomUUID } from "crypto";
|
import { randomUUID } from "crypto";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
|
import fs from "fs";
|
||||||
import session from "express-session";
|
import session from "express-session";
|
||||||
import sharp from "sharp";
|
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);
|
const httpServer = createServer(app);
|
||||||
return httpServer;
|
return httpServer;
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user