feat: redesign NewsTicker, remove redundant dashboard search, and integrate Live TV module

This commit is contained in:
Carlos (Almeira) 2026-04-22 15:21:51 -05:00
parent 41735c5280
commit 32e5450ab1
5 changed files with 143 additions and 63 deletions

View File

@ -0,0 +1,45 @@
import { RadioTower, ExternalLink } from 'lucide-react';
export const metadata = {
title: 'Gestor StreamingTV | Almeira CMS',
};
export default function StreamingAdminPage() {
return (
<div className="space-y-6">
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
<div>
<h1 className="text-3xl font-black font-outfit uppercase tracking-tighter text-white flex items-center gap-3">
<div className="p-2 bg-gradient-to-br from-cyan-500 to-blue-600 rounded-xl shadow-lg">
<RadioTower size={24} className="text-white" />
</div>
Gestor StreamingTV
</h1>
<p className="text-slate-400 mt-1 max-w-2xl">
Panel administrativo integrado del servidor Datarhei Restreamer. Desde aquí puedes controlar la captura, el estado y las transmisiones de tu canal de TV.
</p>
</div>
<a
href="https://stream.almeira.com.co/ui/"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 px-4 py-2 bg-slate-800 hover:bg-slate-700 text-cyan-400 rounded-lg text-sm font-bold uppercase tracking-wider transition-colors border border-slate-700 hover:border-cyan-500/50"
>
<ExternalLink size={16} />
Abrir en Pestaña Nueva
</a>
</div>
<div className="w-full bg-[#1e2023] rounded-2xl overflow-hidden shadow-[0_0_40px_rgba(0,0,0,0.5)] border border-slate-800 relative z-10" style={{ height: 'calc(100vh - 200px)', minHeight: '600px' }}>
<div className="absolute inset-x-0 top-0 h-10 bg-gradient-to-b from-black/50 to-transparent pointer-events-none z-20"></div>
<iframe
src="https://stream.almeira.com.co/ui/"
className="w-full h-full border-none"
title="Datarhei Restreamer Panel"
allow="camera; microphone; fullscreen; display-capture"
></iframe>
</div>
</div>
);
}

View File

@ -0,0 +1,39 @@
'use client';
import { useEffect, useState } from 'react';
export default function LiveClock() {
const [time, setTime] = useState<Date | null>(null);
useEffect(() => {
setTime(new Date());
const timer = setInterval(() => setTime(new Date()), 1000);
return () => clearInterval(timer);
}, []);
if (!time) return null;
const dateStr = time.toLocaleDateString('es-CO', {
weekday: 'long',
day: 'numeric',
month: 'long',
year: 'numeric'
});
const timeStr = time.toLocaleTimeString('es-CO', {
hour: 'numeric',
minute: '2-digit',
second: '2-digit'
});
return (
<div className="flex items-center gap-3 h-full">
<span className="text-slate-300 text-[11px] hidden lg:block font-medium lowercase first-letter:uppercase">
{dateStr}
</span>
<span className="bg-slate-100 text-slate-900 px-2 py-1 rounded text-[11px] font-bold whitespace-nowrap shadow-[inset_0_1px_3px_rgba(0,0,0,0.1)]">
{timeStr}
</span>
</div>
);
}

View File

