feat: redesign NewsTicker, remove redundant dashboard search, and integrate Live TV module
This commit is contained in:
parent
41735c5280
commit
32e5450ab1
45
src/app/dashboard/streaming/page.tsx
Normal file
45
src/app/dashboard/streaming/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
39
src/components/LiveClock.tsx
Normal file
39
src/components/LiveClock.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
@ -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 },
|
||||
]
|
||||
}
|
||||
|
||||
@ -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}>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user