Video_assets

This commit is contained in:
kenilkb 2026-07-17 15:05:23 +05:30
parent 9db9e14328
commit 29871cf834
29 changed files with 4956 additions and 104 deletions

View File

@ -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
}

View File

@ -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(/<p[^>]*>([\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(/<meta\s+property="og:description"\s+content="([^"]*)"/i) ||
html.match(/<meta\s+name="description"\s+content="([^"]*)"/i) ||
html.match(/<meta\s+content="([^"]*)"\s+property="og:description"/i) ||
html.match(/<meta\s+content="([^"]*)"\s+name="description"/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);
}
res.status(200).json({
title,
description,
thumbnailUrl
});
} catch (err) {
next(err);
}
};
}

View File

@ -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;

View File

@ -2,7 +2,7 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/png" href="/logo.png" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Tech4Biz Client & Admin Portal</title>
</head>

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

View File

@ -100,7 +100,7 @@ export const AdminLayout: React.FC = () => {
title={isCollapsed ? item.name : undefined}
>
<Icon
className={`w-5 h-5 transition-transform group-hover:scale-110 ${isActive ? "text-ink-950" : "text-ink-400 group-hover:text-ink-900"}`}
className={`w-5 h-5 transition-transform group-hover:scale-110 ${isActive ? "text-ink-900" : "text-ink-400 group-hover:text-ink-900"}`}
/>
{!isCollapsed && <span>{item.name}</span>}
{!isCollapsed && isActive && (

View File

@ -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}
>
<Icon className={`w-5 h-5 transition-transform group-hover:scale-110 ${isActive ? 'text-ink-950' : 'text-ink-400 group-hover:text-ink-900'}`} />
<Icon className={`w-5 h-5 transition-transform group-hover:scale-110 ${isActive ? 'text-ink-900' : 'text-ink-400 group-hover:text-ink-900'}`} />
{!isCollapsed && <span>{item.label}</span>}
{!isCollapsed && isActive && <ChevronRight className="w-4 h-4 ml-auto opacity-50" />}
</Link>
@ -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}
>
<Settings className={`w-5 h-5 transition-transform group-hover:scale-110 ${settingsOpen ? 'text-ink-950' : 'text-ink-400 group-hover:text-ink-900'}`} />
<Settings className={`w-5 h-5 transition-transform group-hover:scale-110 ${settingsOpen ? 'text-ink-900' : 'text-ink-400 group-hover:text-ink-900'}`} />
{!isCollapsed && <span>Profile Settings</span>}
</button>
</nav>
@ -292,7 +293,7 @@ export const ClientLayout: React.FC = () => {
>
<div className="flex justify-between items-start mb-6">
<div>
<h3 className="text-xl font-black tracking-tight text-ink-950">Profile Settings</h3>
<h3 className="text-xl font-black tracking-tight text-ink-900">Profile Settings</h3>
<p className="text-xs font-bold text-ink-500 mt-1">Refine your profile parameters and manage credentials.</p>
</div>
<button
@ -345,7 +346,7 @@ export const ClientLayout: React.FC = () => {
{/* Security Fields */}
<div className="border-t border-ink-200 pt-4 mt-2">
<h4 className="text-sm font-bold text-ink-950 mb-3">Change Password</h4>
<h4 className="text-sm font-bold text-ink-900 mb-3">Change Password</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-xs font-extrabold uppercase tracking-widest text-ink-500 mb-1.5">New Password</label>
@ -383,7 +384,7 @@ export const ClientLayout: React.FC = () => {
<button
type="submit"
disabled={isSaving}
className="px-6 py-2.5 rounded-xl text-sm font-bold bg-ink-900 hover:bg-ink-950 text-ink-0 hover:shadow-lg transition-all disabled:opacity-50 cursor-pointer flex items-center justify-center gap-2"
className="px-6 py-2.5 rounded-xl text-sm font-bold bg-ink-900 hover:bg-ink-900 text-ink-0 hover:shadow-lg transition-all disabled:opacity-50 cursor-pointer flex items-center justify-center gap-2"
>
{isSaving ? 'Saving Changes...' : 'Save Settings'}
</button>

View File

@ -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([
</Suspense>
),
},
{
path: "showcase",
element: (
<Suspense fallback={<LoadingFallback />}>
<ShowcasePage />
</Suspense>
),
},
],
},
{

View File

@ -235,7 +235,7 @@ export const DocumentPreviewModal: React.FC<DocumentPreviewModalProps> = ({
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 */}

View File

@ -298,7 +298,7 @@ export const renderInlineText = (text: string): React.ReactNode[] => {
return tokens.map((part, idx) => {
switch (part.type) {
case "bold":
return <strong key={idx} className="font-extrabold text-ink-950">{part.text}</strong>;
return <strong key={idx} className="font-extrabold text-ink-900">{part.text}</strong>;
case "italic":
return <em key={idx} className="italic text-ink-800">{part.text}</em>;
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 (
<Component key={key} className={listClass}>
{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<string, string> = {
"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",
};

View File

@ -47,7 +47,7 @@ export const Modal: React.FC<ModalProps> = ({
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 */}

View File

@ -105,7 +105,7 @@ export const DocumentPreview: React.FC<DocumentPreviewProps> = ({
}}
>
{/* Document Header / Letterhead */}
<div className="text-center mb-10 pb-6 border-b-2 border-ink-950">
<div className="text-center mb-10 pb-6 border-b-2 border-ink-900">
<h1 className="text-xl md:text-2xl font-bold uppercase tracking-wider text-ink-900 font-sans mb-2">
{documentType === "NDA"
? "Mutual Non-Disclosure Agreement"
@ -347,7 +347,7 @@ export const DocumentPreview: React.FC<DocumentPreviewProps> = ({
For: Tech4Biz Solutions Inc.
</p>
<div className="h-16 flex items-end pb-1 border-b border-ink-300 relative">
<span className="font-serif italic text-base text-ink-950 select-none pb-1">
<span className="font-serif italic text-base text-ink-900 select-none pb-1">
Yasha Khandelwal{" "}
</span>
</div>

View File

@ -175,8 +175,8 @@ export const AssetCard: React.FC<AssetCardProps> = ({
/>
)}
{isRecommended && (
<div className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded bg-amber-500 text-ink-950 text-[9px] font-extrabold uppercase tracking-wider shadow-sm border border-amber-400">
<Sparkles className="w-2.5 h-2.5 fill-ink-950" />
<div className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded bg-amber-500 text-ink-900 text-[9px] font-extrabold uppercase tracking-wider shadow-sm border border-amber-400">
<Sparkles className="w-2.5 h-2.5 fill-ink-900" />
<span>Recommended for You</span>
</div>
)}
@ -580,7 +580,7 @@ export const AssetCard: React.FC<AssetCardProps> = ({
)}
</div>
<h3 className="font-bold text-ink-900 text-sm asset-card-title leading-snug line-clamp-2 group-hover:text-ink-950 transition-colors" title={asset.title}>
<h3 className="font-bold text-ink-900 text-sm asset-card-title leading-snug line-clamp-2 group-hover:text-ink-900 transition-colors" title={asset.title}>
{asset.title}
</h3>
{asset.description && (

View File

@ -241,7 +241,7 @@ export const AssetExplorer: React.FC = () => {
<div className="flex items-center justify-between border-t border-ink-100 pt-3">
<button
onClick={() => setSelectedAsset(asset)}
className="text-xs font-bold text-ink-800 hover:text-ink-950 transition-colors"
className="text-xs font-bold text-ink-800 hover:text-ink-900 transition-colors"
>
View Details
</button>
@ -275,7 +275,7 @@ export const AssetExplorer: React.FC = () => {
{/* Asset Details Modal */}
{selectedAsset && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-ink-950/20 backdrop-blur-sm p-4 animate-fade-in">
<div className="fixed inset-0 z-50 flex items-center justify-center bg-ink-900/20 backdrop-blur-sm p-4 animate-fade-in">
<div className="w-full max-w-2xl rounded-2xl border border-ink-200 bg-ink-0 p-6 shadow-xl max-h-[90vh] overflow-y-auto space-y-6 relative animate-scale-up">
<button
onClick={() => setSelectedAsset(null)}

View File

@ -332,7 +332,7 @@ export const AssetViewerModal: React.FC<AssetViewerModalProps> = ({
<div className="w-full h-full flex flex-col min-h-0 bg-ink-0">
<div className="bg-ink-100 px-4 py-2 border-b border-ink-200 flex justify-between items-center text-xs text-ink-600 font-bold select-none">
<span className="flex items-center gap-1.5">
<Globe className="w-4 h-4 text-ink-950" />
<Globe className="w-4 h-4 text-ink-900" />
<span className="font-sans">Embedded GitHub Document</span>
</span>
</div>
@ -345,7 +345,7 @@ export const AssetViewerModal: React.FC<AssetViewerModalProps> = ({
) : (
<div className="max-w-3xl mx-auto space-y-4">
<div className="border-b border-ink-200 pb-4 mb-6">
<h1 className="text-xl font-extrabold text-ink-950 font-sans">{asset.title}</h1>
<h1 className="text-xl font-extrabold text-ink-900 font-sans">{asset.title}</h1>
<p className="text-xs text-ink-500 mt-1 font-mono">{asset.url}</p>
</div>
<MarkdownViewer markdown={textPreviewContent} />
@ -357,7 +357,7 @@ export const AssetViewerModal: React.FC<AssetViewerModalProps> = ({
<div className="w-full h-full flex flex-col min-h-0 bg-ink-0">
<div className="bg-ink-100 px-4 py-2 border-b border-ink-200 flex justify-between items-center text-xs text-ink-600 font-bold select-none shrink-0">
<span className="flex items-center gap-1.5">
<Globe className="w-4 h-4 text-ink-950" />
<Globe className="w-4 h-4 text-ink-900" />
<span className="font-sans">External Web Link Portal Guide</span>
</span>
<span className="text-[10px] text-ink-400 font-mono font-normal truncate max-w-xs">{asset.url}</span>
@ -440,7 +440,7 @@ export const AssetViewerModal: React.FC<AssetViewerModalProps> = ({
<span className="inline-flex px-2 py-0.5 rounded text-[9px] font-extrabold uppercase tracking-wider text-indigo-650 bg-indigo-50 border border-indigo-100">
External Portal Link
</span>
<h3 className="text-lg font-extrabold text-ink-950 font-sans leading-snug mt-1.5">{asset.title}</h3>
<h3 className="text-lg font-extrabold text-ink-900 font-sans leading-snug mt-1.5">{asset.title}</h3>
{asset.description ? (
<p className="text-xs text-ink-500 font-sans mt-2 leading-relaxed">{asset.description}</p>
) : (
@ -471,7 +471,7 @@ export const AssetViewerModal: React.FC<AssetViewerModalProps> = ({
href={asset.url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center justify-center gap-2 px-6 py-2.5 rounded-xl bg-ink-900 text-ink-0 text-xs font-bold hover:bg-ink-850 transition-all hover:scale-[1.02] active:scale-95 shadow-md w-full font-sans cursor-pointer"
className="inline-flex items-center justify-center gap-2 px-6 py-2.5 rounded-xl bg-ink-900 text-ink-0 text-xs font-bold hover:bg-ink-800 transition-all hover:scale-[1.02] active:scale-95 shadow-md w-full font-sans cursor-pointer"
>
<span>Open Website in New Tab</span>
<ExternalLink className="w-4 h-4" />
@ -498,7 +498,7 @@ export const AssetViewerModal: React.FC<AssetViewerModalProps> = ({
) : asset.type.includes('pdf') ? (
<div className="w-full h-full flex flex-col min-h-0">
<div className="bg-ink-100 px-4 py-2 border-b border-ink-200 flex items-center text-xs text-ink-600 font-bold select-none">
<FileText className="w-4 h-4 text-ink-950 mr-1.5" />
<FileText className="w-4 h-4 text-ink-900 mr-1.5" />
<span className="font-sans">Interactive PDF Preview</span>
</div>
<iframe
@ -510,7 +510,7 @@ export const AssetViewerModal: React.FC<AssetViewerModalProps> = ({
) : isTextOrCodeAsset(asset.url, asset.type) ? (
<div className="w-full h-full flex flex-col min-h-0 bg-ink-0">
<div className="bg-ink-100 px-4 py-2 border-b border-ink-200 flex items-center text-xs text-ink-600 font-bold select-none">
<FileText className="w-4 h-4 text-ink-950 mr-1.5" />
<FileText className="w-4 h-4 text-ink-900 mr-1.5" />
<span className="font-sans">Document Reader</span>
</div>
<div className="flex-1 overflow-auto p-6 text-left select-text bg-ink-0 text-ink-800 font-sans leading-relaxed">
@ -522,7 +522,7 @@ export const AssetViewerModal: React.FC<AssetViewerModalProps> = ({
) : (
<div className="max-w-3xl mx-auto space-y-4">
<div className="border-b border-ink-200 pb-4 mb-6">
<h1 className="text-xl font-extrabold text-ink-950 font-sans">{asset.title}</h1>
<h1 className="text-xl font-extrabold text-ink-900 font-sans">{asset.title}</h1>
<p className="text-xs text-ink-500 mt-1 font-sans">Plain Text / Source Code Format</p>
</div>
{asset.url.toLowerCase().endsWith('.md') || asset.type.includes('markdown') ? (
@ -552,9 +552,9 @@ export const AssetViewerModal: React.FC<AssetViewerModalProps> = ({
/>
</div>
) : isVideoAsset(asset.url, asset.type) ? (
<div className="w-full h-full flex flex-col min-h-0 bg-ink-950">
<div className="w-full h-full flex flex-col min-h-0 bg-ink-900">
<div className="bg-ink-100 px-4 py-2 border-b border-ink-200 flex items-center text-xs text-ink-600 font-bold select-none w-full shrink-0">
<FileText className="w-4 h-4 text-ink-950 mr-1.5" />
<FileText className="w-4 h-4 text-ink-900 mr-1.5" />
<span className="font-sans text-ink-900">Video Player</span>
</div>
<div className="flex-1 flex items-center justify-center min-h-0 relative p-4">
@ -570,7 +570,7 @@ export const AssetViewerModal: React.FC<AssetViewerModalProps> = ({
<div className="w-full h-full flex flex-col min-h-0 bg-ink-0">
<div className="bg-ink-100 px-4 py-2 border-b border-ink-200 flex justify-between items-center text-xs text-ink-600 font-bold select-none shrink-0">
<span className="flex items-center gap-1.5">
<FileText className="w-4 h-4 text-ink-950" />
<FileText className="w-4 h-4 text-ink-900" />
<span className="font-sans">
{isWord ? 'Interactive Word Document Viewer' : isSpreadsheet ? 'Interactive Spreadsheet Grid Viewer' : 'Interactive Presentation Viewer'}
</span>

View File

@ -290,7 +290,7 @@ export const UploadAssetModal: React.FC<UploadAssetModalProps> = ({
e.preventDefault();
setFullImagePreviewUrl(previewUrl);
}}
className="p-1 rounded-lg bg-ink-55 hover:bg-ink-100 text-ink-600 hover:text-ink-955 border border-ink-200 transition-all duration-200 hover:scale-105 active:scale-95 flex items-center justify-center cursor-pointer"
className="p-1 rounded-lg bg-ink-55 hover:bg-ink-100 text-ink-600 hover:text-ink-900 border border-ink-200 transition-all duration-200 hover:scale-105 active:scale-95 flex items-center justify-center cursor-pointer"
title="Preview Image"
>
<Eye className="w-3 h-3" />

View File

@ -276,7 +276,7 @@ export const AssetsPage = () => {
subtitle="Securely manage, distribute, and track marketing collateral and partner resources."
// badge={
// <div className="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full bg-ink-100 border border-ink-200 text-[10px] font-bold text-ink-900 tracking-wider uppercase shrink-0">
// <CheckCircle className="w-3.5 h-3.5 text-ink-950" />
// <CheckCircle className="w-3.5 h-3.5 text-ink-900" />
// <span>Global CDN Active</span>
// </div>
// }

View File

@ -29,17 +29,17 @@ export const EcosystemPage: React.FC = () => {
const [filter, setFilter] = useState<'ALL' | 'PRODUCT' | 'SERVICE'>('ALL');
useEffect(() => {
const fetchOfferings = async () => {
const fetchData = async () => {
try {
const data = await getEcosystemOfferings();
setOfferings(data);
} catch (err) {
console.error('Failed to load ecosystem offerings:', err);
console.error('Failed to load ecosystem data:', err);
} finally {
setLoading(false);
}
};
fetchOfferings();
fetchData();
}, []);
const filteredOfferings = offerings.filter(o => {
@ -56,22 +56,30 @@ export const EcosystemPage: React.FC = () => {
return (
<PageLayout header={headerNode}>
<div className="p-6 space-y-6">
<div className="p-6 space-y-8">
{/* Segmented controls filter */}
<div className="flex justify-between items-center border-b border-ink-200 pb-4">
<div className="flex bg-ink-100 p-0.5 rounded-xl border border-ink-200">
{(['ALL', 'PRODUCT', 'SERVICE'] as const).map((type) => (
<button
key={type}
onClick={() => setFilter(type)}
className={`px-4 py-2 rounded-lg text-xs font-black tracking-wider uppercase transition-all duration-300 cursor-pointer ${filter === type
? 'bg-ink-0 text-ink-950 shadow-sm border border-ink-200/50'
: 'text-ink-500 hover:text-ink-900'
<div className="flex bg-ink-100 p-0.5 rounded-xl border border-ink-200 overflow-x-auto max-w-full">
{(['ALL', 'PRODUCT', 'SERVICE'] as const).map((type) => {
const labels: Record<string, string> = {
ALL: 'All Solutions',
PRODUCT: 'Products',
SERVICE: 'Services'
};
return (
<button
key={type}
onClick={() => setFilter(type)}
className={`px-4 py-2 rounded-lg text-xs font-black tracking-wider uppercase transition-all duration-300 whitespace-nowrap cursor-pointer ${
filter === type
? 'bg-ink-0 text-ink-900 shadow-sm border border-ink-200/50'
: 'text-ink-500 hover:text-ink-900'
}`}
>
{type}s
</button>
))}
>
{labels[type]}
</button>
);
})}
</div>
<div className="text-[10px] font-bold text-ink-400 uppercase tracking-widest hidden sm:block">
{filteredOfferings.length} Solutions Available
@ -83,23 +91,22 @@ export const EcosystemPage: React.FC = () => {
<div className="w-8 h-8 border-4 border-ink-900/30 border-t-ink-900 rounded-full animate-spin"></div>
</div>
) : (
<motion.div
layout
className="grid grid-cols-1 md:grid-cols-2 gap-8"
>
<AnimatePresence mode="popLayout">
{filteredOfferings.map((offering, idx) => {
<AnimatePresence mode="wait">
<motion.div
key="offerings-grid"
initial={{ opacity: 0, y: 15 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -15 }}
transition={{ duration: 0.25 }}
className="grid grid-cols-1 md:grid-cols-2 gap-8"
>
{filteredOfferings.map((offering) => {
const IconComponent = iconMap[offering.logoIcon] || Globe;
const isProduct = offering.type === 'PRODUCT';
return (
<motion.div
<div
key={offering.id}
layout
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ type: 'spring', stiffness: 300, damping: 25, delay: idx * 0.05 }}
className="group flex flex-col justify-between bg-ink-0 border border-ink-200 hover:border-ink-450 rounded-3xl p-6 md:p-8 hover:shadow-xl transition-all duration-500"
>
<div>
@ -107,7 +114,7 @@ export const EcosystemPage: React.FC = () => {
<div className="flex justify-between items-center mb-6">
<div className="h-10 flex items-center shrink-0">
{offering.logoUrl ? (
<BrandLogo name={offering.logoUrl} className="max-h-7 max-w-[150px] object-contain text-ink-955 dark:text-ink-0" />
<BrandLogo name={offering.logoUrl} className="max-h-7 max-w-[150px] object-contain text-ink-900 dark:text-ink-0" />
) : (
<div className="w-10 h-10 rounded-xl flex items-center justify-center text-ink-0 bg-ink-900 shadow-md">
<IconComponent className="w-5 h-5" />
@ -115,10 +122,11 @@ export const EcosystemPage: React.FC = () => {
)}
</div>
<span className={`inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-[9px] font-black tracking-widest uppercase border ${isProduct
<span className={`inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-[9px] font-black tracking-widest uppercase border ${
isProduct
? 'bg-blue-500/10 text-blue-600 border-blue-500/20'
: 'bg-emerald-500/10 text-emerald-600 border-emerald-500/20'
}`}>
}`}>
<Layers className="w-3 h-3" />
{offering.type}
</span>
@ -126,7 +134,7 @@ export const EcosystemPage: React.FC = () => {
{/* Info Copy */}
<div className="space-y-3">
<h3 className="text-lg font-black tracking-tight text-ink-950">
<h3 className="text-lg font-black tracking-tight text-ink-900">
{offering.name}
</h3>
<p className="text-xs font-bold text-ink-700 leading-snug">
@ -144,8 +152,9 @@ export const EcosystemPage: React.FC = () => {
<ul className="grid grid-cols-1 gap-2.5 pt-1">
{offering.benefits.map((benefit, bIdx) => (
<li key={bIdx} className="flex items-start gap-2.5 text-xs font-medium text-ink-600">
<span className={`w-1.5 h-1.5 rounded-full shrink-0 mt-1.5 ${isProduct ? 'bg-blue-500' : 'bg-emerald-500'
}`} />
<span className={`w-1.5 h-1.5 rounded-full shrink-0 mt-1.5 ${
isProduct ? 'bg-blue-500' : 'bg-emerald-500'
}`} />
<span>{benefit}</span>
</li>
))}
@ -198,11 +207,11 @@ export const EcosystemPage: React.FC = () => {
<ExternalLink className="w-3.5 h-3.5" />
</a>
</div>
</motion.div>
</div>
);
})}
</AnimatePresence>
</motion.div>
</motion.div>
</AnimatePresence>
)}
</div>
</PageLayout>

View File

@ -161,7 +161,7 @@ export const LoginPage = () => {
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute inset-y-0 right-0 pr-3.5 flex items-center text-ink-400 hover:text-ink-950 focus:outline-none transition-colors"
className="absolute inset-y-0 right-0 pr-3.5 flex items-center text-ink-400 hover:text-ink-900 focus:outline-none transition-colors"
>
{showPassword ? (
<EyeOff className="w-4 h-4" />

View File

@ -0,0 +1,439 @@
import React, { useEffect, useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import {
Play,
X,
ExternalLink,
Video,
Maximize2,
Tv,
Monitor
} from 'lucide-react';
import { PageHeader } from '../components/ui/PageHeader';
import { PageLayout } from '../components/layout/PageLayout';
import { getShowcaseContent } from '../services/ecosystem-api';
import type { ContentShowcase } from '../services/ecosystem-api';
// ── Helper: Extract YouTube video ID ──
const 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;
};
// ── Helper: Extract Instagram post/reel ID ──
const extractInstagramId = (url: string): string | null => {
const patterns = [
/instagram\.com\/p\/([a-zA-Z0-9_-]+)/,
/instagram\.com\/reel\/([a-zA-Z0-9_-]+)/,
/instagram\.com\/tv\/([a-zA-Z0-9_-]+)/
];
for (const pattern of patterns) {
const match = url.match(pattern);
if (match) return match[1];
}
return null;
};
// ── YouTube Player Embed Component ──
const YouTubeEmbed: React.FC<{ videoId: string }> = ({ videoId }) => {
return (
<div className="w-full aspect-video bg-black rounded-2xl overflow-hidden shadow-2xl relative">
<iframe
src={`https://www.youtube-nocookie.com/embed/${videoId}?autoplay=1&rel=0&modestbranding=1`}
className="w-full h-full border-0 absolute inset-0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
allowFullScreen
/>
</div>
);
};
// ── Instagram Player Embed Component ──
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]';
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`}>
<iframe
src={`https://www.instagram.com/p/${postId}/embed`}
className="w-full h-full border-0"
allowFullScreen
scrolling="no"
allow="autoplay; clipboard-write; encrypted-media; picture-in-picture"
/>
</div>
);
};
// ── Twitter/X Embed Component ──
const TwitterEmbed: React.FC<{ url: string }> = ({ url }) => {
useEffect(() => {
if (!document.getElementById('twitter-wjs')) {
const script = document.createElement('script');
script.id = 'twitter-wjs';
script.src = 'https://platform.twitter.com/widgets.js';
script.async = true;
script.charset = 'utf-8';
document.body.appendChild(script);
} else {
try {
(window as any).twttr?.widgets?.load();
} catch (err) {
console.error('Failed to reload twitter widgets:', err);
}
}
}, [url]);
return (
<div className="w-full max-h-[70vh] overflow-y-auto flex justify-center bg-white p-6 rounded-2xl">
<blockquote className="twitter-tweet" data-align="center">
<a href={url}>Loading Tweet...</a>
</blockquote>
</div>
);
};
// ── Collapsible Video Description Component ──
const VideoDescription: React.FC<{ text: string }> = ({ text }) => {
const [isExpanded, setIsExpanded] = useState(false);
const shouldCollapse = text.length > 180 || text.includes('\n');
return (
<div className="space-y-2">
<p
className={`text-[11px] font-medium text-ink-500 leading-relaxed transition-all duration-300 ${
isExpanded ? '' : 'line-clamp-3'
}`}
style={{ whiteSpace: isExpanded ? 'pre-wrap' : 'normal' }}
>
{text}
</p>
{shouldCollapse && (
<button
onClick={(e) => {
e.stopPropagation();
setIsExpanded(!isExpanded);
}}
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"
>
{isExpanded ? 'Show Less' : 'Show More'}
</button>
)}
</div>
);
};
export const ShowcasePage: React.FC = () => {
const [items, setItems] = useState<ContentShowcase[]>([]);
const [loading, setLoading] = useState(true);
const [playingVideoId, setPlayingVideoId] = useState<string | null>(null);
// Resizable Lightbox state: compact | theater | cinema
const [lightboxSize, setLightboxSize] = useState<'compact' | 'theater' | 'cinema'>('compact');
useEffect(() => {
const fetchItems = async () => {
try {
const data = await getShowcaseContent();
setItems(data);
} catch (err) {
console.error('Failed to load showcase content:', err);
} finally {
setLoading(false);
}
};
fetchItems();
}, []);
const activeItem = items.find(item => item.id === playingVideoId);
const headerNode = (
<PageHeader
title="Featured Content"
subtitle="Discover interactive walk-throughs, demo reels, and case study updates across all Tech4Biz channels."
/>
);
return (
<PageLayout header={headerNode}>
<div className="p-6 space-y-6">
<div className="flex justify-between items-center border-b border-ink-200 pb-4">
<div className="flex items-center gap-2">
<div className="p-2 rounded-xl bg-blue-500/10 text-blue-600">
<Video className="w-5 h-5" />
</div>
<div>
<h3 className="text-sm font-black uppercase tracking-wider text-ink-900">Media Showcase</h3>
<p className="text-[10px] font-bold text-ink-500 uppercase tracking-widest mt-0.5">YouTube, Instagram & Twitter/X Gallery</p>
</div>
</div>
<div className="text-[10px] font-bold text-ink-400 uppercase tracking-widest">
{items.length} Videos Available
</div>
</div>
{loading ? (
<div className="flex flex-1 items-center justify-center py-20">
<div className="w-8 h-8 border-4 border-ink-900/30 border-t-ink-900 rounded-full animate-spin"></div>
</div>
) : items.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20 text-center border border-dashed border-ink-300 rounded-3xl p-8 bg-ink-50">
<Play className="w-12 h-12 text-ink-300 mb-3" />
<h4 className="text-sm font-black text-ink-900 uppercase tracking-wider">No Video Showcase Content</h4>
<p className="text-xs text-ink-500 mt-1 max-w-sm">There are no featured videos available in the showcase right now. Check back later!</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6 animate-fadeIn">
{items.map((item) => {
const ytId = extractYouTubeVideoId(item.youtubeUrl);
const isIg = item.youtubeUrl.includes('instagram.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);
return (
<div
key={item.id}
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"
>
{/* Video Player / Thumbnail */}
<div className="relative aspect-video bg-ink-900 overflow-hidden">
{thumbnail ? (
<img
src={thumbnail}
alt={item.title}
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-700"
/>
) : (
/* Platform Fallback Gradients */
<div className={`w-full h-full flex items-center justify-center ${
isIg
? 'bg-gradient-to-tr from-yellow-500 via-pink-500 to-purple-600'
: isTw
? 'bg-ink-950'
: 'bg-ink-100'
}`}>
{isIg && (
<svg className="w-12 h-12 text-white/90 animate-pulse" fill="none" stroke="currentColor" strokeWidth="1.5" viewBox="0 0 24 24">
<rect x="2" y="2" width="20" height="20" rx="5" ry="5" />
<path d="M16 11.37A4 4 0 1112.63 8 4 4 0 0116 11.37z" />
<line x1="17.5" y1="6.5" x2="17.51" y2="6.5" />
</svg>
)}
{isTw && (
<svg className="w-12 h-12 text-white/90 animate-pulse" fill="currentColor" viewBox="0 0 24 24">
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
</svg>
)}
{!isIg && !isTw && (
<Play className="w-12 h-12 text-ink-400" />
)}
</div>
)}
{/* Dark gradient overlay */}
<div className="absolute inset-0 bg-gradient-to-t from-ink-900/60 via-transparent to-transparent" />
{/* Play button overlay */}
<button
onClick={() => setPlayingVideoId(item.id)}
className="absolute inset-0 flex items-center justify-center cursor-pointer group/play"
>
<div className="w-14 h-14 rounded-full bg-ink-0/95 backdrop-blur-sm flex items-center justify-center shadow-2xl border border-ink-200/50 group-hover/play:scale-110 group-hover/play:bg-blue-600 group-hover/play:border-blue-500 transition-all duration-300">
<Play className="w-6 h-6 text-ink-900 group-hover/play:text-ink-0 ml-0.5 transition-colors" fill="currentColor" />
</div>
</button>
{/* Platform Tag */}
<div className="absolute bottom-2 right-2 px-2 py-0.5 rounded bg-ink-900/80 text-ink-0 text-[9px] font-bold backdrop-blur-sm">
{isIg ? 'Instagram' : isTw ? 'Twitter / X' : 'YouTube'}
</div>
</div>
{/* Content Info */}
<div className="p-5 flex-1 flex flex-col justify-between space-y-4">
<div className="space-y-2">
<h3 className="text-sm font-black tracking-tight text-ink-900 leading-snug">
{item.title}
</h3>
{item.description && (
<VideoDescription text={item.description} />
)}
</div>
{/* Redirect CTA */}
{item.redirectUrl && (
<div className="pt-2">
<a
href={item.redirectUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 text-[10px] font-black uppercase tracking-widest text-ink-700 hover:text-ink-900 transition-colors"
>
<span>{item.redirectLabel || 'Learn More'}</span>
<ExternalLink className="w-3 h-3" />
</a>
</div>
)}
</div>
</div>
);
})}
</div>
)}
{/* ── Immersive Lightbox Media Modal ── */}
<AnimatePresence>
{activeItem && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-ink-950/90 backdrop-blur-xl"
onClick={() => setPlayingVideoId(null)}
>
{/* Modal Container */}
<motion.div
initial={{ opacity: 0, scale: 0.9, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.9, y: 20 }}
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 ${
lightboxSize === 'cinema'
? 'w-[95vw] max-w-7xl flex-col h-[90vh]'
: lightboxSize === 'theater'
? 'w-[85vw] max-w-6xl flex-row max-h-[85vh]'
: 'w-full max-w-4xl flex-row max-h-[80vh]'
}`}
onClick={(e) => e.stopPropagation()}
>
{/* Media Container */}
<div className="flex-1 bg-black flex items-center justify-center p-4 overflow-hidden relative min-h-[300px]">
{(() => {
const ytId = extractYouTubeVideoId(activeItem.youtubeUrl);
if (ytId) {
return <YouTubeEmbed videoId={ytId} />;
}
const igId = extractInstagramId(activeItem.youtubeUrl);
if (igId) {
return <InstagramEmbed postId={igId} size={lightboxSize} />;
}
if (activeItem.youtubeUrl.includes('twitter.com') || activeItem.youtubeUrl.includes('x.com')) {
return <TwitterEmbed url={activeItem.youtubeUrl} />;
}
return (
<div className="text-ink-400 text-xs font-bold p-10">
Unsupported media format. Please visit direct link.
</div>
);
})()}
</div>
{/* Info Container */}
<div className={`p-6 flex flex-col justify-between border-ink-800 bg-ink-950 overflow-y-auto ${
lightboxSize === 'cinema'
? 'w-full border-t h-[30%] shrink-0'
: 'w-full md:w-80 border-t md:border-t-0 md:border-l shrink-0'
}`}>
<div className="space-y-4">
{/* Toolbar with Resizer Preset Buttons */}
<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">
<button
onClick={() => setLightboxSize('compact')}
className={`p-1.5 rounded text-[9px] font-black uppercase tracking-wider flex items-center gap-1 transition-all cursor-pointer ${
lightboxSize === 'compact'
? 'bg-ink-800 text-ink-0'
: 'text-ink-500 hover:text-ink-300'
}`}
title="Compact View"
>
<Monitor className="w-3 h-3" />
<span>Compact</span>
</button>
<button
onClick={() => setLightboxSize('theater')}
className={`p-1.5 rounded text-[9px] font-black uppercase tracking-wider flex items-center gap-1 transition-all cursor-pointer ${
lightboxSize === 'theater'
? 'bg-ink-800 text-ink-0'
: 'text-ink-500 hover:text-ink-300'
}`}
title="Theater View"
>
<Tv className="w-3 h-3" />
<span>Theater</span>
</button>
<button
onClick={() => setLightboxSize('cinema')}
className={`p-1.5 rounded text-[9px] font-black uppercase tracking-wider flex items-center gap-1 transition-all cursor-pointer ${
lightboxSize === 'cinema'
? 'bg-ink-800 text-ink-0'
: 'text-ink-500 hover:text-ink-300'
}`}
title="Cinema View"
>
<Maximize2 className="w-3 h-3" />
<span>Cinema</span>
</button>
</div>
<button
onClick={() => setPlayingVideoId(null)}
className="text-ink-400 hover:text-ink-0 transition-colors p-1.5 rounded-lg hover:bg-ink-850 cursor-pointer"
>
<X className="w-4 h-4" />
</button>
</div>
<div className="flex justify-between items-start pt-1">
<span className="px-2 py-0.5 rounded bg-ink-800 text-ink-300 text-[9px] font-black uppercase tracking-widest">
{(() => {
if (activeItem.youtubeUrl.includes('youtube.com') || activeItem.youtubeUrl.includes('youtu.be')) return 'YouTube';
if (activeItem.youtubeUrl.includes('instagram.com')) return 'Instagram';
if (activeItem.youtubeUrl.includes('twitter.com') || activeItem.youtubeUrl.includes('x.com')) return 'Twitter / X';
return 'Media';
})()}
</span>
</div>
<h3 className="text-base font-black tracking-tight text-ink-0 leading-snug">
{activeItem.title}
</h3>
{activeItem.description && (
<div className="max-h-60 overflow-y-auto pr-1">
<p className="text-xs font-medium text-ink-400 leading-relaxed whitespace-pre-wrap">
{activeItem.description}
</p>
</div>
)}
</div>
{activeItem.redirectUrl && (
<div className="pt-6 border-t border-ink-800 mt-6">
<a
href={activeItem.redirectUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center justify-center gap-2 w-full px-4 py-2.5 rounded-xl text-xs font-black uppercase tracking-wider bg-ink-0 text-ink-900 hover:bg-ink-100 hover:shadow-lg transition-all duration-300 cursor-pointer"
>
<span>{activeItem.redirectLabel || 'Explore More'}</span>
<ExternalLink className="w-3.5 h-3.5" />
</a>
</div>
)}
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</div>
</PageLayout>
);
};
export default ShowcasePage;

View File

@ -836,7 +836,7 @@ export const DirectoryPage: React.FC = () => {
setSelectedPartnerForAssets(partner);
setIsPartnerAssetsOpen(true);
}}
className="text-xs font-bold text-ink-700 hover:text-ink-950 hover:underline cursor-pointer focus:outline-none flex flex-col items-start gap-0.5"
className="text-xs font-bold text-ink-700 hover:text-ink-900 hover:underline cursor-pointer focus:outline-none flex flex-col items-start gap-0.5"
>
<span>
{(() => {
@ -884,7 +884,7 @@ export const DirectoryPage: React.FC = () => {
<button
onClick={() => handleResendInvite(partner.id, partner.email)}
disabled={resendingPartnerId === partner.id}
className="p-1.5 text-ink-600 hover:text-ink-950 hover:bg-ink-100 rounded-lg transition-colors cursor-pointer disabled:opacity-55 disabled:cursor-not-allowed"
className="p-1.5 text-ink-600 hover:text-ink-900 hover:bg-ink-100 rounded-lg transition-colors cursor-pointer disabled:opacity-55 disabled:cursor-not-allowed"
title="Resend Invitation Email"
>
{resendingPartnerId === partner.id ? (
@ -900,7 +900,7 @@ export const DirectoryPage: React.FC = () => {
navigator.clipboard.writeText(link);
success("Link copied", "Onboarding invitation link copied to clipboard!");
}}
className="p-1.5 text-ink-600 hover:text-ink-950 hover:bg-ink-100 rounded-lg transition-colors cursor-pointer"
className="p-1.5 text-ink-600 hover:text-ink-900 hover:bg-ink-100 rounded-lg transition-colors cursor-pointer"
title="Copy Invitation Link"
>
<Copy className="w-4 h-4" />
@ -921,7 +921,7 @@ export const DirectoryPage: React.FC = () => {
setEditShareAll(sharedIds.length === allAssets.length && allAssets.length > 0);
setIsEditOpen(true);
}}
className="p-1.5 text-ink-600 hover:text-ink-950 hover:bg-ink-100 rounded-lg transition-colors cursor-pointer"
className="p-1.5 text-ink-600 hover:text-ink-900 hover:bg-ink-100 rounded-lg transition-colors cursor-pointer"
>
<Pencil className="w-4 h-4" />
</button>
@ -1436,7 +1436,7 @@ export const DirectoryPage: React.FC = () => {
setSelectedAssetIds([]);
}
}}
className="rounded border-ink-300 text-ink-900 focus:ring-ink-950 w-4 h-4 cursor-pointer"
className="rounded border-ink-300 text-ink-900 focus:ring-ink-900 w-4 h-4 cursor-pointer"
/>
<label htmlFor="share-all-invite" className="text-xs font-bold text-ink-900 cursor-pointer select-none">
Share all assets in catalog
@ -1634,7 +1634,7 @@ export const DirectoryPage: React.FC = () => {
setEditAssetIds([]);
}
}}
className="rounded border-ink-300 text-ink-900 focus:ring-ink-950 w-4 h-4 cursor-pointer"
className="rounded border-ink-300 text-ink-900 focus:ring-ink-900 w-4 h-4 cursor-pointer"
/>
<label htmlFor="share-all-edit" className="text-xs font-bold text-ink-900 cursor-pointer select-none">
Share all assets in catalog

View File

@ -11,7 +11,9 @@ import {
Image as ImageIcon,
Upload,
Check,
AlertCircle
AlertCircle,
Play,
RefreshCw
} from 'lucide-react';
import { PageHeader } from '../../components/ui/PageHeader';
import { PageLayout } from '../../components/layout/PageLayout';
@ -20,9 +22,14 @@ import {
createEcosystemOffering,
updateEcosystemOffering,
deleteEcosystemOffering,
uploadEcosystemFile
uploadEcosystemFile,
getShowcaseContent,
createShowcaseContent,
updateShowcaseContent,
deleteShowcaseContent,
getYoutubeMeta,
} from '../../services/ecosystem-api';
import type { EcosystemOffering } from '../../services/ecosystem-api';
import type { EcosystemOffering, ContentShowcase } from '../../services/ecosystem-api';
// 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" }) => {
@ -50,7 +57,7 @@ export const BrandLogo: React.FC<{ name: string; className?: string }> = ({ name
const isTech4biz = normName.includes('tech4biz');
return (
<div className={`inline-flex items-center justify-center p-1.5 rounded-xl transition-all ${
isTech4biz ? 'bg-ink-950/90 dark:bg-ink-0/10' : 'bg-transparent'
isTech4biz ? 'bg-ink-900/90 dark:bg-ink-0/10' : 'bg-transparent'
}`}>
<img
src={resolvedUrl}
@ -66,7 +73,7 @@ export const BrandLogo: React.FC<{ name: string; className?: string }> = ({ name
if (normName.includes('tech4biz')) {
return (
<svg viewBox="0 0 120 30" className={className} fill="currentColor">
<path d="M10 6h12v4H16v14h-4V10H10V6zm20 8h8v3h-8v4h9v3h-13V10h13v3h-9v1zm19 5c0 1.7-.6 3.1-1.7 4.1C46.2 24.2 44.7 24.7 43 24.7c-2 0-3.6-.6-4.7-1.7-1.1-1.1-1.6-2.6-1.6-4.5v-4.5c0-1.9.5-3.4 1.6-4.5 1.1-1.1 2.7-1.7 4.7-1.7 1.8 0 3.2.5 4.3 1.5 1 1 1.5 2.5 1.5 4.3h-4c0-.9-.2-1.5-.6-1.9-.4-.4-.9-.6-1.7-.6-.7 0-1.3.3-1.6.8-.3.5-.5 1.3-.5 2.3v4.3c0 1 .2 1.8.5 2.3.3.5.9.8 1.6.8.8 0 1.3-.2 1.7-.6.4-.4.6-1 .6-1.9v-1h-2.5v-3h6.5v8.5zm13-13h4v8h8V6h4v18h-4v-7h-8v7h-4V6zm21 0h3.5l5 12 5-12h3.5v18h-3.5v-12.5l-5 12.5h-1l-5-12.5V24h-3.5V6zm19 0h4v18h-4V6z" className="text-ink-950 dark:text-ink-0" />
<path d="M10 6h12v4H16v14h-4V10H10V6zm20 8h8v3h-8v4h9v3h-13V10h13v3h-9v1zm19 5c0 1.7-.6 3.1-1.7 4.1C46.2 24.2 44.7 24.7 43 24.7c-2 0-3.6-.6-4.7-1.7-1.1-1.1-1.6-2.6-1.6-4.5v-4.5c0-1.9.5-3.4 1.6-4.5 1.1-1.1 2.7-1.7 4.7-1.7 1.8 0 3.2.5 4.3 1.5 1 1 1.5 2.5 1.5 4.3h-4c0-.9-.2-1.5-.6-1.9-.4-.4-.9-.6-1.7-.6-.7 0-1.3.3-1.6.8-.3.5-.5 1.3-.5 2.3v4.3c0 1 .2 1.8.5 2.3.3.5.9.8 1.6.8.8 0 1.3-.2 1.7-.6.4-.4.6-1 .6-1.9v-1h-2.5v-3h6.5v8.5zm13-13h4v8h8V6h4v18h-4v-7h-8v7h-4V6zm21 0h3.5l5 12 5-12h3.5v18h-3.5v-12.5l-5 12.5h-1l-5-12.5V24h-3.5V6zm19 0h4v18h-4V6z" className="text-ink-900 dark:text-ink-0" />
</svg>
);
}
@ -74,7 +81,7 @@ export const BrandLogo: React.FC<{ name: string; className?: string }> = ({ name
return (
<svg viewBox="0 0 140 30" className={className} fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M15 9c-3.3 0-6 2.7-6 6s2.7 6 6 6h12c2.2 0 4-1.8 4-4s-1.8-4-4-4h-2c-1.1 0-2-.9-2-2s.9-2 2-2h4v-2c0-1.1-.9-2-2-2h-8z" fill="#0EA5E9" />
<text x="36" y="21" fontFamily="sans-serif" fontWeight="900" fontSize="13" fill="currentColor" className="text-ink-950 dark:text-ink-0">Cloudtopiaa</text>
<text x="36" y="21" fontFamily="sans-serif" fontWeight="900" fontSize="13" fill="currentColor" className="text-ink-900 dark:text-ink-0">Cloudtopiaa</text>
</svg>
);
}
@ -82,7 +89,7 @@ export const BrandLogo: React.FC<{ name: string; className?: string }> = ({ name
return (
<svg viewBox="0 0 110 30" className={className} fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8 8l-6 7 6 7M18 8l6 7-6 7M14 6l-3 18" stroke="#6366F1" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" />
<text x="32" y="21" fontFamily="sans-serif" fontWeight="900" fontSize="14" fill="currentColor" className="text-ink-950 dark:text-ink-0">CodeNuk</text>
<text x="32" y="21" fontFamily="sans-serif" fontWeight="900" fontSize="14" fill="currentColor" className="text-ink-900 dark:text-ink-0">CodeNuk</text>
</svg>
);
}
@ -91,7 +98,7 @@ export const BrandLogo: React.FC<{ name: string; className?: string }> = ({ name
<svg viewBox="0 0 130 30" className={className} fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 4L4 7v8c0 5.5 3.8 10.7 8 12 4.2-1.3 8-6.5 8-12V7l-8-3z" fill="#10B981" />
<path d="M9 13.5l2 2 4-4" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
<text x="28" y="20" fontFamily="sans-serif" fontWeight="900" fontSize="11" fill="currentColor" className="text-ink-950 dark:text-ink-0">AUDITRAX LABS</text>
<text x="28" y="20" fontFamily="sans-serif" fontWeight="900" fontSize="11" fill="currentColor" className="text-ink-900 dark:text-ink-0">AUDITRAX LABS</text>
</svg>
);
}
@ -104,6 +111,536 @@ export const BrandLogo: React.FC<{ name: string; className?: string }> = ({ name
);
};
// ── Helper: Extract YouTube video ID from any URL format ──
const 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;
};
// ── Content Showcase Manager Component ──
const ContentShowcaseManager: React.FC = () => {
const [items, setItems] = useState<ContentShowcase[]>([]);
const [loading, setLoading] = useState(true);
const [isModalOpen, setIsModalOpen] = useState(false);
const [editingItem, setEditingItem] = useState<ContentShowcase | null>(null);
const [deleteItem, setDeleteItem] = useState<ContentShowcase | null>(null);
const [isSaving, setIsSaving] = useState(false);
const [formError, setFormError] = useState('');
// Form fields
const [scTitle, setScTitle] = useState('');
const [scDescription, setScDescription] = useState('');
const [scYoutubeUrl, setScYoutubeUrl] = useState('');
const [scThumbnailUrl, setScThumbnailUrl] = useState('');
const [scRedirectUrl, setScRedirectUrl] = useState('');
const [scRedirectLabel, setScRedirectLabel] = useState('Learn More');
const [scOrderIndex, setScOrderIndex] = useState(0);
const [scIsActive, setScIsActive] = useState(true);
// File Upload states for Thumbnail
const [uploadingThumbnail, setUploadingThumbnail] = useState(false);
const thumbnailFileRef = useRef<HTMLInputElement>(null);
// Auto-fetching metadata states
const [isFetchingMeta, setIsFetchingMeta] = useState(false);
// Action to fetch metadata explicitly
const handleScrapeMetadata = async () => {
const url = scYoutubeUrl.trim();
if (!url) return;
const lower = url.toLowerCase();
const isYt = lower.includes('youtube.com') || lower.includes('youtu.be') || lower.includes('youtube-nocookie.com');
const isIg = lower.includes('instagram.com');
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.');
return;
}
setIsFetchingMeta(true);
setFormError('');
try {
const meta = await getYoutubeMeta(url);
setScTitle(meta.title || '');
setScDescription(meta.description || '');
if (meta.thumbnailUrl) {
setScThumbnailUrl(meta.thumbnailUrl);
}
} catch (err: any) {
console.error('Failed to fetch video meta:', err);
setFormError('Failed to fetch video metadata. You can enter the title and description manually.');
} finally {
setIsFetchingMeta(false);
}
};
useEffect(() => { fetchItems(); }, []);
const fetchItems = async () => {
try {
setLoading(true);
const data = await getShowcaseContent();
setItems(data);
} catch (err) {
console.error('Failed to load showcase content:', err);
} finally {
setLoading(false);
}
};
const handleThumbnailUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setUploadingThumbnail(true);
setFormError('');
try {
const res = await uploadEcosystemFile(file);
setScThumbnailUrl(res.url);
} catch (err: any) {
setFormError('Failed to upload thumbnail image.');
} finally {
setUploadingThumbnail(false);
}
};
const openAddModal = () => {
setEditingItem(null);
setScTitle('');
setScDescription('');
setScYoutubeUrl('');
setScThumbnailUrl('');
setScRedirectUrl('');
setScRedirectLabel('Learn More');
setScOrderIndex(items.length);
setScIsActive(true);
setFormError('');
setIsModalOpen(true);
};
const openEditModal = (item: ContentShowcase) => {
setEditingItem(item);
setScTitle(item.title);
setScDescription(item.description || '');
setScYoutubeUrl(item.youtubeUrl);
setScThumbnailUrl(item.thumbnailUrl || '');
setScRedirectUrl(item.redirectUrl || '');
setScRedirectLabel(item.redirectLabel || 'Learn More');
setScOrderIndex(item.orderIndex);
setScIsActive(item.isActive);
setFormError('');
setIsModalOpen(true);
};
const handleSave = async () => {
if (!scTitle.trim() || !scYoutubeUrl.trim()) {
setFormError('Title and Media URL are required.');
return;
}
const detectPlatform = (url: string) => {
const norm = url.toLowerCase();
if (norm.includes('youtube.com') || norm.includes('youtu.be') || norm.includes('youtube-nocookie.com')) return 'YOUTUBE';
if (norm.includes('instagram.com')) return 'INSTAGRAM';
if (norm.includes('twitter.com') || norm.includes('x.com')) return 'TWITTER';
return 'UNKNOWN';
};
if (detectPlatform(scYoutubeUrl) === 'UNKNOWN') {
setFormError('Please enter a valid URL from YouTube, Instagram, or Twitter/X.');
return;
}
setIsSaving(true);
setFormError('');
try {
const payload = {
title: scTitle.trim(),
description: scDescription.trim() || null,
youtubeUrl: scYoutubeUrl.trim(),
thumbnailUrl: scThumbnailUrl.trim() || null,
redirectUrl: scRedirectUrl.trim() || null,
redirectLabel: scRedirectLabel.trim() || 'Learn More',
orderIndex: scOrderIndex,
isActive: scIsActive,
};
if (editingItem) {
await updateShowcaseContent(editingItem.id, payload);
} else {
await createShowcaseContent(payload);
}
setIsModalOpen(false);
fetchItems();
} catch (err: any) {
setFormError(err.response?.data?.error || 'Failed to save content.');
} finally {
setIsSaving(false);
}
};
const handleDelete = async () => {
if (!deleteItem) return;
try {
await deleteShowcaseContent(deleteItem.id);
setDeleteItem(null);
fetchItems();
} catch (err) {
console.error('Failed to delete:', err);
}
};
// Auto-thumbnail from the current YouTube URL field
const previewVideoId = extractYouTubeVideoId(scYoutubeUrl);
const previewThumbnail = previewVideoId ? `https://img.youtube.com/vi/${previewVideoId}/mqdefault.jpg` : null;
return (
<div className="space-y-4 pt-2 border-t border-ink-200 mt-6">
<div className="flex justify-between items-center pb-2">
<div className="text-sm font-bold text-ink-500 uppercase tracking-widest">
{items.length} Content Showcase Items
</div>
<button
onClick={openAddModal}
className="flex items-center gap-2 px-4 py-2.5 rounded-xl text-xs font-black tracking-wider uppercase bg-ink-900 text-ink-0 hover:bg-ink-800 hover:shadow-lg transition-all cursor-pointer"
>
<Plus className="w-4 h-4" />
<span>Add Video Content</span>
</button>
</div>
{loading ? (
<div className="flex items-center justify-center py-10">
<div className="w-6 h-6 border-3 border-ink-900/30 border-t-ink-900 rounded-full animate-spin"></div>
</div>
) : items.length === 0 ? (
<div className="text-center py-12 bg-ink-0 border border-ink-200 rounded-2xl">
<Video className="w-10 h-10 text-ink-300 mx-auto mb-3" />
<p className="text-xs font-bold text-ink-500">No showcase content yet</p>
<p className="text-[10px] text-ink-400 mt-1">Add YouTube videos to showcase on the Explore More page</p>
</div>
) : (
<div className="overflow-x-auto border border-ink-200 rounded-2xl bg-ink-0 shadow-sm">
<table className="w-full text-sm">
<thead>
<tr className="bg-ink-50 border-b border-ink-200">
<th className="text-left py-3 px-6 text-[10px] font-extrabold text-ink-500 uppercase tracking-widest">Thumbnail</th>
<th className="text-left py-3 px-6 text-[10px] font-extrabold text-ink-500 uppercase tracking-widest">Title</th>
<th className="text-left py-3 px-6 text-[10px] font-extrabold text-ink-500 uppercase tracking-widest">Redirect</th>
<th className="text-left py-3 px-6 text-[10px] font-extrabold text-ink-500 uppercase tracking-widest">Status</th>
<th className="text-right py-3 px-6 text-[10px] font-extrabold text-ink-500 uppercase tracking-widest">Actions</th>
</tr>
</thead>
<tbody>
{items.map(item => {
const videoId = extractYouTubeVideoId(item.youtubeUrl);
const thumb = item.thumbnailUrl || (videoId ? `https://img.youtube.com/vi/${videoId}/mqdefault.jpg` : null);
return (
<tr key={item.id} className="border-b border-ink-100 last:border-0 hover:bg-ink-50/50 transition-colors">
<td className="py-3 px-6">
{thumb ? (
<img src={thumb} alt={item.title} className="w-24 h-14 rounded-lg object-cover border border-ink-200 shadow-sm" />
) : (
<div className="w-24 h-14 rounded-lg bg-ink-100 flex items-center justify-center border border-ink-200">
<Video className="w-5 h-5 text-ink-400" />
</div>
)}
</td>
<td className="py-3 px-6">
<div className="text-xs font-bold text-ink-900">{item.title}</div>
{item.description && <div className="text-[10px] text-ink-500 mt-0.5 truncate max-w-[200px]">{item.description}</div>}
</td>
<td className="py-3 px-6 text-xs font-bold text-ink-500">
{item.redirectUrl ? (
<a href={item.redirectUrl} target="_blank" rel="noreferrer" className="hover:underline flex items-center gap-1 hover:text-ink-900">
{item.redirectLabel || 'Link'}
<ExternalLink className="w-3 h-3" />
</a>
) : (
<span className="text-ink-300 font-normal">None</span>
)}
</td>
<td className="py-3 px-6">
<span className={`inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-[9px] font-black uppercase tracking-wider ${
item.isActive
? 'bg-green-500/10 text-green-600 border border-green-500/20'
: 'bg-ink-150 text-ink-500 border border-ink-300'
}`}>
{item.isActive ? 'Active' : 'Inactive'}
</span>
</td>
<td className="py-3 px-6 text-right">
<div className="flex items-center justify-end gap-2">
<button onClick={() => openEditModal(item)} className="p-1.5 rounded-lg border border-ink-200 text-ink-500 hover:text-ink-900 hover:bg-ink-100 transition-all cursor-pointer" title="Edit">
<Edit2 className="w-3.5 h-3.5" />
</button>
<button onClick={() => setDeleteItem(item)} className="p-1.5 rounded-lg border border-red-200 text-red-500 hover:text-red-700 hover:bg-red-50 transition-all cursor-pointer" title="Delete">
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
{/* ── Add/Edit Content Showcase Modal ── */}
<AnimatePresence>
{isModalOpen && (
<>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={() => setIsModalOpen(false)}
className="fixed inset-0 bg-ink-900/40 backdrop-blur-md z-45"
/>
<motion.div
initial={{ opacity: 0, scale: 0.95, y: 10 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 10 }}
className="fixed inset-y-6 right-6 w-full max-w-lg bg-ink-0 border border-ink-200 rounded-3xl p-6 shadow-2xl z-50 flex flex-col overflow-y-auto"
>
<div className="flex justify-between items-start border-b border-ink-200 pb-4 mb-5">
<div>
<h3 className="text-lg font-black tracking-tight text-ink-900">
{editingItem ? 'Edit Video Content' : 'Add Video Content'}
</h3>
<p className="text-[10px] font-bold text-ink-500 uppercase tracking-widest mt-1">
YouTube Showcase
</p>
</div>
<button onClick={() => setIsModalOpen(false)} className="p-2 rounded-xl bg-ink-100 hover:bg-ink-200 text-ink-700 transition-colors">
<X className="w-4 h-4" />
</button>
</div>
<div className="space-y-4 flex-1">
{/* Media URL */}
<div>
<div className="flex justify-between items-center mb-1.5">
<label className="block text-[10px] font-extrabold text-ink-500 uppercase tracking-widest">Media URL *</label>
</div>
<div className="flex gap-2">
<input
value={scYoutubeUrl}
onChange={e => setScYoutubeUrl(e.target.value)}
placeholder="YouTube watch/shorts/embed URL, Instagram URL, or Twitter/X URL..."
className="flex-1 px-3 py-2.5 border border-ink-200 rounded-xl text-xs font-semibold text-ink-900 bg-ink-50 outline-none focus:border-ink-400 focus:ring-2 ring-ink-900/10 transition-all"
/>
<button
type="button"
onClick={handleScrapeMetadata}
disabled={isFetchingMeta || !scYoutubeUrl.trim()}
className="px-4 py-2 border border-blue-200 hover:border-blue-400 bg-blue-50 hover:bg-blue-100 text-blue-700 disabled:opacity-50 disabled:border-ink-200 disabled:bg-ink-50 disabled:text-ink-400 text-xs font-bold rounded-xl transition-all cursor-pointer flex items-center gap-1.5 shrink-0"
>
{isFetchingMeta ? (
<>
<span className="w-3.5 h-3.5 border-2 border-blue-700 border-t-transparent rounded-full animate-spin inline-block" />
<span>Scraping...</span>
</>
) : (
<>
<RefreshCw className="w-3.5 h-3.5" />
<span>Fetch Meta</span>
</>
)}
</button>
</div>
</div>
{/* Custom Thumbnail */}
<div>
<label className="block text-[10px] font-extrabold text-ink-500 uppercase tracking-widest mb-1.5">Thumbnail (URL or File Upload)</label>
<div className="flex gap-2">
<input
value={scThumbnailUrl}
onChange={e => setScThumbnailUrl(e.target.value)}
placeholder="https://... (or upload an image below)"
className="flex-1 px-3 py-2.5 border border-ink-200 rounded-xl text-xs font-semibold text-ink-900 bg-ink-50 outline-none focus:border-ink-400 focus:ring-2 ring-ink-900/10 transition-all"
/>
<input
type="file"
ref={thumbnailFileRef}
onChange={handleThumbnailUpload}
accept="image/*"
className="hidden"
/>
<button
type="button"
onClick={() => thumbnailFileRef.current?.click()}
disabled={uploadingThumbnail}
className="px-4 py-2 border border-ink-200 hover:border-ink-400 hover:bg-ink-50 text-xs font-bold rounded-xl transition-all cursor-pointer flex items-center gap-1"
>
<Upload className="w-3.5 h-3.5" />
<span>{uploadingThumbnail ? 'Uploading...' : 'Upload'}</span>
</button>
</div>
{/* Thumbnail preview */}
{previewThumbnail && (
<div className="mt-2.5 rounded-xl overflow-hidden border border-ink-200 shadow-sm relative aspect-video bg-ink-100 max-w-[240px]">
<img src={previewThumbnail} alt="Thumbnail Preview" className="w-full h-full object-cover" />
<div className="absolute inset-0 flex items-center justify-center bg-ink-900/10">
<Play className="w-8 h-8 text-white drop-shadow-md" fill="currentColor" />
</div>
</div>
)}
</div>
<div>
<label className="block text-[10px] font-extrabold text-ink-500 uppercase tracking-widest mb-1.5">Title *</label>
<input
value={scTitle}
onChange={e => setScTitle(e.target.value)}
placeholder="e.g., Platform Demo, Case Study, Webinar..."
className="w-full px-3 py-2.5 border border-ink-200 rounded-xl text-xs font-semibold text-ink-900 bg-ink-50 outline-none focus:border-ink-400 focus:ring-2 ring-ink-900/10 transition-all"
/>
</div>
<div>
<label className="block text-[10px] font-extrabold text-ink-500 uppercase tracking-widest mb-1.5">Description</label>
<textarea
value={scDescription}
onChange={e => setScDescription(e.target.value)}
rows={2}
placeholder="Brief description of this content..."
className="w-full px-3 py-2.5 border border-ink-200 rounded-xl text-xs font-semibold text-ink-900 bg-ink-50 outline-none focus:border-ink-400 focus:ring-2 ring-ink-900/10 transition-all resize-none"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-[10px] font-extrabold text-ink-500 uppercase tracking-widest mb-1.5">Redirect URL</label>
<input
value={scRedirectUrl}
onChange={e => setScRedirectUrl(e.target.value)}
placeholder="https://..."
className="w-full px-3 py-2.5 border border-ink-200 rounded-xl text-xs font-semibold text-ink-900 bg-ink-50 outline-none focus:border-ink-400 focus:ring-2 ring-ink-900/10 transition-all"
/>
</div>
<div>
<label className="block text-[10px] font-extrabold text-ink-500 uppercase tracking-widest mb-1.5">Redirect Label</label>
<input
value={scRedirectLabel}
onChange={e => setScRedirectLabel(e.target.value)}
placeholder="Learn More"
className="w-full px-3 py-2.5 border border-ink-200 rounded-xl text-xs font-semibold text-ink-900 bg-ink-50 outline-none focus:border-ink-400 focus:ring-2 ring-ink-900/10 transition-all"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-[10px] font-extrabold text-ink-500 uppercase tracking-widest mb-1.5">Display Order</label>
<input
type="number"
value={scOrderIndex}
onChange={e => setScOrderIndex(parseInt(e.target.value) || 0)}
className="w-full px-3 py-2.5 border border-ink-200 rounded-xl text-xs font-semibold text-ink-900 bg-ink-50 outline-none focus:border-ink-400 focus:ring-2 ring-ink-900/10 transition-all"
/>
</div>
<div>
<label className="block text-[10px] font-extrabold text-ink-500 uppercase tracking-widest mb-1.5">Visibility</label>
<div
onClick={() => setScIsActive(!scIsActive)}
className={`flex items-center gap-2 px-3 py-2.5 border rounded-xl text-xs font-bold cursor-pointer transition-all ${
scIsActive ? 'bg-green-50 border-green-200 text-green-700' : 'bg-ink-50 border-ink-200 text-ink-500'
}`}
>
<div className={`w-3 h-3 rounded-full border-2 ${scIsActive ? 'bg-green-500 border-green-600' : 'bg-ink-300 border-ink-400'}`} />
{scIsActive ? 'Active' : 'Inactive'}
</div>
</div>
</div>
{formError && (
<div className="flex items-center gap-2 p-3 bg-red-50 border border-red-200 rounded-xl text-xs text-red-700 font-bold">
<AlertCircle className="w-4 h-4 shrink-0" />
<span>{formError}</span>
</div>
)}
</div>
<div className="flex gap-3 pt-5 mt-auto border-t border-ink-200">
<button
onClick={() => setIsModalOpen(false)}
className="flex-1 px-4 py-2.5 rounded-xl text-xs font-bold uppercase tracking-wider border border-ink-200 text-ink-700 hover:bg-ink-50 transition-colors cursor-pointer"
>
Cancel
</button>
<button
onClick={handleSave}
disabled={isSaving}
className="flex-1 flex items-center justify-center gap-2 px-4 py-2.5 rounded-xl text-xs font-black uppercase tracking-wider bg-ink-900 text-ink-0 hover:bg-ink-800 disabled:opacity-60 transition-all cursor-pointer"
>
{isSaving ? (
<div className="w-4 h-4 border-2 border-ink-0/30 border-t-ink-0 rounded-full animate-spin" />
) : (
<>
<Check className="w-4 h-4" />
<span>{editingItem ? 'Update' : 'Create'}</span>
</>
)}
</button>
</div>
</motion.div>
</>
)}
</AnimatePresence>
{/* ── Delete Confirmation ── */}
<AnimatePresence>
{deleteItem && (
<>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={() => setDeleteItem(null)}
className="fixed inset-0 bg-ink-900/40 backdrop-blur-md z-45"
/>
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
className="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-full max-w-md bg-ink-0 border border-ink-200 rounded-3xl p-6 shadow-2xl z-50 text-center"
>
<div className="w-12 h-12 rounded-full bg-red-500/10 text-red-500 flex items-center justify-center mx-auto mb-4 border border-red-200">
<Trash2 className="w-6 h-6" />
</div>
<h3 className="text-base font-black text-ink-900 mb-2">Delete Video Content?</h3>
<p className="text-xs font-medium text-ink-600 mb-6">
Are you sure you want to delete <strong className="text-ink-900">{deleteItem.title}</strong>? This action is permanent.
</p>
<div className="flex gap-3 justify-center">
<button onClick={() => setDeleteItem(null)} className="px-4 py-2.5 rounded-xl text-xs font-bold uppercase border border-ink-200 text-ink-700 hover:bg-ink-50 transition-colors cursor-pointer">
Cancel
</button>
<button onClick={handleDelete} className="px-4 py-2.5 rounded-xl text-xs font-black uppercase bg-red-600 text-ink-0 hover:bg-red-700 transition-colors cursor-pointer">
Yes, Delete
</button>
</div>
</motion.div>
</>
)}
</AnimatePresence>
</div>
);
};
export const EcosystemManagerPage: React.FC = () => {
const [offerings, setOfferings] = useState<EcosystemOffering[]>([]);
const [loading, setLoading] = useState(true);
@ -335,7 +872,7 @@ export const EcosystemManagerPage: React.FC = () => {
</div>
<button
onClick={openAddModal}
className="flex items-center gap-2 px-4 py-2.5 rounded-xl text-xs font-black tracking-wider uppercase bg-ink-950 text-ink-0 hover:bg-ink-850 hover:shadow-lg transition-all cursor-pointer"
className="flex items-center gap-2 px-4 py-2.5 rounded-xl text-xs font-black tracking-wider uppercase bg-ink-900 text-ink-0 hover:bg-ink-800 hover:shadow-lg transition-all cursor-pointer"
>
<Plus className="w-4 h-4" />
<span>Add New Offering</span>
@ -385,7 +922,7 @@ export const EcosystemManagerPage: React.FC = () => {
</td>
{/* Name */}
<td className="py-4 px-6 font-extrabold text-ink-955 text-sm">
<td className="py-4 px-6 font-extrabold text-ink-900 text-sm">
{offering.name}
</td>
@ -468,6 +1005,11 @@ export const EcosystemManagerPage: React.FC = () => {
</div>
)}
{/* ══════════════════════════════════════════════════════════════════ */}
{/* ── Content Showcase Manager (YouTube Videos) ── */}
{/* ══════════════════════════════════════════════════════════════════ */}
<ContentShowcaseManager />
{/* ── Add / Edit Modal ── */}
<AnimatePresence>
{isModalOpen && (
@ -477,7 +1019,7 @@ export const EcosystemManagerPage: React.FC = () => {
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={() => setIsModalOpen(false)}
className="fixed inset-0 bg-ink-950/40 backdrop-blur-md z-45"
className="fixed inset-0 bg-ink-900/40 backdrop-blur-md z-45"
/>
<motion.div
initial={{ opacity: 0, scale: 0.95, y: 10 }}
@ -487,7 +1029,7 @@ export const EcosystemManagerPage: React.FC = () => {
>
<div className="flex justify-between items-start border-b border-ink-200 pb-4 mb-6">
<div>
<h3 className="text-lg font-black tracking-tight text-ink-950">
<h3 className="text-lg font-black tracking-tight text-ink-900">
{selectedOffering ? 'Edit Offering' : 'Add New Offering'}
</h3>
<p className="text-xs font-bold text-ink-500 uppercase tracking-widest mt-1">
@ -603,21 +1145,21 @@ export const EcosystemManagerPage: React.FC = () => {
<button
type="button"
onClick={() => setLogoTab('preset')}
className={`px-2 py-1 rounded ${logoTab === 'preset' ? 'bg-ink-0 text-ink-950 shadow-sm' : 'text-ink-500'}`}
className={`px-2 py-1 rounded ${logoTab === 'preset' ? 'bg-ink-0 text-ink-900 shadow-sm' : 'text-ink-500'}`}
>
Preset Brand
</button>
<button
type="button"
onClick={() => setLogoTab('upload')}
className={`px-2 py-1 rounded ${logoTab === 'upload' ? 'bg-ink-0 text-ink-950 shadow-sm' : 'text-ink-500'}`}
className={`px-2 py-1 rounded ${logoTab === 'upload' ? 'bg-ink-0 text-ink-900 shadow-sm' : 'text-ink-500'}`}
>
Upload File
</button>
<button
type="button"
onClick={() => setLogoTab('link')}
className={`px-2 py-1 rounded ${logoTab === 'link' ? 'bg-ink-0 text-ink-950 shadow-sm' : 'text-ink-500'}`}
className={`px-2 py-1 rounded ${logoTab === 'link' ? 'bg-ink-0 text-ink-900 shadow-sm' : 'text-ink-500'}`}
>
Custom Link
</button>
@ -635,12 +1177,12 @@ export const EcosystemManagerPage: React.FC = () => {
onClick={() => setLogoUrl(brand.key)}
className={`flex flex-col items-center justify-center p-3 rounded-xl border transition-all relative min-h-[70px] ${
isSelected
? 'bg-ink-0 border-ink-950 shadow-md ring-2 ring-ink-950/10'
? 'bg-ink-0 border-ink-900 shadow-md ring-2 ring-ink-900/10'
: 'bg-ink-0/60 border-ink-200 hover:border-ink-400 hover:bg-ink-0'
}`}
>
{isSelected && (
<span className="absolute top-1.5 right-1.5 bg-ink-950 text-ink-0 rounded-full p-0.5">
<span className="absolute top-1.5 right-1.5 bg-ink-900 text-ink-0 rounded-full p-0.5">
<Check className="w-2.5 h-2.5" />
</span>
)}
@ -709,14 +1251,14 @@ export const EcosystemManagerPage: React.FC = () => {
<button
type="button"
onClick={() => setMediaTab('link')}
className={`px-2 py-1 rounded ${mediaTab === 'link' ? 'bg-ink-0 text-ink-950 shadow-sm' : 'text-ink-500'}`}
className={`px-2 py-1 rounded ${mediaTab === 'link' ? 'bg-ink-0 text-ink-900 shadow-sm' : 'text-ink-500'}`}
>
Media Link
</button>
<button
type="button"
onClick={() => setMediaTab('upload')}
className={`px-2 py-1 rounded ${mediaTab === 'upload' ? 'bg-ink-0 text-ink-950 shadow-sm' : 'text-ink-500'}`}
className={`px-2 py-1 rounded ${mediaTab === 'upload' ? 'bg-ink-0 text-ink-900 shadow-sm' : 'text-ink-500'}`}
>
Upload File
</button>
@ -825,7 +1367,7 @@ export const EcosystemManagerPage: React.FC = () => {
<button
type="submit"
disabled={isSaving}
className="px-5 py-3 rounded-xl text-xs font-black tracking-wider uppercase bg-ink-950 text-ink-0 hover:bg-ink-850 transition-colors disabled:opacity-50"
className="px-5 py-3 rounded-xl text-xs font-black tracking-wider uppercase bg-ink-900 text-ink-0 hover:bg-ink-800 transition-colors disabled:opacity-50"
>
{isSaving ? 'Saving...' : 'Save Offering'}
</button>
@ -845,7 +1387,7 @@ export const EcosystemManagerPage: React.FC = () => {
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={() => setIsDeleteConfirmOpen(false)}
className="fixed inset-0 bg-ink-950/40 backdrop-blur-md z-45"
className="fixed inset-0 bg-ink-900/40 backdrop-blur-md z-45"
/>
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
@ -856,9 +1398,9 @@ export const EcosystemManagerPage: React.FC = () => {
<div className="w-12 h-12 rounded-full bg-red-500/10 text-red-500 flex items-center justify-center mx-auto mb-4 border border-red-200">
<Trash2 className="w-6 h-6" />
</div>
<h3 className="text-base font-black text-ink-955 mb-2">Delete Ecosystem Offering?</h3>
<h3 className="text-base font-black text-ink-900 mb-2">Delete Ecosystem Offering?</h3>
<p className="text-xs font-medium text-ink-600 mb-6">
Are you sure you want to delete <strong className="text-ink-955">{offeringToDelete?.name}</strong>? This action is permanent and cannot be undone.
Are you sure you want to delete <strong className="text-ink-900">{offeringToDelete?.name}</strong>? This action is permanent and cannot be undone.
</p>
<div className="flex gap-3 justify-center">
<button

View File

@ -134,7 +134,7 @@ export const LegalTemplatesPage: React.FC = () => {
subtitle="Configure active documents required during partner onboarding."
// badge={
// <div className="flex items-center gap-1.5 px-2.5 py-0.5 rounded-full bg-ink-100 border border-ink-200 text-[10px] font-bold text-ink-900 tracking-wider uppercase shrink-0">
// <Shield className="w-3.5 h-3.5 text-ink-950" />
// <Shield className="w-3.5 h-3.5 text-ink-900" />
// <span>Compliance Panel</span>
// </div>
// }

View File

@ -53,3 +53,51 @@ export const uploadEcosystemFile = async (file: File): Promise<{ url: string }>
});
return response.data;
};
// ── Content Showcase (YouTube Videos) ──
export interface ContentShowcase {
id: string;
title: string;
description?: string | null;
youtubeUrl: string;
thumbnailUrl?: string | null;
redirectUrl?: string | null;
redirectLabel?: string | null;
orderIndex: number;
isActive: boolean;
createdAt: string;
updatedAt: string;
}
export const getShowcaseContent = async (): Promise<ContentShowcase[]> => {
const response = await axiosInstance.get<ContentShowcase[]>("/ecosystem/showcase");
return response.data;
};
export const createShowcaseContent = async (
payload: Omit<Partial<ContentShowcase>, 'id' | 'createdAt' | 'updatedAt'>
): Promise<ContentShowcase> => {
const response = await axiosInstance.post<ContentShowcase>("/ecosystem/showcase", payload);
return response.data;
};
export const updateShowcaseContent = async (
id: string,
payload: Partial<ContentShowcase>
): Promise<ContentShowcase> => {
const response = await axiosInstance.put<ContentShowcase>(`/ecosystem/showcase/${id}`, payload);
return response.data;
};
export const deleteShowcaseContent = async (id: string): Promise<void> => {
await axiosInstance.delete(`/ecosystem/showcase/${id}`);
};
export const getYoutubeMeta = async (url: string): Promise<{ title: string; description: string; thumbnailUrl: string }> => {
const response = await axiosInstance.get<{ title: string; description: string; thumbnailUrl: string }>("/ecosystem/video-meta", {
params: { url }
});
return response.data;
};

2116
player_response.json Normal file

File diff suppressed because it is too large Load Diff

83
youtube.html Normal file

File diff suppressed because one or more lines are too long

83
youtube_correct.html Normal file

File diff suppressed because one or more lines are too long

83
youtube_l.html Normal file

File diff suppressed because one or more lines are too long

1132
ytInitialData.json Normal file

File diff suppressed because it is too large Load Diff