diff --git a/package-lock.json b/package-lock.json index af7b259..f53ab95 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,14 +21,14 @@ "@tiptap/starter-kit": "^3.21.0", "@types/dompurify": "^3.0.5", "@types/video.js": "^7.3.58", - "appwrite": "^24.1.1", + "appwrite": "^22.4.1", "axios": "^1.13.6", "clsx": "^2.1.1", "dompurify": "^3.3.3", "isomorphic-dompurify": "^2.20.0", "lucide-react": "^0.577.0", "next": "16.1.6", - "node-appwrite": "^23.1.0", + "node-appwrite": "^22.0.0", "react": "19.2.3", "react-dom": "19.2.3", "tailwind-merge": "^3.5.0", @@ -3137,9 +3137,9 @@ } }, "node_modules/appwrite": { - "version": "24.1.1", - "resolved": "https://registry.npmjs.org/appwrite/-/appwrite-24.1.1.tgz", - "integrity": "sha512-6dTKZe059GWyrH1AObfC0KR20vmQ9+wUo3iCRmM4lFofTRMrSzjCEcblpNERXHVKrLlzsr1T4kQweQEgrp89yQ==", + "version": "22.4.1", + "resolved": "https://registry.npmjs.org/appwrite/-/appwrite-22.4.1.tgz", + "integrity": "sha512-NlEgUvSo7A1m+TEHZ8zlWUIuCDtNg3VN6Mbxd6r+1618CiWokig630FpeKQ9/WD5Io92QxuTV/dVzBLPlQghqg==", "license": "BSD-3-Clause", "dependencies": { "json-bigint": "1.0.0" @@ -6494,9 +6494,9 @@ } }, "node_modules/node-appwrite": { - "version": "23.1.0", - "resolved": "https://registry.npmjs.org/node-appwrite/-/node-appwrite-23.1.0.tgz", - "integrity": "sha512-XYv23uIIrEfAomubIrQZFMo6jbIpCfmFwkvTwwo8zEJM1KTpFnSuXg8mSvtnu1ssSp6NqxVbasSAyQ8XF8cEmg==", + "version": "22.1.3", + "resolved": "https://registry.npmjs.org/node-appwrite/-/node-appwrite-22.1.3.tgz", + "integrity": "sha512-pXxvojqgYY3HAiJAj+P6xb/CXVNsMUd+JXVOLrCCDzqZmpKeMF6OEt4mAoDEyKTnRaixUDHlPrdh7y1G/w3sbw==", "license": "BSD-3-Clause", "dependencies": { "json-bigint": "1.0.0", diff --git a/package.json b/package.json index 5f5faa9..21698e4 100644 --- a/package.json +++ b/package.json @@ -23,14 +23,14 @@ "@tiptap/starter-kit": "^3.21.0", "@types/dompurify": "^3.0.5", "@types/video.js": "^7.3.58", - "appwrite": "^24.1.1", + "appwrite": "^22.4.1", "axios": "^1.13.6", "clsx": "^2.1.1", "dompurify": "^3.3.3", "isomorphic-dompurify": "^2.20.0", "lucide-react": "^0.577.0", "next": "16.1.6", - "node-appwrite": "^23.1.0", + "node-appwrite": "^22.0.0", "react": "19.2.3", "react-dom": "19.2.3", "tailwind-merge": "^3.5.0", diff --git a/setup_live_chat.js b/setup_live_chat.js new file mode 100644 index 0000000..5d03e38 --- /dev/null +++ b/setup_live_chat.js @@ -0,0 +1,44 @@ +const { Client, Databases, ID, Permission, Role } = require('node-appwrite'); +require('dotenv').config({ path: '.env.local' }); + +async function setupLiveChat() { + console.log("Configuring Live Chat collection..."); + const client = new Client() + .setEndpoint(process.env.NEXT_PUBLIC_APPWRITE_ENDPOINT) + .setProject(process.env.NEXT_PUBLIC_APPWRITE_PROJECT) + .setKey(process.env.APPWRITE_API_KEY); + + const databases = new Databases(client); + const dbId = process.env.NEXT_PUBLIC_APPWRITE_DATABASE; + + try { + const collection = await databases.createCollection( + dbId, + 'live_chat', + 'Live Chat', + [ + Permission.read(Role.any()), + Permission.create(Role.users()), // Logged in users can chat + Permission.update(Role.team('admin')), + Permission.delete(Role.team('admin')), + ] + ); + console.log("Collection 'live_chat' created."); + + // Attributes + await databases.createStringAttribute(dbId, 'live_chat', 'user_id', 50, true); + await databases.createStringAttribute(dbId, 'live_chat', 'user_name', 100, true); + await databases.createStringAttribute(dbId, 'live_chat', 'message', 500, true); + await databases.createDatetimeAttribute(dbId, 'live_chat', 'timestamp', true); + + console.log("Attributes created."); + } catch (e) { + if (e.code === 409) { + console.log("Collection already exists."); + } else { + console.error("Error creating collection:", e); + } + } +} + +setupLiveChat(); diff --git a/setup_stream_state.js b/setup_stream_state.js new file mode 100644 index 0000000..105912f --- /dev/null +++ b/setup_stream_state.js @@ -0,0 +1,41 @@ +const { Client, Databases, ID, Permission, Role } = require('node-appwrite'); +require('dotenv').config({ path: '.env.local' }); + +async function setupStreamState() { + console.log("Configuring Stream State collection..."); + const client = new Client() + .setEndpoint(process.env.NEXT_PUBLIC_APPWRITE_ENDPOINT) + .setProject(process.env.NEXT_PUBLIC_APPWRITE_PROJECT) + .setKey(process.env.APPWRITE_API_KEY); + + const databases = new Databases(client); + const dbId = process.env.NEXT_PUBLIC_APPWRITE_DATABASE; + + try { + const collection = await databases.createCollection( + dbId, + 'stream_state', + 'Stream State', + [ + Permission.read(Role.any()), + Permission.update(Role.any()), // Permitimos a cualquiera actualizar por ahora para la demo + Permission.create(Role.any()), + Permission.delete(Role.any()), + ] + ); + console.log("Collection 'stream_state' created."); + + // Attributes + await databases.createStringAttribute(dbId, 'stream_state', 'active_overlays', 10000, false); + + console.log("Attributes created."); + } catch (e) { + if (e.code === 409) { + console.log("Collection already exists."); + } else { + console.error("Error creating collection:", e); + } + } +} + +setupStreamState(); diff --git a/src/app/tv/overlay/page.tsx b/src/app/tv/overlay/page.tsx new file mode 100644 index 0000000..c7726f1 --- /dev/null +++ b/src/app/tv/overlay/page.tsx @@ -0,0 +1,124 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { databases } from '@/lib/appwrite'; + +const DATABASE_ID = process.env.NEXT_PUBLIC_APPWRITE_DATABASE || 'default'; +const COLLECTION_ID = 'stream_state'; +const DOCUMENT_ID = 'main_stream_state'; + +// El mismo modelo que en Android +type StreamOverlay = { + id: string; + type: 'LOWER_THIRD' | 'TICKER' | 'LOGO' | 'COMMENT'; + text: string; + subtext: string; + isVisible: boolean; + position: string; +}; + +export default function CloudOverlayPage() { + const [overlays, setOverlays] = useState([]); + + useEffect(() => { + // Inicializar documento si no existe (opcional, lo crea Android) + databases.getDocument(DATABASE_ID, COLLECTION_ID, DOCUMENT_ID) + .then((doc) => { + try { + if (doc.active_overlays) { + setOverlays(JSON.parse(doc.active_overlays)); + } + } catch(e) {} + }) + .catch(() => console.log('Documento no encontrado aún')); + + const unsubscribe = databases.client.subscribe( + `databases.${DATABASE_ID}.collections.${COLLECTION_ID}.documents.${DOCUMENT_ID}`, + (response: any) => { + if (response.payload && response.payload.active_overlays) { + try { + const parsed = JSON.parse(response.payload.active_overlays); + setOverlays(parsed); + } catch(e) {} + } + } + ); + + return () => unsubscribe(); + }, []); + + // Esta página debe tener fondo transparente + useEffect(() => { + document.body.style.backgroundColor = 'transparent'; + }, []); + + return ( +
+ {overlays.filter(o => o.isVisible).map(overlay => { + return ( + + ); + })} +
+ ); +} + +function OverlayRenderer({ overlay }: { overlay: StreamOverlay }) { + // Determinar posicionamiento + const getPositionClasses = () => { + switch (overlay.position) { + case 'BOTTOM_LEFT': return 'bottom-16 left-16'; + case 'BOTTOM_CENTER': return 'bottom-16 left-1/2 -translate-x-1/2'; + case 'BOTTOM_RIGHT': return 'bottom-16 right-16'; + case 'TOP_LEFT': return 'top-16 left-16'; + case 'TOP_CENTER': return 'top-16 left-1/2 -translate-x-1/2'; + case 'TOP_RIGHT': return 'top-16 right-16'; + default: return 'bottom-16 left-16'; + } + }; + + const positionClass = getPositionClasses(); + + if (overlay.type === 'LOWER_THIRD') { + return ( +
+
+
+

{overlay.text}

+ {overlay.subtext &&

{overlay.subtext}

} +
+
+ ); + } + + if (overlay.type === 'COMMENT') { + return ( +
+
+

{overlay.text}

+
+
+ ); + } + + if (overlay.type === 'TICKER') { + return ( +
+
+ • {overlay.text} + • {overlay.text} + • {overlay.text} + • {overlay.text} +
+ +
+ ); + } + + return null; +} diff --git a/src/app/tv/page.tsx b/src/app/tv/page.tsx index 80d353a..4aa7baf 100644 --- a/src/app/tv/page.tsx +++ b/src/app/tv/page.tsx @@ -1,9 +1,10 @@ import { APPWRITE_ALMEIRA_CONFIG } from '@/lib/appwrite'; import { createAdminClient } from '@/lib/appwrite.server'; import ProgrammingSchedule from '@/components/ProgrammingSchedule'; -import { Tv, RadioTower } from 'lucide-react'; +import { Tv, RadioTower, MessageSquare } from 'lucide-react'; import { getEntityConfig } from '@/app/actions/entity'; import { LivePlayer } from '@/components/media/LivePlayer'; +import LiveChatBox from '@/components/LiveChatBox'; // Nuevo componente cliente export const revalidate = 60; // Revalida la TV cada minuto si hay cambios @@ -42,9 +43,24 @@ export default async function TVPage() { - {/* Video Player Container */} + {/* Video Player & Chat Container */}
- +
+
+ +
+
+
+
+ +

Chat en Vivo

+
+
+ +
+
+
+
{/* Parrilla */} diff --git a/src/components/LiveChatBox.tsx b/src/components/LiveChatBox.tsx new file mode 100644 index 0000000..290afb6 --- /dev/null +++ b/src/components/LiveChatBox.tsx @@ -0,0 +1,112 @@ +'use client'; + +import { useState, useEffect, useRef } from 'react'; +import { account, databases } from '@/lib/appwrite'; +import { Send, User } from 'lucide-react'; +import { ID, Query } from 'appwrite'; + +const DATABASE_ID = process.env.NEXT_PUBLIC_APPWRITE_DATABASE || 'default'; +const COLLECTION_ID = 'live_chat'; + +export default function LiveChatBox() { + const [messages, setMessages] = useState([]); + const [newMessage, setNewMessage] = useState(''); + const [user, setUser] = useState(null); + const messagesEndRef = useRef(null); + + useEffect(() => { + // Verificar sesión + account.get().then(setUser).catch(() => setUser(null)); + + // Cargar últimos 50 + databases.listDocuments(DATABASE_ID, COLLECTION_ID, [ + Query.orderDesc('timestamp'), + Query.limit(50) + ]).then(res => { + setMessages(res.documents.reverse()); + scrollToBottom(); + }).catch(console.error); + + // Suscribirse a nuevos mensajes + const unsubscribe = databases.client.subscribe( + `databases.${DATABASE_ID}.collections.${COLLECTION_ID}.documents`, + response => { + if (response.events.includes('databases.*.collections.*.documents.*.create')) { + setMessages(prev => [...prev, response.payload]); + setTimeout(scrollToBottom, 100); + } + } + ); + + return () => unsubscribe(); + }, []); + + const scrollToBottom = () => { + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); + }; + + const sendMessage = async (e: React.FormEvent) => { + e.preventDefault(); + if (!newMessage.trim() || !user) return; + + try { + await databases.createDocument(DATABASE_ID, COLLECTION_ID, ID.unique(), { + user_id: user.$id, + user_name: user.name || 'Usuario', + message: newMessage.trim(), + timestamp: new Date().toISOString() + }); + setNewMessage(''); + } catch (error) { + console.error('Error sending message:', error); + alert('Error al enviar el mensaje'); + } + }; + + return ( +
+
+ {messages.length === 0 ? ( +
+ No hay mensajes aún. ¡Sé el primero! +
+ ) : ( + messages.map((msg, idx) => ( +
+ {msg.user_name} + {msg.message} +
+ )) + )} +
+
+ +
+ {!user ? ( +
+ + Inicia sesión para participar en el chat +
+ ) : ( +
+ setNewMessage(e.target.value)} + placeholder="Escribe un mensaje..." + className="flex-1 bg-black border border-slate-700 rounded-lg px-4 py-2 text-white focus:border-cyan-500 focus:outline-none text-sm" + maxLength={500} + /> + +
+ )} +
+
+ ); +} diff --git a/src/lib/appwrite.ts b/src/lib/appwrite.ts index 34d976f..0f882c2 100644 --- a/src/lib/appwrite.ts +++ b/src/lib/appwrite.ts @@ -1,4 +1,4 @@ -import { Client, Databases, Storage, Query } from 'appwrite'; +import { Client, Databases, Storage, Query, Account } from 'appwrite'; const client = new Client(); @@ -12,6 +12,7 @@ client export const databases = new Databases(client); export const storage = new Storage(client); +export const account = new Account(client); export { Query }; export const APPWRITE_ALMEIRA_CONFIG = {