diff --git a/Channel-Backend/src/controllers/ecosystem.controller.ts b/Channel-Backend/src/controllers/ecosystem.controller.ts index a9c269e..6e1d902 100644 --- a/Channel-Backend/src/controllers/ecosystem.controller.ts +++ b/Channel-Backend/src/controllers/ecosystem.controller.ts @@ -134,8 +134,20 @@ export class EcosystemController { try { 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; + + 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) { const videoId = this.extractYouTubeVideoId(data.youtubeUrl); if (videoId) { @@ -145,8 +157,8 @@ export class EcosystemController { const created = await prisma.contentShowcase.create({ data: { - title: data.title, - description: data.description || null, + title: title || 'Media Showcase Item', + description, youtubeUrl: data.youtubeUrl, thumbnailUrl, redirectUrl: data.redirectUrl || null, @@ -167,9 +179,20 @@ export class EcosystemController { const { id } = req.params; 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; - 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); if (videoId) { thumbnailUrl = `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`; @@ -179,8 +202,8 @@ export class EcosystemController { const updated = await prisma.contentShowcase.update({ where: { id }, data: { - title: data.title, - description: data.description, + title, + description, youtubeUrl: data.youtubeUrl, thumbnailUrl, redirectUrl: data.redirectUrl, @@ -210,6 +233,21 @@ export class EcosystemController { // Helper: Extract video ID from various YouTube URL formats 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 = [ /(?: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})/, @@ -221,6 +259,113 @@ export class EcosystemController { 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(/]*>([\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(/') + .replace(/'/g, "'") + .replace(/'/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 { const idx = html.indexOf(targetStr); if (idx === -1) return null; diff --git a/Channel-Frontend/src/features/assets/components/AssetCard.tsx b/Channel-Frontend/src/features/assets/components/AssetCard.tsx index 5cddc67..75f216e 100644 --- a/Channel-Frontend/src/features/assets/components/AssetCard.tsx +++ b/Channel-Frontend/src/features/assets/components/AssetCard.tsx @@ -153,7 +153,7 @@ export const AssetCard: React.FC = ({ } 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 ? 'absolute z-20 top-0 left-0 right-0 h-auto shadow-2xl border-ink-300 bg-ink-0' : isRecommended diff --git a/Channel-Frontend/src/pages/ShowcasePage.tsx b/Channel-Frontend/src/pages/ShowcasePage.tsx index 4df893a..865ca62 100644 --- a/Channel-Frontend/src/pages/ShowcasePage.tsx +++ b/Channel-Frontend/src/pages/ShowcasePage.tsx @@ -16,6 +16,21 @@ import type { ContentShowcase } from '../services/ecosystem-api'; // ── Helper: Extract YouTube video ID ── 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 = [ /(?: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})/, @@ -107,13 +122,13 @@ interface VideoDescriptionProps { } const VideoDescription: React.FC = ({ text, isExpanded, onToggleExpand }) => { - const shouldCollapse = text.length > 180 || text.includes('\n'); + const shouldCollapse = text.length > 120 || text.includes('\n'); return ( -
+

@@ -195,7 +210,7 @@ export const ShowcasePage: React.FC = () => {

There are no featured videos available in the showcase right now. Check back later!

) : ( -
+
{items.map((item) => { const ytId = extractYouTubeVideoId(item.youtubeUrl); const isIg = item.youtubeUrl.includes('instagram.com'); @@ -204,11 +219,11 @@ export const ShowcasePage: React.FC = () => { const isExpanded = expandedItemId === item.id; return ( -
+
{ {/* Content Info */}
-

+

{item.title}

{item.description && ( diff --git a/Channel-Frontend/src/pages/admin/EcosystemManagerPage.tsx b/Channel-Frontend/src/pages/admin/EcosystemManagerPage.tsx index 835e4cc..6be19c2 100644 --- a/Channel-Frontend/src/pages/admin/EcosystemManagerPage.tsx +++ b/Channel-Frontend/src/pages/admin/EcosystemManagerPage.tsx @@ -30,6 +30,7 @@ import { getYoutubeMeta, } 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 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 ── const ContentShowcaseManager: React.FC = () => { + const { success, error } = useToast(); const [items, setItems] = useState([]); const [loading, setLoading] = useState(true); const [isModalOpen, setIsModalOpen] = useState(false); @@ -154,7 +156,10 @@ const ContentShowcaseManager: React.FC = () => { // Action to fetch metadata explicitly const handleScrapeMetadata = async () => { const url = scYoutubeUrl.trim(); - if (!url) return; + if (!url) { + error('URL Required', 'Please enter a media URL first.'); + return; + } const lower = url.toLowerCase(); 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'); 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; } @@ -175,8 +180,10 @@ const ContentShowcaseManager: React.FC = () => { if (meta.thumbnailUrl) { setScThumbnailUrl(meta.thumbnailUrl); } + success('Metadata Fetched', 'Media title, description, and thumbnail populated successfully.'); } catch (err: any) { 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.'); } finally { setIsFetchingMeta(false); @@ -206,7 +213,9 @@ const ContentShowcaseManager: React.FC = () => { try { const res = await uploadEcosystemFile(file); setScThumbnailUrl(res.url); + success('Upload Success', 'Thumbnail image uploaded successfully.'); } catch (err: any) { + error('Upload Failed', 'Failed to upload thumbnail image.'); setFormError('Failed to upload thumbnail image.'); } finally { setUploadingThumbnail(false); @@ -276,12 +285,15 @@ const ContentShowcaseManager: React.FC = () => { if (editingItem) { await updateShowcaseContent(editingItem.id, payload); + success('Showcase Updated', 'The showcase item has been updated successfully.'); } else { await createShowcaseContent(payload); + success('Showcase Created', 'A new showcase item has been created successfully.'); } setIsModalOpen(false); fetchItems(); } catch (err: any) { + error('Save Failed', err.response?.data?.error || 'Failed to save content.'); setFormError(err.response?.data?.error || 'Failed to save content.'); } finally { setIsSaving(false); @@ -294,14 +306,16 @@ const ContentShowcaseManager: React.FC = () => { await deleteShowcaseContent(deleteItem.id); setDeleteItem(null); fetchItems(); + success('Showcase Deleted', 'The showcase item was deleted successfully.'); } catch (err) { console.error('Failed to delete:', err); + error('Delete Failed', 'Failed to delete the showcase item.'); } }; // Auto-thumbnail from the current YouTube URL field 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 (