@ -4,6 +4,7 @@ import { Query } from 'node-appwrite';
import Link from 'next/link';
import { MessageCircle } from 'lucide-react';
import { Settings } from '@/types/settings';
import LiveClock from './LiveClock';
export default async function NewsTicker() {
let tickerItems: any[] = [];
@ -110,43 +111,58 @@ export default async function NewsTicker() {
if (tickerItems.length === 0) return null;
return (
<div className="w-full bg-cyan-950/80 backdrop-blur-md border-y border-cyan-800/50 overflow-hidden relative flex items-center h-12 z-40">
<div className="absolute left-0 z-20 bg-gradient-to-r from-cyan-950 to-transparent w-20 h-full"></div>
<div className="absolute right-0 z-20 bg-gradient-to-l from-cyan-950 to-transparent w-20 h-full"></div>
<div className="flex animate-marquee whitespace-nowrap items-center group-hover:[animation-play-state:paused]">
{/* Doble render para bucle infinito visual */}
{[...tickerItems, ...tickerItems].map((item, idx) => {
const isWA = item.type === 'whatsapp';
const Content = (
<div className="flex items-center gap-4 mx-8 group cursor-pointer border-r border-cyan-800/30 pr-8 py-1">
{isWA ? (
<div className="w-9 h-9 rounded-full bg-green-500/20 flex items-center justify-center border border-green-500/30">
<MessageCircle size={14} className="text-green-400 fill-current" />
</div>
) : (
item.imagenUrl && (
// eslint-disable-next-line @next/next/no-img-element
<img src={item.imagenUrl} alt={item.titulo} className="w-12 h-7 aspect-video object-cover rounded shadow-[0_0_10px_rgba(6,182,212,0.3)] group-hover:scale-110 transition-all duration-500 border border-white/10" />
)
)}
<span className={`text-[11px] font-black uppercase tracking-widest flex items-center gap-2 transition-colors duration-300 ${
isWA ? 'text-green-400 group-hover:text-white' : 'text-slate-200 group-hover:text-cyan-400'
}`}>
{isWA && <span className="text-[9px] font-bold px-1.5 py-0.5 bg-green-500/20 rounded border border-green-500/30 text-green-400">COMUNIDAD</span>}
{item.titulo.length > 80 ? item.titulo.substring(0, 80) + '...' : item.titulo}
</span>
</div>
);
if (item.slug) {
return <Link key={idx} href={`/${item.slug}`} className="flex items-center">{Content}</Link>;
}
return <div key={idx} className="flex items-center">{Content}</div>;
})}
<div className="w-full bg-white dark:bg-slate-900 border-y border-slate-200 dark:border-white/10 h-[44px] flex overflow-hidden z-40 font-sans shadow-sm">
{/* Left Block */}
<div className="z-30 h-full flex items-center bg-cyan-600 text-white px-4 md:px-5 shrink-0 uppercase tracking-widest text-[11px] md:text-[13px] font-black truncate relative">
Últimas Noticias
</div>
{/* Middle Marquee */}
<div className="flex-1 overflow-hidden relative flex items-center h-full bg-white dark:bg-slate-900 z-10">
<div className="absolute left-0 z-20 bg-gradient-to-r from-white dark:from-slate-900 to-transparent w-6 h-full pointer-events-none"></div>
<div className="absolute right-0 z-20 bg-gradient-to-l from-white dark:from-slate-900 to-transparent w-6 h-full pointer-events-none"></div>
<div className="flex animate-marquee whitespace-nowrap items-center hover:[animation-play-state:paused] h-full">
{/* Doble render para bucle infinito visual */}
{[...tickerItems, ...tickerItems].map((item, idx) => {
const isWA = item.type === 'whatsapp';
const Content = (
<div className="flex items-center gap-3 mx-4 md:mx-6 group cursor-pointer border-r border-slate-200 dark:border-slate-800 pr-4 md:pr-6 h-full transition-all duration-300">
{isWA ? (
<div className="w-[42px] h-[28px] rounded bg-green-100 dark:bg-green-500/20 flex items-center justify-center border border-green-200 dark:border-green-500/30 shrink-0">
<MessageCircle size={14} className="text-green-600 dark:text-green-400 fill-current" />
</div>
) : (
item.imagenUrl && (
// eslint-disable-next-line @next/next/no-img-element
<img src={item.imagenUrl} alt={item.titulo} className="w-[42px] h-[28px] object-cover rounded shadow-sm group-hover:brightness-110 transition-all border border-slate-100 dark:border-white/10 shrink-0" />
)
)}
<span className={`text-[13px] font-medium flex items-center gap-2 transition-colors duration-300 ${
isWA ? 'text-green-700 dark:text-green-400 group-hover:text-green-900 dark:group-hover:text-green-300' : 'text-slate-600 dark:text-slate-300 group-hover:text-cyan-600 dark:group-hover:text-cyan-400'
}`}>
{isWA && <span className="text-[10px] font-bold px-1.5 py-0.5 bg-green-100 dark:bg-green-500/20 rounded border border-green-200 dark:border-green-500/30 text-green-700 dark:text-green-400">COMUNIDAD</span>}
{item.titulo.length > 90 ? item.titulo.substring(0, 90) + '...' : item.titulo}
</span>
</div>
);
if (item.slug) {
return <Link key={idx} href={`/${item.slug}`} className="flex items-center h-full">{Content}</Link>;
}
return <div key={idx} className="flex items-center h-full">{Content}</div>;
})}
</div>
</div>
{/* Right Block */}
{settings.show_news_date !== false && (
<div className="z-30 h-full bg-slate-800 flex items-center px-4 shrink-0 overflow-hidden hidden sm:flex">
<LiveClock />
</div>
)}
<style dangerouslySetInnerHTML={{__html: `
@keyframes marquee {

View File

@ -3,7 +3,7 @@
import { useState } from 'react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { LayoutDashboard, Newspaper, Folders, Building2, MapPin, BadgeDollarSign, FileText, LogOut, ChevronDown, ChevronRight, Image as ImageIcon, Settings, Calendar } from 'lucide-react';
import { LayoutDashboard, Newspaper, Folders, Building2, MapPin, BadgeDollarSign, FileText, LogOut, ChevronDown, ChevronRight, Image as ImageIcon, Settings, Calendar, RadioTower } from 'lucide-react';
import { logout } from '@/app/actions/auth';
export const Sidebar = ({ user }: { user: any }) => {
@ -58,6 +58,7 @@ export const Sidebar = ({ user }: { user: any }) => {
items: [
{ name: 'Perfil de Entidad', href: '/dashboard/entity', icon: Building2 },
{ name: 'Parrilla Programación', href: '/dashboard/programming', icon: Calendar },
{ name: 'Gestor StreamingTV', href: '/dashboard/streaming', icon: RadioTower },
{ name: 'Configuración Global', href: '/dashboard/settings', icon: Settings },
]
}

View File

@ -66,7 +66,7 @@ export const PublicationsList: React.FC<PublicationsListProps> = ({
// Local state
const [posts, setPosts] = useState<Post[]>(initialPosts);
const [searchQuery, setSearchQuery] = useState('');
const [statusFilter, setStatusFilter] = useState<string>('all');
const [sortBy, setSortBy] = useState<SortKey>('fecha_publicacion');
const [drawerPost, setDrawerPost] = useState<Post | null>(null);
@ -82,16 +82,6 @@ export const PublicationsList: React.FC<PublicationsListProps> = ({
const filteredAndSortedPosts = useMemo(() => {
let result = [...posts];
// Filter by search
if (searchQuery.trim()) {
const q = searchQuery.toLowerCase();
result = result.filter(
(p) =>
p.titulo.toLowerCase().includes(q) ||
p.slug.toLowerCase().includes(q) ||
(p.resumen_redes || '').toLowerCase().includes(q)
);
}
// Filter by status
if (statusFilter !== 'all') {
@ -108,7 +98,7 @@ export const PublicationsList: React.FC<PublicationsListProps> = ({
});
return result;
}, [posts, searchQuery, statusFilter, sortBy]);
}, [posts, statusFilter, sortBy]);
const stats = useMemo(() => ({
total: posts.length,
@ -180,19 +170,8 @@ export const PublicationsList: React.FC<PublicationsListProps> = ({
</div>
{/* Toolbar */}
<div className="flex flex-col md:flex-row gap-3">
{/* Search */}
<div className="relative flex-1">
<Search size={16} className="absolute left-4 top-1/2 -translate-y-1/2 text-slate-500" />
<input
type="search"
placeholder="Buscar publicaciones..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full bg-slate-900/60 border border-white/8 rounded-xl pl-10 pr-4 py-2.5 text-white text-sm
focus:border-cyan-500 focus:outline-none focus:ring-1 focus:ring-cyan-500/30 transition-all"
/>
</div>
<div className="flex flex-col md:flex-row gap-3 justify-end">
{/* Status Filter */}
<div className="flex items-center gap-2">
@ -268,7 +247,7 @@ export const PublicationsList: React.FC<PublicationsListProps> = ({
<div className="text-center">
<h3 className="text-white font-bold mb-1">Sin resultados</h3>
<p className="text-slate-500 text-sm">
{searchQuery ? 'No hay publicaciones que coincidan con tu búsqueda.' : 'Aún no hay publicaciones. ¡Crea la primera!'}
{'No se encontraron publicaciones. ¡Intenta con otra búsqueda o crea una nueva!'}
</p>
</div>
<PermissionGuard allowedRoles={['editor', 'admin']} userRole={userRole}>