Compare commits

...

3 Commits

Author SHA1 Message Date
kenilkb
b2853a952b fixed_discripancies_observed 2026-07-17 17:46:48 +05:30
kenilkb
9646309a0a Refactor showcase cards to use absolute grid overlay expansion system 2026-07-17 16:21:29 +05:30
kenilkb
a1626a5c20 Responsive mobile layout fix for showcase lightbox 2026-07-17 15:44:22 +05:30
4 changed files with 302 additions and 110 deletions

View File

@ -134,8 +134,20 @@ export class EcosystemController {
try { try {
const data = req.body; const data = req.body;
// Auto-extract YouTube thumbnail if not provided let title = data.title;
let description = data.description || null;
let thumbnailUrl = data.thumbnailUrl || null; let thumbnailUrl = data.thumbnailUrl || null;
if ((!title || !description || !thumbnailUrl) && data.youtubeUrl) {
const meta = await this.scrapeMetaInternal(data.youtubeUrl);
if (meta) {
if (!title) title = meta.title;
if (!description) description = meta.description || null;
if (!thumbnailUrl) thumbnailUrl = meta.thumbnailUrl || null;
}
}
// Auto-extract YouTube thumbnail if not provided and it's YouTube
if (!thumbnailUrl && data.youtubeUrl) { if (!thumbnailUrl && data.youtubeUrl) {
const videoId = this.extractYouTubeVideoId(data.youtubeUrl); const videoId = this.extractYouTubeVideoId(data.youtubeUrl);
if (videoId) { if (videoId) {
@ -145,8 +157,8 @@ export class EcosystemController {
const created = await prisma.contentShowcase.create({ const created = await prisma.contentShowcase.create({
data: { data: {
title: data.title, title: title || 'Media Showcase Item',
description: data.description || null, description,
youtubeUrl: data.youtubeUrl, youtubeUrl: data.youtubeUrl,
thumbnailUrl, thumbnailUrl,
redirectUrl: data.redirectUrl || null, redirectUrl: data.redirectUrl || null,
@ -167,9 +179,20 @@ export class EcosystemController {
const { id } = req.params; const { id } = req.params;
const data = req.body; const data = req.body;
// Auto-extract YouTube thumbnail if URL changed and no explicit thumbnail let title = data.title;
let description = data.description;
let thumbnailUrl = data.thumbnailUrl; let thumbnailUrl = data.thumbnailUrl;
if (thumbnailUrl === undefined && data.youtubeUrl) {
if (data.youtubeUrl) {
const meta = await this.scrapeMetaInternal(data.youtubeUrl);
if (meta) {
if (title === undefined || title === null || title === '') title = meta.title;
if (description === undefined) description = meta.description || null;
if (thumbnailUrl === undefined) thumbnailUrl = meta.thumbnailUrl || null;
}
}
if (!thumbnailUrl && data.youtubeUrl) {
const videoId = this.extractYouTubeVideoId(data.youtubeUrl); const videoId = this.extractYouTubeVideoId(data.youtubeUrl);
if (videoId) { if (videoId) {
thumbnailUrl = `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`; thumbnailUrl = `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`;
@ -179,8 +202,8 @@ export class EcosystemController {
const updated = await prisma.contentShowcase.update({ const updated = await prisma.contentShowcase.update({
where: { id }, where: { id },
data: { data: {
title: data.title, title,
description: data.description, description,
youtubeUrl: data.youtubeUrl, youtubeUrl: data.youtubeUrl,
thumbnailUrl, thumbnailUrl,
redirectUrl: data.redirectUrl, redirectUrl: data.redirectUrl,
@ -210,6 +233,21 @@ export class EcosystemController {
// Helper: Extract video ID from various YouTube URL formats // Helper: Extract video ID from various YouTube URL formats
private extractYouTubeVideoId(url: string): string | null { private extractYouTubeVideoId(url: string): string | null {
try {
const urlObj = new URL(url);
if (urlObj.hostname.includes('youtu.be')) {
return urlObj.pathname.slice(1).split(/[?#]/)[0];
}
if (urlObj.pathname.includes('/shorts/') || urlObj.pathname.includes('/embed/')) {
const parts = urlObj.pathname.split('/');
return parts.pop()?.split(/[?#]/)[0] || null;
}
if (urlObj.searchParams.has('v')) {
return urlObj.searchParams.get('v');
}
} catch (e) {
// Fallback to regex
}
const patterns = [ const patterns = [
/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/|youtube\.com\/shorts\/)([a-zA-Z0-9_-]{11})/, /(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/|youtube\.com\/shorts\/)([a-zA-Z0-9_-]{11})/,
/(?:youtube-nocookie\.com\/embed\/)([a-zA-Z0-9_-]{11})/, /(?:youtube-nocookie\.com\/embed\/)([a-zA-Z0-9_-]{11})/,
@ -221,6 +259,113 @@ export class EcosystemController {
return null; return null;
} }
private async scrapeMetaInternal(url: string): Promise<{ title?: string; description?: string; thumbnailUrl?: string } | null> {
try {
const normUrl = url.trim();
const lowerUrl = normUrl.toLowerCase();
// Case 1: Twitter / X
if (lowerUrl.includes('twitter.com') || lowerUrl.includes('x.com')) {
const oembedUrl = `https://publish.twitter.com/oembed?url=${encodeURIComponent(normUrl)}`;
const resOembed = await fetch(oembedUrl);
if (resOembed.ok) {
const data = await resOembed.json() as any;
let description = '';
if (data.html) {
const pMatch = data.html.match(/<p[^>]*>([\s\S]*?)<\/p>/i);
if (pMatch) {
description = pMatch[1].replace(/<[^>]*>/g, '').trim();
}
}
return {
title: data.author_name ? `Tweet by ${data.author_name}` : 'Tweet Content',
description,
thumbnailUrl: undefined
};
}
}
// Case 2: Instagram
if (lowerUrl.includes('instagram.com')) {
const oembedUrl = `https://graph.facebook.com/v25.0/instagram_oembed?url=${encodeURIComponent(normUrl)}`;
const resOembed = await fetch(oembedUrl);
if (resOembed.ok) {
const data = await resOembed.json() as any;
return {
title: data.author_name ? `Instagram post by ${data.author_name}` : 'Instagram Post/Reel',
description: data.title || 'Instagram Content',
thumbnailUrl: data.thumbnail_url || undefined
};
}
}
// Case 3: YouTube
const videoId = this.extractYouTubeVideoId(normUrl);
if (videoId) {
let title = '';
let thumbnailUrl = `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`;
try {
const oembedUrl = `https://www.youtube.com/oembed?url=https://www.youtube.com/watch?v=${videoId}&format=json`;
const oembedRes = await fetch(oembedUrl);
if (oembedRes.ok) {
const data = await oembedRes.json() as any;
title = data.title || '';
thumbnailUrl = data.thumbnail_url || thumbnailUrl;
}
} catch (err) {
console.error('oEmbed fetch error:', err);
}
let description = '';
try {
const watchUrl = `https://www.youtube.com/watch?v=${videoId}`;
const pageRes = await fetch(watchUrl, {
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/115.0.0.0 Safari/537.36',
'Accept-Language': 'en-US,en;q=0.9'
}
});
if (pageRes.ok) {
const html = await pageRes.text();
const playerResponse = this.extractJsonFromHtml(html, 'ytInitialPlayerResponse = ');
if (playerResponse && playerResponse.videoDetails) {
description = playerResponse.videoDetails.shortDescription || '';
if (playerResponse.videoDetails.title && !title) {
title = playerResponse.videoDetails.title;
}
}
if (!description) {
const descMatch = html.match(/<meta\s+property="og:description"\s+content="([^"]*)"/i) ||
html.match(/<meta\s+name="description"\s+content="([^"]*)"/i);
if (descMatch && descMatch[1]) {
description = descMatch[1]
.replace(/&quot;/g, '"')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&#39;/g, "'")
.replace(/&apos;/g, "'");
}
}
}
} catch (err) {
console.error('HTML scrape error:', err);
}
return {
title: title || undefined,
description: description || undefined,
thumbnailUrl
};
}
} catch (e) {
console.error('scrapeMetaInternal error:', e);
}
return null;
}
private extractJsonFromHtml(html: string, targetStr: string): any { private extractJsonFromHtml(html: string, targetStr: string): any {
const idx = html.indexOf(targetStr); const idx = html.indexOf(targetStr);
if (idx === -1) return null; if (idx === -1) return null;

View File

@ -153,7 +153,7 @@ export const AssetCard: React.FC<AssetCardProps> = ({
} }
onViewDetails(asset); onViewDetails(asset);
}} }}
className={`group bg-ink-0 border rounded-xl p-4 transition-all duration-300 flex flex-col justify-between cursor-pointer min-h-[410px] ${ className={`group bg-ink-0 border rounded-xl p-4 transition-[border-color,box-shadow,background-color] duration-300 flex flex-col justify-between cursor-pointer min-h-[410px] ${
isExpanded isExpanded
? 'absolute z-20 top-0 left-0 right-0 h-auto shadow-2xl border-ink-300 bg-ink-0' ? 'absolute z-20 top-0 left-0 right-0 h-auto shadow-2xl border-ink-300 bg-ink-0'
: isRecommended : isRecommended

View File

@ -16,6 +16,21 @@ import type { ContentShowcase } from '../services/ecosystem-api';
// ── Helper: Extract YouTube video ID ── // ── Helper: Extract YouTube video ID ──
const extractYouTubeVideoId = (url: string): string | null => { const extractYouTubeVideoId = (url: string): string | null => {
try {
const urlObj = new URL(url);
if (urlObj.hostname.includes('youtu.be')) {
return urlObj.pathname.slice(1).split(/[?#]/)[0];
}
if (urlObj.pathname.includes('/shorts/') || urlObj.pathname.includes('/embed/')) {
const parts = urlObj.pathname.split('/');
return parts.pop()?.split(/[?#]/)[0] || null;
}
if (urlObj.searchParams.has('v')) {
return urlObj.searchParams.get('v');
}
} catch (e) {
// Fallback to regex
}
const patterns = [ const patterns = [
/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/|youtube\.com\/shorts\/)([a-zA-Z0-9_-]{11})/, /(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/|youtube\.com\/shorts\/)([a-zA-Z0-9_-]{11})/,
/(?:youtube-nocookie\.com\/embed\/)([a-zA-Z0-9_-]{11})/, /(?:youtube-nocookie\.com\/embed\/)([a-zA-Z0-9_-]{11})/,
@ -59,7 +74,7 @@ const YouTubeEmbed: React.FC<{ videoId: string }> = ({ videoId }) => {
const InstagramEmbed: React.FC<{ postId: string; size: 'compact' | 'theater' | 'cinema' }> = ({ postId, size }) => { const InstagramEmbed: React.FC<{ postId: string; size: 'compact' | 'theater' | 'cinema' }> = ({ postId, size }) => {
const maxWidthClass = size === 'compact' ? 'max-w-[360px]' : size === 'theater' ? 'max-w-[420px]' : 'max-w-[480px]'; const maxWidthClass = size === 'compact' ? 'max-w-[360px]' : size === 'theater' ? 'max-w-[420px]' : 'max-w-[480px]';
return ( return (
<div className={`w-full ${maxWidthClass} aspect-[9/16] max-h-[75vh] mx-auto bg-ink-950 rounded-2xl overflow-hidden shadow-2xl flex justify-center items-center`}> <div className={`w-full ${maxWidthClass} aspect-[9/16] max-h-full mx-auto bg-ink-950 rounded-2xl overflow-hidden shadow-2xl flex justify-center items-center`}>
<iframe <iframe
src={`https://www.instagram.com/p/${postId}/embed`} src={`https://www.instagram.com/p/${postId}/embed`}
className="w-full h-full border-0" className="w-full h-full border-0"
@ -91,7 +106,7 @@ const TwitterEmbed: React.FC<{ url: string }> = ({ url }) => {
}, [url]); }, [url]);
return ( return (
<div className="w-full max-h-[70vh] overflow-y-auto flex justify-center bg-white p-6 rounded-2xl"> <div className="w-full max-h-full overflow-y-auto flex justify-center bg-white p-4 sm:p-6 rounded-2xl">
<blockquote className="twitter-tweet" data-align="center"> <blockquote className="twitter-tweet" data-align="center">
<a href={url}>Loading Tweet...</a> <a href={url}>Loading Tweet...</a>
</blockquote> </blockquote>
@ -100,15 +115,20 @@ const TwitterEmbed: React.FC<{ url: string }> = ({ url }) => {
}; };
// ── Collapsible Video Description Component ── // ── Collapsible Video Description Component ──
const VideoDescription: React.FC<{ text: string }> = ({ text }) => { interface VideoDescriptionProps {
const [isExpanded, setIsExpanded] = useState(false); text: string;
const shouldCollapse = text.length > 180 || text.includes('\n'); isExpanded: boolean;
onToggleExpand: () => void;
}
const VideoDescription: React.FC<VideoDescriptionProps> = ({ text, isExpanded, onToggleExpand }) => {
const shouldCollapse = text.length > 120 || text.includes('\n');
return ( return (
<div className="space-y-2"> <div className="space-y-1">
<p <p
className={`text-[11px] font-medium text-ink-500 leading-relaxed transition-all duration-300 ${ className={`text-[11px] font-medium text-ink-500 leading-relaxed overflow-hidden ${
isExpanded ? '' : 'line-clamp-3' isExpanded ? '' : 'line-clamp-2'
}`} }`}
style={{ whiteSpace: isExpanded ? 'pre-wrap' : 'normal' }} style={{ whiteSpace: isExpanded ? 'pre-wrap' : 'normal' }}
> >
@ -118,7 +138,7 @@ const VideoDescription: React.FC<{ text: string }> = ({ text }) => {
<button <button
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
setIsExpanded(!isExpanded); onToggleExpand();
}} }}
className="inline-flex items-center text-[10px] font-black uppercase tracking-wider text-blue-600 hover:text-blue-800 transition-colors focus:outline-none cursor-pointer" className="inline-flex items-center text-[10px] font-black uppercase tracking-wider text-blue-600 hover:text-blue-800 transition-colors focus:outline-none cursor-pointer"
> >
@ -133,6 +153,7 @@ export const ShowcasePage: React.FC = () => {
const [items, setItems] = useState<ContentShowcase[]>([]); const [items, setItems] = useState<ContentShowcase[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [playingVideoId, setPlayingVideoId] = useState<string | null>(null); const [playingVideoId, setPlayingVideoId] = useState<string | null>(null);
const [expandedItemId, setExpandedItemId] = useState<string | null>(null);
// Resizable Lightbox state: compact | theater | cinema // Resizable Lightbox state: compact | theater | cinema
const [lightboxSize, setLightboxSize] = useState<'compact' | 'theater' | 'cinema'>('compact'); const [lightboxSize, setLightboxSize] = useState<'compact' | 'theater' | 'cinema'>('compact');
@ -195,14 +216,21 @@ export const ShowcasePage: React.FC = () => {
const isIg = item.youtubeUrl.includes('instagram.com'); const isIg = item.youtubeUrl.includes('instagram.com');
const isTw = item.youtubeUrl.includes('twitter.com') || item.youtubeUrl.includes('x.com'); const isTw = item.youtubeUrl.includes('twitter.com') || item.youtubeUrl.includes('x.com');
const thumbnail = item.thumbnailUrl || (ytId ? `https://img.youtube.com/vi/${ytId}/hqdefault.jpg` : null); const thumbnail = item.thumbnailUrl || (ytId ? `https://img.youtube.com/vi/${ytId}/hqdefault.jpg` : null);
const isExpanded = expandedItemId === item.id;
return ( return (
<div <div key={item.id} className="relative h-[410px] w-full flex flex-col">
key={item.id} <motion.div
className="group flex flex-col bg-ink-0 border border-ink-200 hover:border-ink-350 rounded-2xl overflow-hidden hover:shadow-xl transition-all duration-500" layout
transition={{ type: "spring", stiffness: 320, damping: 28 }}
className={`group flex flex-col bg-ink-0 border rounded-2xl overflow-hidden transition-[border-color,box-shadow,background-color] duration-300 ${
isExpanded
? 'absolute z-20 top-0 left-0 right-0 h-auto shadow-2xl border-ink-350 bg-ink-0'
: 'relative w-full h-full border-ink-200 hover:border-ink-350 hover:shadow-xl'
}`}
> >
{/* Video Player / Thumbnail */} {/* Video Player / Thumbnail */}
<div className="relative aspect-video bg-ink-900 overflow-hidden"> <div className="relative aspect-video bg-ink-900 overflow-hidden shrink-0">
{thumbnail ? ( {thumbnail ? (
<img <img
src={thumbnail} src={thumbnail}
@ -256,13 +284,17 @@ export const ShowcasePage: React.FC = () => {
</div> </div>
{/* Content Info */} {/* Content Info */}
<div className="p-5 flex-1 flex flex-col justify-between space-y-4"> <div className="p-5 flex-grow flex flex-col justify-between space-y-4">
<div className="space-y-2"> <div className="space-y-2">
<h3 className="text-sm font-black tracking-tight text-ink-900 leading-snug"> <h3 className={`text-sm font-black tracking-tight text-ink-900 leading-snug ${isExpanded ? '' : 'line-clamp-2'}`} title={item.title}>
{item.title} {item.title}
</h3> </h3>
{item.description && ( {item.description && (
<VideoDescription text={item.description} /> <VideoDescription
text={item.description}
isExpanded={isExpanded}
onToggleExpand={() => setExpandedItemId(isExpanded ? null : item.id)}
/>
)} )}
</div> </div>
@ -281,6 +313,7 @@ export const ShowcasePage: React.FC = () => {
</div> </div>
)} )}
</div> </div>
</motion.div>
</div> </div>
); );
})} })}
@ -303,17 +336,17 @@ export const ShowcasePage: React.FC = () => {
animate={{ opacity: 1, scale: 1, y: 0 }} animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.9, y: 20 }} exit={{ opacity: 0, scale: 0.9, y: 20 }}
transition={{ type: 'spring', damping: 25, stiffness: 250 }} transition={{ type: 'spring', damping: 25, stiffness: 250 }}
className={`relative bg-ink-900 border border-ink-800 rounded-3xl overflow-hidden shadow-2xl flex transition-all duration-300 ${ className={`relative bg-ink-900 border border-ink-800 rounded-3xl overflow-hidden shadow-2xl flex flex-col transition-all duration-300 ${
lightboxSize === 'cinema' lightboxSize === 'cinema'
? 'w-[95vw] max-w-7xl flex-col h-[90vh]' ? 'w-[95vw] max-w-7xl md:flex-col h-[85vh] md:h-[90vh]'
: lightboxSize === 'theater' : lightboxSize === 'theater'
? 'w-[85vw] max-w-6xl flex-row max-h-[85vh]' ? 'w-[95vw] md:w-[85vw] max-w-6xl md:flex-row h-[85vh] md:max-h-[85vh]'
: 'w-full max-w-4xl flex-row max-h-[80vh]' : 'w-[95vw] md:w-full max-w-4xl md:flex-row h-[85vh] md:max-h-[80vh]'
}`} }`}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
{/* Media Container */} {/* Media Container */}
<div className="flex-1 bg-black flex items-center justify-center p-4 overflow-hidden relative min-h-[300px]"> <div className="flex-1 bg-black flex items-center justify-center p-3 sm:p-4 overflow-hidden relative min-h-[180px] sm:min-h-[240px] md:min-h-[300px]">
{(() => { {(() => {
const ytId = extractYouTubeVideoId(activeItem.youtubeUrl); const ytId = extractYouTubeVideoId(activeItem.youtubeUrl);
if (ytId) { if (ytId) {
@ -335,15 +368,15 @@ export const ShowcasePage: React.FC = () => {
</div> </div>
{/* Info Container */} {/* Info Container */}
<div className={`p-6 flex flex-col justify-between border-ink-800 bg-ink-950 overflow-y-auto ${ <div className={`p-5 sm:p-6 flex flex-col justify-between border-ink-800 bg-ink-950 overflow-y-auto ${
lightboxSize === 'cinema' lightboxSize === 'cinema'
? 'w-full border-t h-[30%] shrink-0' ? 'w-full border-t h-[40%] md:h-[30%] shrink-0'
: 'w-full md:w-80 border-t md:border-t-0 md:border-l shrink-0' : 'w-full md:w-80 border-t md:border-t-0 md:border-l h-[45%] md:h-auto shrink-0'
}`}> }`}>
<div className="space-y-4"> <div className="space-y-4">
{/* Toolbar with Resizer Preset Buttons */} {/* Toolbar with Resizer Preset Buttons */}
<div className="flex justify-between items-center pb-2 border-b border-ink-800/60"> <div className="flex justify-between items-center pb-2 border-b border-ink-800/60">
<div className="flex bg-ink-900 p-0.5 rounded-lg border border-ink-800 gap-0.5"> <div className="hidden md:flex bg-ink-900 p-0.5 rounded-lg border border-ink-800 gap-0.5">
<button <button
onClick={() => setLightboxSize('compact')} onClick={() => setLightboxSize('compact')}
className={`p-1.5 rounded text-[9px] font-black uppercase tracking-wider flex items-center gap-1 transition-all cursor-pointer ${ className={`p-1.5 rounded text-[9px] font-black uppercase tracking-wider flex items-center gap-1 transition-all cursor-pointer ${
@ -383,7 +416,7 @@ export const ShowcasePage: React.FC = () => {
</div> </div>
<button <button
onClick={() => setPlayingVideoId(null)} onClick={() => setPlayingVideoId(null)}
className="text-ink-400 hover:text-ink-0 transition-colors p-1.5 rounded-lg hover:bg-ink-850 cursor-pointer" className="text-ink-400 hover:text-ink-0 transition-colors p-1.5 rounded-lg hover:bg-ink-850 cursor-pointer ml-auto md:ml-0"
> >
<X className="w-4 h-4" /> <X className="w-4 h-4" />
</button> </button>
@ -405,7 +438,7 @@ export const ShowcasePage: React.FC = () => {
</h3> </h3>
{activeItem.description && ( {activeItem.description && (
<div className="max-h-60 overflow-y-auto pr-1"> <div className="max-h-60 md:max-h-none overflow-y-auto pr-1">
<p className="text-xs font-medium text-ink-400 leading-relaxed whitespace-pre-wrap"> <p className="text-xs font-medium text-ink-400 leading-relaxed whitespace-pre-wrap">
{activeItem.description} {activeItem.description}
</p> </p>

View File

@ -30,6 +30,7 @@ import {
getYoutubeMeta, getYoutubeMeta,
} from '../../services/ecosystem-api'; } from '../../services/ecosystem-api';
import type { EcosystemOffering, ContentShowcase } from '../../services/ecosystem-api'; import type { EcosystemOffering, ContentShowcase } from '../../services/ecosystem-api';
import { useToast } from '../../hooks/use-toast';
// Custom Brand Logo renderer with remote URL preset load + high-fidelity SVG fallback // Custom Brand Logo renderer with remote URL preset load + high-fidelity SVG fallback
export const BrandLogo: React.FC<{ name: string; className?: string }> = ({ name, className = "h-7" }) => { export const BrandLogo: React.FC<{ name: string; className?: string }> = ({ name, className = "h-7" }) => {
@ -126,6 +127,7 @@ const extractYouTubeVideoId = (url: string): string | null => {
// ── Content Showcase Manager Component ── // ── Content Showcase Manager Component ──
const ContentShowcaseManager: React.FC = () => { const ContentShowcaseManager: React.FC = () => {
const { success, error } = useToast();
const [items, setItems] = useState<ContentShowcase[]>([]); const [items, setItems] = useState<ContentShowcase[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [isModalOpen, setIsModalOpen] = useState(false); const [isModalOpen, setIsModalOpen] = useState(false);
@ -154,7 +156,10 @@ const ContentShowcaseManager: React.FC = () => {
// Action to fetch metadata explicitly // Action to fetch metadata explicitly
const handleScrapeMetadata = async () => { const handleScrapeMetadata = async () => {
const url = scYoutubeUrl.trim(); const url = scYoutubeUrl.trim();
if (!url) return; if (!url) {
error('URL Required', 'Please enter a media URL first.');
return;
}
const lower = url.toLowerCase(); const lower = url.toLowerCase();
const isYt = lower.includes('youtube.com') || lower.includes('youtu.be') || lower.includes('youtube-nocookie.com'); const isYt = lower.includes('youtube.com') || lower.includes('youtu.be') || lower.includes('youtube-nocookie.com');
@ -162,7 +167,7 @@ const ContentShowcaseManager: React.FC = () => {
const isTw = lower.includes('twitter.com') || lower.includes('x.com'); const isTw = lower.includes('twitter.com') || lower.includes('x.com');
if (!isYt && !isIg && !isTw) { if (!isYt && !isIg && !isTw) {
setFormError('Please enter a valid YouTube, Instagram, or Twitter/X URL.'); error('Invalid URL', 'Please enter a valid YouTube, Instagram, or Twitter/X URL.');
return; return;
} }
@ -175,8 +180,10 @@ const ContentShowcaseManager: React.FC = () => {
if (meta.thumbnailUrl) { if (meta.thumbnailUrl) {
setScThumbnailUrl(meta.thumbnailUrl); setScThumbnailUrl(meta.thumbnailUrl);
} }
success('Metadata Fetched', 'Media title, description, and thumbnail populated successfully.');
} catch (err: any) { } catch (err: any) {
console.error('Failed to fetch video meta:', err); console.error('Failed to fetch video meta:', err);
error('Fetch Failed', 'Failed to retrieve metadata. You can input fields manually.');
setFormError('Failed to fetch video metadata. You can enter the title and description manually.'); setFormError('Failed to fetch video metadata. You can enter the title and description manually.');
} finally { } finally {
setIsFetchingMeta(false); setIsFetchingMeta(false);
@ -206,7 +213,9 @@ const ContentShowcaseManager: React.FC = () => {
try { try {
const res = await uploadEcosystemFile(file); const res = await uploadEcosystemFile(file);
setScThumbnailUrl(res.url); setScThumbnailUrl(res.url);
success('Upload Success', 'Thumbnail image uploaded successfully.');
} catch (err: any) { } catch (err: any) {
error('Upload Failed', 'Failed to upload thumbnail image.');
setFormError('Failed to upload thumbnail image.'); setFormError('Failed to upload thumbnail image.');
} finally { } finally {
setUploadingThumbnail(false); setUploadingThumbnail(false);
@ -276,12 +285,15 @@ const ContentShowcaseManager: React.FC = () => {
if (editingItem) { if (editingItem) {
await updateShowcaseContent(editingItem.id, payload); await updateShowcaseContent(editingItem.id, payload);
success('Showcase Updated', 'The showcase item has been updated successfully.');
} else { } else {
await createShowcaseContent(payload); await createShowcaseContent(payload);
success('Showcase Created', 'A new showcase item has been created successfully.');
} }
setIsModalOpen(false); setIsModalOpen(false);
fetchItems(); fetchItems();
} catch (err: any) { } catch (err: any) {
error('Save Failed', err.response?.data?.error || 'Failed to save content.');
setFormError(err.response?.data?.error || 'Failed to save content.'); setFormError(err.response?.data?.error || 'Failed to save content.');
} finally { } finally {
setIsSaving(false); setIsSaving(false);
@ -294,14 +306,16 @@ const ContentShowcaseManager: React.FC = () => {
await deleteShowcaseContent(deleteItem.id); await deleteShowcaseContent(deleteItem.id);
setDeleteItem(null); setDeleteItem(null);
fetchItems(); fetchItems();
success('Showcase Deleted', 'The showcase item was deleted successfully.');
} catch (err) { } catch (err) {
console.error('Failed to delete:', err); console.error('Failed to delete:', err);
error('Delete Failed', 'Failed to delete the showcase item.');
} }
}; };
// Auto-thumbnail from the current YouTube URL field // Auto-thumbnail from the current YouTube URL field
const previewVideoId = extractYouTubeVideoId(scYoutubeUrl); const previewVideoId = extractYouTubeVideoId(scYoutubeUrl);
const previewThumbnail = previewVideoId ? `https://img.youtube.com/vi/${previewVideoId}/mqdefault.jpg` : null; const previewThumbnail = scThumbnailUrl || (previewVideoId ? `https://img.youtube.com/vi/${previewVideoId}/mqdefault.jpg` : null);
return ( return (
<div className="space-y-4 pt-2 border-t border-ink-200 mt-6"> <div className="space-y-4 pt-2 border-t border-ink-200 mt-6">