From 29871cf8346af7333829a1a024de2b7c87648b20 Mon Sep 17 00:00:00 2001 From: kenilkb Date: Fri, 17 Jul 2026 15:05:23 +0530 Subject: [PATCH] Video_assets --- Channel-Backend/prisma/schema.prisma | 14 + .../src/controllers/ecosystem.controller.ts | 282 +++ .../src/routes/ecosystem.routes.ts | 7 + Channel-Frontend/index.html | 2 +- Channel-Frontend/public/favicon.ico | Bin 0 -> 4286 bytes .../src/app/layouts/AdminLayout.tsx | 2 +- .../src/app/layouts/ClientLayout.tsx | 15 +- Channel-Frontend/src/app/router/index.tsx | 13 + .../components/ui/DocumentPreviewModal.tsx | 2 +- .../src/components/ui/MarkdownViewer.tsx | 10 +- Channel-Frontend/src/components/ui/Modal.tsx | 2 +- .../agreements/components/DocumentPreview.tsx | 4 +- .../features/assets/components/AssetCard.tsx | 6 +- .../assets/components/AssetExplorer.tsx | 4 +- .../assets/components/AssetViewerModal.tsx | 22 +- .../assets/components/UploadAssetModal.tsx | 2 +- Channel-Frontend/src/pages/AssetsPage.tsx | 2 +- Channel-Frontend/src/pages/EcosystemPage.tsx | 83 +- Channel-Frontend/src/pages/LoginPage.tsx | 2 +- Channel-Frontend/src/pages/ShowcasePage.tsx | 439 ++++ .../src/pages/admin/DirectoryPage.tsx | 12 +- .../src/pages/admin/EcosystemManagerPage.tsx | 588 ++++- .../src/pages/admin/LegalTemplatesPage.tsx | 2 +- .../src/services/ecosystem-api.ts | 48 + player_response.json | 2116 +++++++++++++++++ youtube.html | 83 + youtube_correct.html | 83 + youtube_l.html | 83 + ytInitialData.json | 1132 +++++++++ 29 files changed, 4956 insertions(+), 104 deletions(-) create mode 100644 Channel-Frontend/public/favicon.ico create mode 100644 Channel-Frontend/src/pages/ShowcasePage.tsx create mode 100644 player_response.json create mode 100644 youtube.html create mode 100644 youtube_correct.html create mode 100644 youtube_l.html create mode 100644 ytInitialData.json diff --git a/Channel-Backend/prisma/schema.prisma b/Channel-Backend/prisma/schema.prisma index ce3b888..936e412 100644 --- a/Channel-Backend/prisma/schema.prisma +++ b/Channel-Backend/prisma/schema.prisma @@ -183,3 +183,17 @@ model EcosystemOffering { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } + +model ContentShowcase { + id String @id @default(uuid()) + title String + description String? @db.Text + youtubeUrl String + thumbnailUrl String? + redirectUrl String? + redirectLabel String? @default("Learn More") + orderIndex Int @default(0) + isActive Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} diff --git a/Channel-Backend/src/controllers/ecosystem.controller.ts b/Channel-Backend/src/controllers/ecosystem.controller.ts index d4dffbc..a9c269e 100644 --- a/Channel-Backend/src/controllers/ecosystem.controller.ts +++ b/Channel-Backend/src/controllers/ecosystem.controller.ts @@ -111,4 +111,286 @@ export class EcosystemController { next(err); } }; + + // ── Content Showcase (YouTube Videos) ── + + public listShowcaseContent = async (req: Request, res: Response, next: NextFunction) => { + try { + const user = (req as any).user; + const isAdmin = user?.role === 'ADMIN'; + + const items = await prisma.contentShowcase.findMany({ + where: isAdmin ? undefined : { isActive: true }, + orderBy: { orderIndex: 'asc' } + }); + + res.status(200).json(items); + } catch (err) { + next(err); + } + }; + + public createShowcaseContent = async (req: Request, res: Response, next: NextFunction) => { + try { + const data = req.body; + + // Auto-extract YouTube thumbnail if not provided + let thumbnailUrl = data.thumbnailUrl || null; + if (!thumbnailUrl && data.youtubeUrl) { + const videoId = this.extractYouTubeVideoId(data.youtubeUrl); + if (videoId) { + thumbnailUrl = `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`; + } + } + + const created = await prisma.contentShowcase.create({ + data: { + title: data.title, + description: data.description || null, + youtubeUrl: data.youtubeUrl, + thumbnailUrl, + redirectUrl: data.redirectUrl || null, + redirectLabel: data.redirectLabel || 'Learn More', + orderIndex: data.orderIndex !== undefined ? data.orderIndex : 0, + isActive: data.isActive !== undefined ? data.isActive : true, + } + }); + + res.status(201).json(created); + } catch (err) { + next(err); + } + }; + + public updateShowcaseContent = async (req: Request, res: Response, next: NextFunction) => { + try { + const { id } = req.params; + const data = req.body; + + // Auto-extract YouTube thumbnail if URL changed and no explicit thumbnail + let thumbnailUrl = data.thumbnailUrl; + if (thumbnailUrl === undefined && data.youtubeUrl) { + const videoId = this.extractYouTubeVideoId(data.youtubeUrl); + if (videoId) { + thumbnailUrl = `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`; + } + } + + const updated = await prisma.contentShowcase.update({ + where: { id }, + data: { + title: data.title, + description: data.description, + youtubeUrl: data.youtubeUrl, + thumbnailUrl, + redirectUrl: data.redirectUrl, + redirectLabel: data.redirectLabel, + orderIndex: data.orderIndex, + isActive: data.isActive, + } + }); + + res.status(200).json(updated); + } catch (err) { + next(err); + } + }; + + public deleteShowcaseContent = async (req: Request, res: Response, next: NextFunction) => { + try { + const { id } = req.params; + await prisma.contentShowcase.delete({ + where: { id } + }); + res.status(200).json({ message: 'Content deleted successfully' }); + } catch (err) { + next(err); + } + }; + + // Helper: Extract video ID from various YouTube URL formats + private extractYouTubeVideoId(url: string): string | null { + 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})/, + ]; + for (const pattern of patterns) { + const match = url.match(pattern); + if (match) return match[1]; + } + return null; + } + + private extractJsonFromHtml(html: string, targetStr: string): any { + const idx = html.indexOf(targetStr); + if (idx === -1) return null; + + const start = idx + targetStr.length; + let openBraces = 0; + let inString = false; + let escape = false; + let endIdx = -1; + + for (let i = start; i < html.length; i++) { + const char = html[i]; + if (escape) { + escape = false; + continue; + } + if (char === '\\') { + escape = true; + continue; + } + if (char === '"') { + inString = !inString; + continue; + } + if (!inString) { + if (char === '{') { + openBraces++; + } else if (char === '}') { + openBraces--; + if (openBraces === 0) { + endIdx = i + 1; + break; + } + } + } + } + + if (endIdx !== -1) { + try { + const jsonStr = html.slice(start, endIdx); + return JSON.parse(jsonStr); + } catch (err) { + console.error('Failed to parse extracted JSON:', err); + } + } + return null; + } + + public getYoutubeMeta = async (req: Request, res: Response, next: NextFunction) => { + try { + const { url } = req.query; + if (!url || typeof url !== 'string') { + return res.status(400).json({ error: 'URL parameter is required' }); + } + + 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)}`; + try { + 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 res.status(200).json({ + title: data.author_name ? `Tweet by ${data.author_name}` : 'Tweet Content', + description, + thumbnailUrl: null + }); + } + } catch (err) { + console.error('Twitter oEmbed fetch error:', err); + } + return res.status(200).json({ + title: 'Tweet Post', + description: '', + thumbnailUrl: null + }); + } + + // Case 2: Instagram + if (lowerUrl.includes('instagram.com')) { + // Since Instagram requires Facebook API, return defaults so user can input manually + return res.status(200).json({ + title: 'Instagram Post/Reel', + description: '', + thumbnailUrl: null + }); + } + + // Case 3: YouTube + const videoId = this.extractYouTubeVideoId(normUrl); + if (!videoId) { + return res.status(400).json({ error: 'Invalid YouTube, Instagram, or Twitter/X URL' }); + } + + // 1. Fetch from oEmbed (gives us a clean title and thumbnail immediately) + const oembedUrl = `https://www.youtube.com/oembed?url=https://www.youtube.com/watch?v=${videoId}&format=json`; + let title = ''; + let thumbnailUrl = `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`; + + try { + 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); + } + + // 2. Fetch raw page to extract the full description + 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(); + + // Try to extract from ytInitialPlayerResponse first (full description) + const playerResponse = this.extractJsonFromHtml(html, 'ytInitialPlayerResponse = '); + if (playerResponse && playerResponse.videoDetails) { + description = playerResponse.videoDetails.shortDescription || ''; + if (playerResponse.videoDetails.title && !title) { + title = playerResponse.videoDetails.title; + } + } + + // Fallback to meta tags if description is still empty + if (!description) { + const descMatch = html.match(/') + .replace(/'/g, "'") + .replace(/'/g, "'"); + } + } + } + } catch (err) { + console.error('HTML scrape error:', err); + } + + res.status(200).json({ + title, + description, + thumbnailUrl + }); + } catch (err) { + next(err); + } + }; } diff --git a/Channel-Backend/src/routes/ecosystem.routes.ts b/Channel-Backend/src/routes/ecosystem.routes.ts index 7a2cd95..2ba6406 100644 --- a/Channel-Backend/src/routes/ecosystem.routes.ts +++ b/Channel-Backend/src/routes/ecosystem.routes.ts @@ -12,4 +12,11 @@ router.put('/offerings/:id', authenticate, requireRole('ADMIN'), controller.upda router.delete('/offerings/:id', authenticate, requireRole('ADMIN'), controller.deleteOffering); router.post('/upload', authenticate, requireRole('ADMIN'), upload.single('file'), controller.uploadFile); +// Content Showcase (YouTube Videos) +router.get('/showcase', authenticate, controller.listShowcaseContent); +router.get('/video-meta', authenticate, requireRole('ADMIN'), controller.getYoutubeMeta); +router.post('/showcase', authenticate, requireRole('ADMIN'), controller.createShowcaseContent); +router.put('/showcase/:id', authenticate, requireRole('ADMIN'), controller.updateShowcaseContent); +router.delete('/showcase/:id', authenticate, requireRole('ADMIN'), controller.deleteShowcaseContent); + export default router; diff --git a/Channel-Frontend/index.html b/Channel-Frontend/index.html index 43f8c08..1f2fa55 100644 --- a/Channel-Frontend/index.html +++ b/Channel-Frontend/index.html @@ -2,7 +2,7 @@ - + Tech4Biz Client & Admin Portal diff --git a/Channel-Frontend/public/favicon.ico b/Channel-Frontend/public/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..1bdbb77616a851932feff8f026ccd61341baf0d1 GIT binary patch literal 4286 zcmc&&YfKzf6uxs`v$HS*%d1pg3)n4`M@xAv6zKM0L9jd;ghC%g1R{DQGTJuiJDAFiP?SquX@R z7Zf}qG|ivO*=>%RxVz%=QH?hF^{ZjO{^NF^yizQAhe0(9CfgGqXq}ddpJ^_?v&*l8 z0?y;78lQZtRPsY6r5WoSIw7~!_~Y}yocvsC$jilo`*1D^jhC#^Kk=)1H2d|1YM(Hx zbdl`P3EBS}f7s9$Dx@$G35}Pm(OUdXh7N71It&%ch0$_u4wP(6{+p3kVIWV!DbQYGNpAcN1x7<`2M@ANZxt&7K|8<;iS=#3`rE>LkX`gT8aRJ`*e46Y3@aJo6X zjQM}AjmIV`xiwMc&H8tTG*Xg5=!Zv{Gk2K5Y%hmbIyhX6a5x_paHdAY21~ewNN7Ei zHM%QENufpP)o$)!uHn*N4l~UH&h8X&a+`qrN@c8lld#%zy?Opx@+ir-H2Ok>J9evp zC+h{MeK5X7!r=l5_hidhgDI@A^$lseQoz6@hcAHBzFkXYa z*8hCTNI=(Uo2fgPYq$Q!xi=#Iwe&we2e0E-{D#nIBkHfx{Wx#@-E02Vd`d=|34Od? zIQL@BadZBcd3epguZR*xG&Gy{+IZri9JxV((2mRqzDo+cxjozhqfAgcnutBwd7}=SrP{GrOT}}P-#&|mb`(! zqbvRJ-f+2}A1TrK4n@RPIX$S}9xu8-Q$9afAQw?je_adM=lnK+++Eoice)M-b0i!K zx?1@AT0R|_akT;{lswd6$mI#B$a_qK>@?&U z_BD$tpYLORjpO<-gZHq`U&Qss8V6mNdlj?Ffg1Y(T>oebU$$3%*M}Iq*~i>%o;@eXczIuqdz0Fj$aAS3O@!3z;!_EeRyvG&_fhlP!6V}CHr?C&0?_7~$U zit~+zeR>qfTMagHoyR { title={isCollapsed ? item.name : undefined} > {!isCollapsed && {item.name}} {!isCollapsed && isActive && ( diff --git a/Channel-Frontend/src/app/layouts/ClientLayout.tsx b/Channel-Frontend/src/app/layouts/ClientLayout.tsx index a328ad5..c5303a9 100644 --- a/Channel-Frontend/src/app/layouts/ClientLayout.tsx +++ b/Channel-Frontend/src/app/layouts/ClientLayout.tsx @@ -2,7 +2,7 @@ import React, { useState } from 'react'; import { Link, Outlet, useLocation, useNavigate } from 'react-router-dom'; import { useThemeStore } from '../../hooks/use-theme'; import { useAuthStore } from '../../hooks/use-auth'; -import { Cpu, LogOut, CheckCircle, Clock, Menu, X, Sun, Moon, ChevronRight, ChevronLeft, Settings, Globe } from 'lucide-react'; +import { Cpu, LogOut, CheckCircle, Clock, Menu, X, Sun, Moon, ChevronRight, ChevronLeft, Settings, Globe, Video } from 'lucide-react'; import { motion, AnimatePresence } from 'framer-motion'; export const ClientLayout: React.FC = () => { @@ -15,7 +15,8 @@ export const ClientLayout: React.FC = () => { const dynamicNavItems = [ { name: 'Assets', path: '/client', icon: Cpu, label: 'Asset Explorer' }, - { name: 'Ecosystem', path: '/client/ecosystem', icon: Globe, label: 'Explore More' } + { name: 'Ecosystem', path: '/client/ecosystem', icon: Globe, label: 'Explore More' }, + { name: 'Showcase', path: '/client/showcase', icon: Video, label: 'Featured Content' } ]; // Settings Modal State @@ -132,7 +133,7 @@ export const ClientLayout: React.FC = () => { }`} title={isCollapsed ? item.label : undefined} > - + {!isCollapsed && {item.label}} {!isCollapsed && isActive && } @@ -146,7 +147,7 @@ export const ClientLayout: React.FC = () => { } text-ink-500 hover:text-ink-900 hover:bg-ink-100`} title={isCollapsed ? 'Profile Settings' : undefined} > - + {!isCollapsed && Profile Settings} @@ -292,7 +293,7 @@ export const ClientLayout: React.FC = () => { >
-

