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 0000000..1bdbb77 Binary files /dev/null and b/Channel-Frontend/public/favicon.ico differ diff --git a/Channel-Frontend/src/app/layouts/AdminLayout.tsx b/Channel-Frontend/src/app/layouts/AdminLayout.tsx index 20f450e..b77e0eb 100644 --- a/Channel-Frontend/src/app/layouts/AdminLayout.tsx +++ b/Channel-Frontend/src/app/layouts/AdminLayout.tsx @@ -100,7 +100,7 @@ export const AdminLayout: React.FC = () => { 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 && ( -
+