Compare commits
No commits in common. "b2853a952beaf478b3d8fb7e7f8e034cf069da43" and "29871cf8346af7333829a1a024de2b7c87648b20" have entirely different histories.
b2853a952b
...
29871cf834
@ -134,20 +134,8 @@ export class EcosystemController {
|
|||||||
try {
|
try {
|
||||||
const data = req.body;
|
const data = req.body;
|
||||||
|
|
||||||
let title = data.title;
|
// Auto-extract YouTube thumbnail if not provided
|
||||||
let description = data.description || null;
|
|
||||||
let thumbnailUrl = data.thumbnailUrl || null;
|
let thumbnailUrl = data.thumbnailUrl || null;
|
||||||
|
|
||||||
if ((!title || !description || !thumbnailUrl) && data.youtubeUrl) {
|
|
||||||
const meta = await this.scrapeMetaInternal(data.youtubeUrl);
|
|
||||||
if (meta) {
|
|
||||||
if (!title) title = meta.title;
|
|
||||||
if (!description) description = meta.description || null;
|
|
||||||
if (!thumbnailUrl) thumbnailUrl = meta.thumbnailUrl || null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Auto-extract YouTube thumbnail if not provided and it's YouTube
|
|
||||||
if (!thumbnailUrl && data.youtubeUrl) {
|
if (!thumbnailUrl && data.youtubeUrl) {
|
||||||
const videoId = this.extractYouTubeVideoId(data.youtubeUrl);
|
const videoId = this.extractYouTubeVideoId(data.youtubeUrl);
|
||||||
if (videoId) {
|
if (videoId) {
|
||||||
@ -157,8 +145,8 @@ export class EcosystemController {
|
|||||||
|
|
||||||
const created = await prisma.contentShowcase.create({
|
const created = await prisma.contentShowcase.create({
|
||||||
data: {
|
data: {
|
||||||
title: title || 'Media Showcase Item',
|
title: data.title,
|
||||||
description,
|
description: data.description || null,
|
||||||
youtubeUrl: data.youtubeUrl,
|
youtubeUrl: data.youtubeUrl,
|
||||||
thumbnailUrl,
|
thumbnailUrl,
|
||||||
redirectUrl: data.redirectUrl || null,
|
redirectUrl: data.redirectUrl || null,
|
||||||
@ -179,20 +167,9 @@ export class EcosystemController {
|
|||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const data = req.body;
|
const data = req.body;
|
||||||
|
|
||||||
let title = data.title;
|
// Auto-extract YouTube thumbnail if URL changed and no explicit thumbnail
|
||||||
let description = data.description;
|
|
||||||
let thumbnailUrl = data.thumbnailUrl;
|
let thumbnailUrl = data.thumbnailUrl;
|
||||||
|
if (thumbnailUrl === undefined && data.youtubeUrl) {
|
||||||
if (data.youtubeUrl) {
|
|
||||||
const meta = await this.scrapeMetaInternal(data.youtubeUrl);
|
|
||||||
if (meta) {
|
|
||||||
if (title === undefined || title === null || title === '') title = meta.title;
|
|
||||||
if (description === undefined) description = meta.description || null;
|
|
||||||
if (thumbnailUrl === undefined) thumbnailUrl = meta.thumbnailUrl || null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!thumbnailUrl && data.youtubeUrl) {
|
|
||||||
const videoId = this.extractYouTubeVideoId(data.youtubeUrl);
|
const videoId = this.extractYouTubeVideoId(data.youtubeUrl);
|
||||||
if (videoId) {
|
if (videoId) {
|
||||||
thumbnailUrl = `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`;
|
thumbnailUrl = `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`;
|
||||||
@ -202,8 +179,8 @@ export class EcosystemController {
|
|||||||
const updated = await prisma.contentShowcase.update({
|
const updated = await prisma.contentShowcase.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: {
|
data: {
|
||||||
title,
|
title: data.title,
|
||||||
description,
|
description: data.description,
|
||||||
youtubeUrl: data.youtubeUrl,
|
youtubeUrl: data.youtubeUrl,
|
||||||
thumbnailUrl,
|
thumbnailUrl,
|
||||||
redirectUrl: data.redirectUrl,
|
redirectUrl: data.redirectUrl,
|
||||||
@ -233,21 +210,6 @@ export class EcosystemController {
|
|||||||
|
|
||||||
// Helper: Extract video ID from various YouTube URL formats
|
// Helper: Extract video ID from various YouTube URL formats
|
||||||
private extractYouTubeVideoId(url: string): string | null {
|
private extractYouTubeVideoId(url: string): string | null {
|
||||||
try {
|
|
||||||
const urlObj = new URL(url);
|
|
||||||
if (urlObj.hostname.includes('youtu.be')) {
|
|
||||||
return urlObj.pathname.slice(1).split(/[?#]/)[0];
|
|
||||||
}
|
|
||||||
if (urlObj.pathname.includes('/shorts/') || urlObj.pathname.includes('/embed/')) {
|
|
||||||
const parts = urlObj.pathname.split('/');
|
|
||||||
return parts.pop()?.split(/[?#]/)[0] || null;
|
|
||||||
}
|
|
||||||
if (urlObj.searchParams.has('v')) {
|
|
||||||
return urlObj.searchParams.get('v');
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
// Fallback to regex
|
|
||||||
}
|
|
||||||
const patterns = [
|
const patterns = [
|
||||||
/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/|youtube\.com\/shorts\/)([a-zA-Z0-9_-]{11})/,
|
/(?: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})/,
|
/(?:youtube-nocookie\.com\/embed\/)([a-zA-Z0-9_-]{11})/,
|
||||||
@ -259,113 +221,6 @@ export class EcosystemController {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async scrapeMetaInternal(url: string): Promise<{ title?: string; description?: string; thumbnailUrl?: string } | null> {
|
|
||||||
try {
|
|
||||||
const normUrl = url.trim();
|
|
||||||
const lowerUrl = normUrl.toLowerCase();
|
|
||||||
|
|
||||||
// Case 1: Twitter / X
|
|
||||||
if (lowerUrl.includes('twitter.com') || lowerUrl.includes('x.com')) {
|
|
||||||
const oembedUrl = `https://publish.twitter.com/oembed?url=${encodeURIComponent(normUrl)}`;
|
|
||||||
const resOembed = await fetch(oembedUrl);
|
|
||||||
if (resOembed.ok) {
|
|
||||||
const data = await resOembed.json() as any;
|
|
||||||
let description = '';
|
|
||||||
if (data.html) {
|
|
||||||
const pMatch = data.html.match(/<p[^>]*>([\s\S]*?)<\/p>/i);
|
|
||||||
if (pMatch) {
|
|
||||||
description = pMatch[1].replace(/<[^>]*>/g, '').trim();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
title: data.author_name ? `Tweet by ${data.author_name}` : 'Tweet Content',
|
|
||||||
description,
|
|
||||||
thumbnailUrl: undefined
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Case 2: Instagram
|
|
||||||
if (lowerUrl.includes('instagram.com')) {
|
|
||||||
const oembedUrl = `https://graph.facebook.com/v25.0/instagram_oembed?url=${encodeURIComponent(normUrl)}`;
|
|
||||||
const resOembed = await fetch(oembedUrl);
|
|
||||||
if (resOembed.ok) {
|
|
||||||
const data = await resOembed.json() as any;
|
|
||||||
return {
|
|
||||||
title: data.author_name ? `Instagram post by ${data.author_name}` : 'Instagram Post/Reel',
|
|
||||||
description: data.title || 'Instagram Content',
|
|
||||||
thumbnailUrl: data.thumbnail_url || undefined
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Case 3: YouTube
|
|
||||||
const videoId = this.extractYouTubeVideoId(normUrl);
|
|
||||||
if (videoId) {
|
|
||||||
let title = '';
|
|
||||||
let thumbnailUrl = `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const oembedUrl = `https://www.youtube.com/oembed?url=https://www.youtube.com/watch?v=${videoId}&format=json`;
|
|
||||||
const oembedRes = await fetch(oembedUrl);
|
|
||||||
if (oembedRes.ok) {
|
|
||||||
const data = await oembedRes.json() as any;
|
|
||||||
title = data.title || '';
|
|
||||||
thumbnailUrl = data.thumbnail_url || thumbnailUrl;
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('oEmbed fetch error:', err);
|
|
||||||
}
|
|
||||||
|
|
||||||
let description = '';
|
|
||||||
try {
|
|
||||||
const watchUrl = `https://www.youtube.com/watch?v=${videoId}`;
|
|
||||||
const pageRes = await fetch(watchUrl, {
|
|
||||||
headers: {
|
|
||||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/115.0.0.0 Safari/537.36',
|
|
||||||
'Accept-Language': 'en-US,en;q=0.9'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
if (pageRes.ok) {
|
|
||||||
const html = await pageRes.text();
|
|
||||||
const playerResponse = this.extractJsonFromHtml(html, 'ytInitialPlayerResponse = ');
|
|
||||||
if (playerResponse && playerResponse.videoDetails) {
|
|
||||||
description = playerResponse.videoDetails.shortDescription || '';
|
|
||||||
if (playerResponse.videoDetails.title && !title) {
|
|
||||||
title = playerResponse.videoDetails.title;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!description) {
|
|
||||||
const descMatch = html.match(/<meta\s+property="og:description"\s+content="([^"]*)"/i) ||
|
|
||||||
html.match(/<meta\s+name="description"\s+content="([^"]*)"/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);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
title: title || undefined,
|
|
||||||
description: description || undefined,
|
|
||||||
thumbnailUrl
|
|
||||||
};
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error('scrapeMetaInternal error:', e);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private extractJsonFromHtml(html: string, targetStr: string): any {
|
private extractJsonFromHtml(html: string, targetStr: string): any {
|
||||||
const idx = html.indexOf(targetStr);
|
const idx = html.indexOf(targetStr);
|
||||||
if (idx === -1) return null;
|
if (idx === -1) return null;
|
||||||
|
|||||||
@ -153,7 +153,7 @@ export const AssetCard: React.FC<AssetCardProps> = ({
|
|||||||
}
|
}
|
||||||
onViewDetails(asset);
|
onViewDetails(asset);
|
||||||
}}
|
}}
|
||||||
className={`group bg-ink-0 border rounded-xl p-4 transition-[border-color,box-shadow,background-color] duration-300 flex flex-col justify-between cursor-pointer min-h-[410px] ${
|
className={`group bg-ink-0 border rounded-xl p-4 transition-all duration-300 flex flex-col justify-between cursor-pointer min-h-[410px] ${
|
||||||
isExpanded
|
isExpanded
|
||||||
? 'absolute z-20 top-0 left-0 right-0 h-auto shadow-2xl border-ink-300 bg-ink-0'
|
? 'absolute z-20 top-0 left-0 right-0 h-auto shadow-2xl border-ink-300 bg-ink-0'
|
||||||
: isRecommended
|
: isRecommended
|
||||||
|
|||||||
@ -16,21 +16,6 @@ import type { ContentShowcase } from '../services/ecosystem-api';
|
|||||||
|
|
||||||
// ── Helper: Extract YouTube video ID ──
|
// ── Helper: Extract YouTube video ID ──
|
||||||
const extractYouTubeVideoId = (url: string): string | null => {
|
const extractYouTubeVideoId = (url: string): string | null => {
|
||||||
try {
|
|
||||||
const urlObj = new URL(url);
|
|
||||||
if (urlObj.hostname.includes('youtu.be')) {
|
|
||||||
return urlObj.pathname.slice(1).split(/[?#]/)[0];
|
|
||||||
}
|
|
||||||
if (urlObj.pathname.includes('/shorts/') || urlObj.pathname.includes('/embed/')) {
|
|
||||||
const parts = urlObj.pathname.split('/');
|
|
||||||
return parts.pop()?.split(/[?#]/)[0] || null;
|
|
||||||
}
|
|
||||||
if (urlObj.searchParams.has('v')) {
|
|
||||||
return urlObj.searchParams.get('v');
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
// Fallback to regex
|
|
||||||
}
|
|
||||||
const patterns = [
|
const patterns = [
|
||||||
/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/|youtube\.com\/shorts\/)([a-zA-Z0-9_-]{11})/,
|
/(?: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})/,
|
/(?:youtube-nocookie\.com\/embed\/)([a-zA-Z0-9_-]{11})/,
|
||||||
@ -74,7 +59,7 @@ const YouTubeEmbed: React.FC<{ videoId: string }> = ({ videoId }) => {
|
|||||||
const InstagramEmbed: React.FC<{ postId: string; size: 'compact' | 'theater' | 'cinema' }> = ({ postId, size }) => {
|
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]';
|
const maxWidthClass = size === 'compact' ? 'max-w-[360px]' : size === 'theater' ? 'max-w-[420px]' : 'max-w-[480px]';
|
||||||
return (
|
return (
|
||||||
<div className={`w-full ${maxWidthClass} aspect-[9/16] max-h-full mx-auto bg-ink-950 rounded-2xl overflow-hidden shadow-2xl flex justify-center items-center`}>
|
<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
|
<iframe
|
||||||
src={`https://www.instagram.com/p/${postId}/embed`}
|
src={`https://www.instagram.com/p/${postId}/embed`}
|
||||||
className="w-full h-full border-0"
|
className="w-full h-full border-0"
|
||||||
@ -106,7 +91,7 @@ const TwitterEmbed: React.FC<{ url: string }> = ({ url }) => {
|
|||||||
}, [url]);
|
}, [url]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full max-h-full overflow-y-auto flex justify-center bg-white p-4 sm:p-6 rounded-2xl">
|
<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">
|
<blockquote className="twitter-tweet" data-align="center">
|
||||||
<a href={url}>Loading Tweet...</a>
|
<a href={url}>Loading Tweet...</a>
|
||||||
</blockquote>
|
</blockquote>
|
||||||
@ -115,20 +100,15 @@ const TwitterEmbed: React.FC<{ url: string }> = ({ url }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// ── Collapsible Video Description Component ──
|
// ── Collapsible Video Description Component ──
|
||||||
interface VideoDescriptionProps {
|
const VideoDescription: React.FC<{ text: string }> = ({ text }) => {
|
||||||
text: string;
|
const [isExpanded, setIsExpanded] = useState(false);
|
||||||
isExpanded: boolean;
|
const shouldCollapse = text.length > 180 || text.includes('\n');
|
||||||
onToggleExpand: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const VideoDescription: React.FC<VideoDescriptionProps> = ({ text, isExpanded, onToggleExpand }) => {
|
|
||||||
const shouldCollapse = text.length > 120 || text.includes('\n');
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-1">
|
<div className="space-y-2">
|
||||||
<p
|
<p
|
||||||
className={`text-[11px] font-medium text-ink-500 leading-relaxed overflow-hidden ${
|
className={`text-[11px] font-medium text-ink-500 leading-relaxed transition-all duration-300 ${
|
||||||
isExpanded ? '' : 'line-clamp-2'
|
isExpanded ? '' : 'line-clamp-3'
|
||||||
}`}
|
}`}
|
||||||
style={{ whiteSpace: isExpanded ? 'pre-wrap' : 'normal' }}
|
style={{ whiteSpace: isExpanded ? 'pre-wrap' : 'normal' }}
|
||||||
>
|
>
|
||||||
@ -138,7 +118,7 @@ const VideoDescription: React.FC<VideoDescriptionProps> = ({ text, isExpanded, o
|
|||||||
<button
|
<button
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onToggleExpand();
|
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"
|
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"
|
||||||
>
|
>
|
||||||
@ -153,7 +133,6 @@ export const ShowcasePage: React.FC = () => {
|
|||||||
const [items, setItems] = useState<ContentShowcase[]>([]);
|
const [items, setItems] = useState<ContentShowcase[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [playingVideoId, setPlayingVideoId] = useState<string | null>(null);
|
const [playingVideoId, setPlayingVideoId] = useState<string | null>(null);
|
||||||
const [expandedItemId, setExpandedItemId] = useState<string | null>(null);
|
|
||||||
|
|
||||||
// Resizable Lightbox state: compact | theater | cinema
|
// Resizable Lightbox state: compact | theater | cinema
|
||||||
const [lightboxSize, setLightboxSize] = useState<'compact' | 'theater' | 'cinema'>('compact');
|
const [lightboxSize, setLightboxSize] = useState<'compact' | 'theater' | 'cinema'>('compact');
|
||||||
@ -216,104 +195,92 @@ export const ShowcasePage: React.FC = () => {
|
|||||||
const isIg = item.youtubeUrl.includes('instagram.com');
|
const isIg = item.youtubeUrl.includes('instagram.com');
|
||||||
const isTw = item.youtubeUrl.includes('twitter.com') || item.youtubeUrl.includes('x.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);
|
const thumbnail = item.thumbnailUrl || (ytId ? `https://img.youtube.com/vi/${ytId}/hqdefault.jpg` : null);
|
||||||
const isExpanded = expandedItemId === item.id;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={item.id} className="relative h-[410px] w-full flex flex-col">
|
<div
|
||||||
<motion.div
|
key={item.id}
|
||||||
layout
|
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"
|
||||||
transition={{ type: "spring", stiffness: 320, damping: 28 }}
|
>
|
||||||
className={`group flex flex-col bg-ink-0 border rounded-2xl overflow-hidden transition-[border-color,box-shadow,background-color] duration-300 ${
|
{/* Video Player / Thumbnail */}
|
||||||
isExpanded
|
<div className="relative aspect-video bg-ink-900 overflow-hidden">
|
||||||
? 'absolute z-20 top-0 left-0 right-0 h-auto shadow-2xl border-ink-350 bg-ink-0'
|
{thumbnail ? (
|
||||||
: 'relative w-full h-full border-ink-200 hover:border-ink-350 hover:shadow-xl'
|
<img
|
||||||
}`}
|
src={thumbnail}
|
||||||
>
|
alt={item.title}
|
||||||
{/* Video Player / Thumbnail */}
|
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-700"
|
||||||
<div className="relative aspect-video bg-ink-900 overflow-hidden shrink-0">
|
/>
|
||||||
{thumbnail ? (
|
) : (
|
||||||
<img
|
/* Platform Fallback Gradients */
|
||||||
src={thumbnail}
|
<div className={`w-full h-full flex items-center justify-center ${
|
||||||
alt={item.title}
|
isIg
|
||||||
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-700"
|
? 'bg-gradient-to-tr from-yellow-500 via-pink-500 to-purple-600'
|
||||||
/>
|
: isTw
|
||||||
) : (
|
? 'bg-ink-950'
|
||||||
/* Platform Fallback Gradients */
|
: 'bg-ink-100'
|
||||||
<div className={`w-full h-full flex items-center justify-center ${
|
}`}>
|
||||||
isIg
|
{isIg && (
|
||||||
? 'bg-gradient-to-tr from-yellow-500 via-pink-500 to-purple-600'
|
<svg className="w-12 h-12 text-white/90 animate-pulse" fill="none" stroke="currentColor" strokeWidth="1.5" viewBox="0 0 24 24">
|
||||||
: isTw
|
<rect x="2" y="2" width="20" height="20" rx="5" ry="5" />
|
||||||
? 'bg-ink-950'
|
<path d="M16 11.37A4 4 0 1112.63 8 4 4 0 0116 11.37z" />
|
||||||
: 'bg-ink-100'
|
<line x1="17.5" y1="6.5" x2="17.51" y2="6.5" />
|
||||||
}`}>
|
</svg>
|
||||||
{isIg && (
|
)}
|
||||||
<svg className="w-12 h-12 text-white/90 animate-pulse" fill="none" stroke="currentColor" strokeWidth="1.5" viewBox="0 0 24 24">
|
{isTw && (
|
||||||
<rect x="2" y="2" width="20" height="20" rx="5" ry="5" />
|
<svg className="w-12 h-12 text-white/90 animate-pulse" fill="currentColor" viewBox="0 0 24 24">
|
||||||
<path d="M16 11.37A4 4 0 1112.63 8 4 4 0 0116 11.37z" />
|
<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" />
|
||||||
<line x1="17.5" y1="6.5" x2="17.51" y2="6.5" />
|
</svg>
|
||||||
</svg>
|
)}
|
||||||
)}
|
{!isIg && !isTw && (
|
||||||
{isTw && (
|
<Play className="w-12 h-12 text-ink-400" />
|
||||||
<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-grow 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 ${isExpanded ? '' : 'line-clamp-2'}`} title={item.title}>
|
|
||||||
{item.title}
|
|
||||||
</h3>
|
|
||||||
{item.description && (
|
|
||||||
<VideoDescription
|
|
||||||
text={item.description}
|
|
||||||
isExpanded={isExpanded}
|
|
||||||
onToggleExpand={() => setExpandedItemId(isExpanded ? null : item.id)}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Redirect CTA */}
|
{/* Dark gradient overlay */}
|
||||||
{item.redirectUrl && (
|
<div className="absolute inset-0 bg-gradient-to-t from-ink-900/60 via-transparent to-transparent" />
|
||||||
<div className="pt-2">
|
|
||||||
<a
|
{/* Play button overlay */}
|
||||||
href={item.redirectUrl}
|
<button
|
||||||
target="_blank"
|
onClick={() => setPlayingVideoId(item.id)}
|
||||||
rel="noopener noreferrer"
|
className="absolute inset-0 flex items-center justify-center cursor-pointer group/play"
|
||||||
className="inline-flex items-center gap-1.5 text-[10px] font-black uppercase tracking-widest text-ink-700 hover:text-ink-900 transition-colors"
|
>
|
||||||
>
|
<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">
|
||||||
<span>{item.redirectLabel || 'Learn More'}</span>
|
<Play className="w-6 h-6 text-ink-900 group-hover/play:text-ink-0 ml-0.5 transition-colors" fill="currentColor" />
|
||||||
<ExternalLink className="w-3 h-3" />
|
</div>
|
||||||
</a>
|
</button>
|
||||||
</div>
|
|
||||||
|
{/* 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>
|
</div>
|
||||||
</motion.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>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@ -336,17 +303,17 @@ export const ShowcasePage: React.FC = () => {
|
|||||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||||
exit={{ opacity: 0, scale: 0.9, y: 20 }}
|
exit={{ opacity: 0, scale: 0.9, y: 20 }}
|
||||||
transition={{ type: 'spring', damping: 25, stiffness: 250 }}
|
transition={{ type: 'spring', damping: 25, stiffness: 250 }}
|
||||||
className={`relative bg-ink-900 border border-ink-800 rounded-3xl overflow-hidden shadow-2xl flex flex-col transition-all duration-300 ${
|
className={`relative bg-ink-900 border border-ink-800 rounded-3xl overflow-hidden shadow-2xl flex transition-all duration-300 ${
|
||||||
lightboxSize === 'cinema'
|
lightboxSize === 'cinema'
|
||||||
? 'w-[95vw] max-w-7xl md:flex-col h-[85vh] md:h-[90vh]'
|
? 'w-[95vw] max-w-7xl flex-col h-[90vh]'
|
||||||
: lightboxSize === 'theater'
|
: lightboxSize === 'theater'
|
||||||
? 'w-[95vw] md:w-[85vw] max-w-6xl md:flex-row h-[85vh] md:max-h-[85vh]'
|
? 'w-[85vw] max-w-6xl flex-row max-h-[85vh]'
|
||||||
: 'w-[95vw] md:w-full max-w-4xl md:flex-row h-[85vh] md:max-h-[80vh]'
|
: 'w-full max-w-4xl flex-row max-h-[80vh]'
|
||||||
}`}
|
}`}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
{/* Media Container */}
|
{/* Media Container */}
|
||||||
<div className="flex-1 bg-black flex items-center justify-center p-3 sm:p-4 overflow-hidden relative min-h-[180px] sm:min-h-[240px] md:min-h-[300px]">
|
<div className="flex-1 bg-black flex items-center justify-center p-4 overflow-hidden relative min-h-[300px]">
|
||||||
{(() => {
|
{(() => {
|
||||||
const ytId = extractYouTubeVideoId(activeItem.youtubeUrl);
|
const ytId = extractYouTubeVideoId(activeItem.youtubeUrl);
|
||||||
if (ytId) {
|
if (ytId) {
|
||||||
@ -368,15 +335,15 @@ export const ShowcasePage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Info Container */}
|
{/* Info Container */}
|
||||||
<div className={`p-5 sm:p-6 flex flex-col justify-between border-ink-800 bg-ink-950 overflow-y-auto ${
|
<div className={`p-6 flex flex-col justify-between border-ink-800 bg-ink-950 overflow-y-auto ${
|
||||||
lightboxSize === 'cinema'
|
lightboxSize === 'cinema'
|
||||||
? 'w-full border-t h-[40%] md:h-[30%] shrink-0'
|
? 'w-full border-t h-[30%] shrink-0'
|
||||||
: 'w-full md:w-80 border-t md:border-t-0 md:border-l h-[45%] md:h-auto shrink-0'
|
: 'w-full md:w-80 border-t md:border-t-0 md:border-l shrink-0'
|
||||||
}`}>
|
}`}>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* Toolbar with Resizer Preset Buttons */}
|
{/* Toolbar with Resizer Preset Buttons */}
|
||||||
<div className="flex justify-between items-center pb-2 border-b border-ink-800/60">
|
<div className="flex justify-between items-center pb-2 border-b border-ink-800/60">
|
||||||
<div className="hidden md:flex bg-ink-900 p-0.5 rounded-lg border border-ink-800 gap-0.5">
|
<div className="flex bg-ink-900 p-0.5 rounded-lg border border-ink-800 gap-0.5">
|
||||||
<button
|
<button
|
||||||
onClick={() => setLightboxSize('compact')}
|
onClick={() => setLightboxSize('compact')}
|
||||||
className={`p-1.5 rounded text-[9px] font-black uppercase tracking-wider flex items-center gap-1 transition-all cursor-pointer ${
|
className={`p-1.5 rounded text-[9px] font-black uppercase tracking-wider flex items-center gap-1 transition-all cursor-pointer ${
|
||||||
@ -416,7 +383,7 @@ export const ShowcasePage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => setPlayingVideoId(null)}
|
onClick={() => setPlayingVideoId(null)}
|
||||||
className="text-ink-400 hover:text-ink-0 transition-colors p-1.5 rounded-lg hover:bg-ink-850 cursor-pointer ml-auto md:ml-0"
|
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" />
|
<X className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
@ -438,7 +405,7 @@ export const ShowcasePage: React.FC = () => {
|
|||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
{activeItem.description && (
|
{activeItem.description && (
|
||||||
<div className="max-h-60 md:max-h-none overflow-y-auto pr-1">
|
<div className="max-h-60 overflow-y-auto pr-1">
|
||||||
<p className="text-xs font-medium text-ink-400 leading-relaxed whitespace-pre-wrap">
|
<p className="text-xs font-medium text-ink-400 leading-relaxed whitespace-pre-wrap">
|
||||||
{activeItem.description}
|
{activeItem.description}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@ -30,7 +30,6 @@ import {
|
|||||||
getYoutubeMeta,
|
getYoutubeMeta,
|
||||||
} from '../../services/ecosystem-api';
|
} from '../../services/ecosystem-api';
|
||||||
import type { EcosystemOffering, ContentShowcase } from '../../services/ecosystem-api';
|
import type { EcosystemOffering, ContentShowcase } from '../../services/ecosystem-api';
|
||||||
import { useToast } from '../../hooks/use-toast';
|
|
||||||
|
|
||||||
// Custom Brand Logo renderer with remote URL preset load + high-fidelity SVG fallback
|
// 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" }) => {
|
export const BrandLogo: React.FC<{ name: string; className?: string }> = ({ name, className = "h-7" }) => {
|
||||||
@ -127,7 +126,6 @@ const extractYouTubeVideoId = (url: string): string | null => {
|
|||||||
|
|
||||||
// ── Content Showcase Manager Component ──
|
// ── Content Showcase Manager Component ──
|
||||||
const ContentShowcaseManager: React.FC = () => {
|
const ContentShowcaseManager: React.FC = () => {
|
||||||
const { success, error } = useToast();
|
|
||||||
const [items, setItems] = useState<ContentShowcase[]>([]);
|
const [items, setItems] = useState<ContentShowcase[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
@ -156,10 +154,7 @@ const ContentShowcaseManager: React.FC = () => {
|
|||||||
// Action to fetch metadata explicitly
|
// Action to fetch metadata explicitly
|
||||||
const handleScrapeMetadata = async () => {
|
const handleScrapeMetadata = async () => {
|
||||||
const url = scYoutubeUrl.trim();
|
const url = scYoutubeUrl.trim();
|
||||||
if (!url) {
|
if (!url) return;
|
||||||
error('URL Required', 'Please enter a media URL first.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const lower = url.toLowerCase();
|
const lower = url.toLowerCase();
|
||||||
const isYt = lower.includes('youtube.com') || lower.includes('youtu.be') || lower.includes('youtube-nocookie.com');
|
const isYt = lower.includes('youtube.com') || lower.includes('youtu.be') || lower.includes('youtube-nocookie.com');
|
||||||
@ -167,7 +162,7 @@ const ContentShowcaseManager: React.FC = () => {
|
|||||||
const isTw = lower.includes('twitter.com') || lower.includes('x.com');
|
const isTw = lower.includes('twitter.com') || lower.includes('x.com');
|
||||||
|
|
||||||
if (!isYt && !isIg && !isTw) {
|
if (!isYt && !isIg && !isTw) {
|
||||||
error('Invalid URL', 'Please enter a valid YouTube, Instagram, or Twitter/X URL.');
|
setFormError('Please enter a valid YouTube, Instagram, or Twitter/X URL.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -180,10 +175,8 @@ const ContentShowcaseManager: React.FC = () => {
|
|||||||
if (meta.thumbnailUrl) {
|
if (meta.thumbnailUrl) {
|
||||||
setScThumbnailUrl(meta.thumbnailUrl);
|
setScThumbnailUrl(meta.thumbnailUrl);
|
||||||
}
|
}
|
||||||
success('Metadata Fetched', 'Media title, description, and thumbnail populated successfully.');
|
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('Failed to fetch video meta:', err);
|
console.error('Failed to fetch video meta:', err);
|
||||||
error('Fetch Failed', 'Failed to retrieve metadata. You can input fields manually.');
|
|
||||||
setFormError('Failed to fetch video metadata. You can enter the title and description manually.');
|
setFormError('Failed to fetch video metadata. You can enter the title and description manually.');
|
||||||
} finally {
|
} finally {
|
||||||
setIsFetchingMeta(false);
|
setIsFetchingMeta(false);
|
||||||
@ -213,9 +206,7 @@ const ContentShowcaseManager: React.FC = () => {
|
|||||||
try {
|
try {
|
||||||
const res = await uploadEcosystemFile(file);
|
const res = await uploadEcosystemFile(file);
|
||||||
setScThumbnailUrl(res.url);
|
setScThumbnailUrl(res.url);
|
||||||
success('Upload Success', 'Thumbnail image uploaded successfully.');
|
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
error('Upload Failed', 'Failed to upload thumbnail image.');
|
|
||||||
setFormError('Failed to upload thumbnail image.');
|
setFormError('Failed to upload thumbnail image.');
|
||||||
} finally {
|
} finally {
|
||||||
setUploadingThumbnail(false);
|
setUploadingThumbnail(false);
|
||||||
@ -285,15 +276,12 @@ const ContentShowcaseManager: React.FC = () => {
|
|||||||
|
|
||||||
if (editingItem) {
|
if (editingItem) {
|
||||||
await updateShowcaseContent(editingItem.id, payload);
|
await updateShowcaseContent(editingItem.id, payload);
|
||||||
success('Showcase Updated', 'The showcase item has been updated successfully.');
|
|
||||||
} else {
|
} else {
|
||||||
await createShowcaseContent(payload);
|
await createShowcaseContent(payload);
|
||||||
success('Showcase Created', 'A new showcase item has been created successfully.');
|
|
||||||
}
|
}
|
||||||
setIsModalOpen(false);
|
setIsModalOpen(false);
|
||||||
fetchItems();
|
fetchItems();
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
error('Save Failed', err.response?.data?.error || 'Failed to save content.');
|
|
||||||
setFormError(err.response?.data?.error || 'Failed to save content.');
|
setFormError(err.response?.data?.error || 'Failed to save content.');
|
||||||
} finally {
|
} finally {
|
||||||
setIsSaving(false);
|
setIsSaving(false);
|
||||||
@ -306,16 +294,14 @@ const ContentShowcaseManager: React.FC = () => {
|
|||||||
await deleteShowcaseContent(deleteItem.id);
|
await deleteShowcaseContent(deleteItem.id);
|
||||||
setDeleteItem(null);
|
setDeleteItem(null);
|
||||||
fetchItems();
|
fetchItems();
|
||||||
success('Showcase Deleted', 'The showcase item was deleted successfully.');
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to delete:', err);
|
console.error('Failed to delete:', err);
|
||||||
error('Delete Failed', 'Failed to delete the showcase item.');
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Auto-thumbnail from the current YouTube URL field
|
// Auto-thumbnail from the current YouTube URL field
|
||||||
const previewVideoId = extractYouTubeVideoId(scYoutubeUrl);
|
const previewVideoId = extractYouTubeVideoId(scYoutubeUrl);
|
||||||
const previewThumbnail = scThumbnailUrl || (previewVideoId ? `https://img.youtube.com/vi/${previewVideoId}/mqdefault.jpg` : null);
|
const previewThumbnail = previewVideoId ? `https://img.youtube.com/vi/${previewVideoId}/mqdefault.jpg` : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4 pt-2 border-t border-ink-200 mt-6">
|
<div className="space-y-4 pt-2 border-t border-ink-200 mt-6">
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user