Video_assets
This commit is contained in:
parent
9db9e14328
commit
29871cf834
@ -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
|
||||
}
|
||||
|
||||
@ -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(/"/g, '"')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('HTML scrape error:', err);
|
||||
}
|
||||
|
||||
res.status(200).json({
|
||||
title,
|
||||
description,
|
||||
thumbnailUrl
|
||||
});
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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>
|
||||
|
||||
BIN
Channel-Frontend/public/favicon.ico
Normal file
BIN
Channel-Frontend/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
@ -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 && (
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@ -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 */}
|
||||
|
||||
@ -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",
|
||||
};
|
||||
|
||||
@ -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 */}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 && (
|
||||
|
||||
@ -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)}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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" />
|
||||
|
||||
@ -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>
|
||||
// }
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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" />
|
||||
|
||||
439
Channel-Frontend/src/pages/ShowcasePage.tsx
Normal file
439
Channel-Frontend/src/pages/ShowcasePage.tsx
Normal 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;
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
// }
|
||||
|
||||
@ -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
2116
player_response.json
Normal file
File diff suppressed because it is too large
Load Diff
83
youtube.html
Normal file
83
youtube.html
Normal file
File diff suppressed because one or more lines are too long
83
youtube_correct.html
Normal file
83
youtube_correct.html
Normal file
File diff suppressed because one or more lines are too long
83
youtube_l.html
Normal file
83
youtube_l.html
Normal file
File diff suppressed because one or more lines are too long
1132
ytInitialData.json
Normal file
1132
ytInitialData.json
Normal file
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user