Profile Settings

+

Profile Settings

Refine your profile parameters and manage credentials.

diff --git a/Channel-Frontend/src/app/router/index.tsx b/Channel-Frontend/src/app/router/index.tsx index 7505bb1..146b1ec 100644 --- a/Channel-Frontend/src/app/router/index.tsx +++ b/Channel-Frontend/src/app/router/index.tsx @@ -52,6 +52,11 @@ const EcosystemPage = React.lazy(() => default: m.EcosystemPage, })), ); +const ShowcasePage = React.lazy(() => + import("../../pages/ShowcasePage").then((m) => ({ + default: m.ShowcasePage, + })), +); const GroupDetailsPage = React.lazy(() => import("../../pages/admin/GroupDetailsPage").then((m) => ({ default: m.GroupDetailsPage, @@ -149,6 +154,14 @@ export const router = createBrowserRouter([ ), }, + { + path: "showcase", + element: ( + }> + + + ), + }, ], }, { diff --git a/Channel-Frontend/src/components/ui/DocumentPreviewModal.tsx b/Channel-Frontend/src/components/ui/DocumentPreviewModal.tsx index c106503..5481f99 100644 --- a/Channel-Frontend/src/components/ui/DocumentPreviewModal.tsx +++ b/Channel-Frontend/src/components/ui/DocumentPreviewModal.tsx @@ -235,7 +235,7 @@ export const DocumentPreviewModal: React.FC = ({ animate={{ opacity: 1 }} exit={{ opacity: 0 }} onClick={onClose} - className="fixed inset-0 bg-ink-950/60 backdrop-blur-md z-[9998]" + className="fixed inset-0 bg-ink-900/60 backdrop-blur-md z-[9998]" /> {/* Modal Box */} diff --git a/Channel-Frontend/src/components/ui/MarkdownViewer.tsx b/Channel-Frontend/src/components/ui/MarkdownViewer.tsx index f78b144..52119fa 100644 --- a/Channel-Frontend/src/components/ui/MarkdownViewer.tsx +++ b/Channel-Frontend/src/components/ui/MarkdownViewer.tsx @@ -298,7 +298,7 @@ export const renderInlineText = (text: string): React.ReactNode[] => { return tokens.map((part, idx) => { switch (part.type) { case "bold": - return {part.text}; + return {part.text}; case "italic": return {part.text}; case "code": @@ -324,8 +324,8 @@ export const renderInlineText = (text: string): React.ReactNode[] => { const renderListBlock = (block: MarkdownBlock, key: string): React.ReactNode => { const Component = block.type === "ul" ? "ul" : "ol"; const listClass = block.type === "ul" - ? "list-disc pl-6 space-y-1.5 my-2.5 text-sm text-ink-850" - : "list-decimal pl-6 space-y-1.5 my-2.5 text-sm text-ink-850"; + ? "list-disc pl-6 space-y-1.5 my-2.5 text-sm text-ink-800" + : "list-decimal pl-6 space-y-1.5 my-2.5 text-sm text-ink-800"; return ( {block.items?.map((item, idx) => ( @@ -338,10 +338,10 @@ const renderListBlock = (block: MarkdownBlock, key: string): React.ReactNode => const renderHeadingBlock = (block: MarkdownBlock, key: string): React.ReactNode => { const level = block.type.slice(1); const classes: Record = { - "1": "text-2xl sm:text-3xl font-extrabold text-ink-950 mt-6 mb-3 border-b border-ink-150 pb-2", + "1": "text-2xl sm:text-3xl font-extrabold text-ink-900 mt-6 mb-3 border-b border-ink-150 pb-2", "2": "text-xl sm:text-2xl font-extrabold text-ink-900 mt-5 mb-2.5", "3": "text-lg sm:text-xl font-bold text-ink-900 mt-4 mb-2", - "4": "text-base sm:text-lg font-bold text-ink-850 mt-3 mb-1.5", + "4": "text-base sm:text-lg font-bold text-ink-800 mt-3 mb-1.5", "5": "text-sm sm:text-base font-bold text-ink-800 mt-2.5 mb-1.5", "6": "text-xs sm:text-sm font-bold text-ink-700 mt-2 mb-1", }; diff --git a/Channel-Frontend/src/components/ui/Modal.tsx b/Channel-Frontend/src/components/ui/Modal.tsx index 15190b7..da00144 100644 --- a/Channel-Frontend/src/components/ui/Modal.tsx +++ b/Channel-Frontend/src/components/ui/Modal.tsx @@ -47,7 +47,7 @@ export const Modal: React.FC = ({ animate={{ opacity: 1 }} exit={{ opacity: 0 }} onClick={onClose} - className="fixed inset-0 bg-ink-950/40 backdrop-blur-sm z-[9999]" + className="fixed inset-0 bg-ink-900/40 backdrop-blur-sm z-[9999]" /> {/* Modal Container */} diff --git a/Channel-Frontend/src/features/agreements/components/DocumentPreview.tsx b/Channel-Frontend/src/features/agreements/components/DocumentPreview.tsx index 78cbef6..b6a8c95 100644 --- a/Channel-Frontend/src/features/agreements/components/DocumentPreview.tsx +++ b/Channel-Frontend/src/features/agreements/components/DocumentPreview.tsx @@ -105,7 +105,7 @@ export const DocumentPreview: React.FC = ({ }} > {/* Document Header / Letterhead */} -
+

{documentType === "NDA" ? "Mutual Non-Disclosure Agreement" @@ -347,7 +347,7 @@ export const DocumentPreview: React.FC = ({ For: Tech4Biz Solutions Inc.

- + Yasha Khandelwal{" "}
diff --git a/Channel-Frontend/src/features/assets/components/AssetCard.tsx b/Channel-Frontend/src/features/assets/components/AssetCard.tsx index d55e5b5..5cddc67 100644 --- a/Channel-Frontend/src/features/assets/components/AssetCard.tsx +++ b/Channel-Frontend/src/features/assets/components/AssetCard.tsx @@ -175,8 +175,8 @@ export const AssetCard: React.FC = ({ /> )} {isRecommended && ( -
- +
+ Recommended for You
)} @@ -580,7 +580,7 @@ export const AssetCard: React.FC = ({ )}
-

+

{asset.title}

{asset.description && ( diff --git a/Channel-Frontend/src/features/assets/components/AssetExplorer.tsx b/Channel-Frontend/src/features/assets/components/AssetExplorer.tsx index 64ce9ee..7738595 100644 --- a/Channel-Frontend/src/features/assets/components/AssetExplorer.tsx +++ b/Channel-Frontend/src/features/assets/components/AssetExplorer.tsx @@ -241,7 +241,7 @@ export const AssetExplorer: React.FC = () => {
@@ -275,7 +275,7 @@ export const AssetExplorer: React.FC = () => { {/* Asset Details Modal */} {selectedAsset && ( -
+