Asistente IA (openAI - proxyapi.ru)
This commit is contained in:
parent
9c7c57d41f
commit
d6957d7959
@ -66,6 +66,7 @@
|
|||||||
"settingsApiServer": { "message": "API- und Serverkonfiguration" },
|
"settingsApiServer": { "message": "API- und Serverkonfiguration" },
|
||||||
"settingsTmdbApiLabel": { "message": "TMDB API-Schlüssel (Optional)" },
|
"settingsTmdbApiLabel": { "message": "TMDB API-Schlüssel (Optional)" },
|
||||||
"settingsTmdbApiPlaceholder": { "message": "Verwendet den Standardschlüssel, wenn leer gelassen" },
|
"settingsTmdbApiPlaceholder": { "message": "Verwendet den Standardschlüssel, wenn leer gelassen" },
|
||||||
|
"openaiApiKey": { "message": "OpenAI API-Schlüssel" },
|
||||||
"settingsTmdbLangLabel": { "message": "Sprache für TMDB & UI" },
|
"settingsTmdbLangLabel": { "message": "Sprache für TMDB & UI" },
|
||||||
"settingsRegionLabel": { "message": "Region für Anbieter" },
|
"settingsRegionLabel": { "message": "Region für Anbieter" },
|
||||||
"allRegions": { "message": "Alle Regionen" },
|
"allRegions": { "message": "Alle Regionen" },
|
||||||
@ -348,5 +349,17 @@
|
|||||||
"m3uInstruction3": { "message": "Klicken Sie auf die Download-Schaltfläche." },
|
"m3uInstruction3": { "message": "Klicken Sie auf die Download-Schaltfläche." },
|
||||||
"m3uInstruction4": { "message": "Importieren Sie die .m3u-Datei in Ihren kompatiblen Player." },
|
"m3uInstruction4": { "message": "Importieren Sie die .m3u-Datei in Ihren kompatiblen Player." },
|
||||||
"settingsRegionLabel": { "message": "Region für die Inhaltsentdeckung" },
|
"settingsRegionLabel": { "message": "Region für die Inhaltsentdeckung" },
|
||||||
"allRegions": { "message": "Alle Regionen" }
|
"allRegions": { "message": "Alle Regionen" },
|
||||||
|
"chatOpen": { "message": "Chat öffnen" },
|
||||||
|
"chatTitle": { "message": "KI-Assistent" },
|
||||||
|
"chatClose": { "message": "X" },
|
||||||
|
"chatPlaceholder": { "message": "Nachricht eingeben..." },
|
||||||
|
"chatSend": { "message": "➤" },
|
||||||
|
"chatGptError": { "message": "Entschuldigung, ich konnte keine Antwort erhalten. Bitte versuchen Sie es erneut." },
|
||||||
|
"chatWelcome": { "message": "Willkommen! Ich bin Ihr CinePlex-Assistent. Fragen Sie mich nach Filmen, Serien oder allem, was Sie sonst noch wissen möchten." },
|
||||||
|
"chatApiKeyMissing": { "message": "Der OpenAI-API-Schlüssel ist nicht festgelegt. Bitte konfigurieren Sie ihn in den Erweiterungseinstellungen." },
|
||||||
|
"chatApiInvalidResponse": { "message": "Die API hat eine ungültige Antwort zurückgegeben. Bitte versuchen Sie es erneut." },
|
||||||
|
"chatApiError": { "message": "Fehler bei der Kommunikation mit dem KI-Assistenten" },
|
||||||
|
"downloadAll": { "message": "Alles herunterladen" },
|
||||||
|
"download": { "message": "Herunterladen" }
|
||||||
}
|
}
|
@ -66,6 +66,7 @@
|
|||||||
"settingsApiServer": { "message": "API and Server Configuration" },
|
"settingsApiServer": { "message": "API and Server Configuration" },
|
||||||
"settingsTmdbApiLabel": { "message": "TMDB API Key (Optional)" },
|
"settingsTmdbApiLabel": { "message": "TMDB API Key (Optional)" },
|
||||||
"settingsTmdbApiPlaceholder": { "message": "Will use default key if left blank" },
|
"settingsTmdbApiPlaceholder": { "message": "Will use default key if left blank" },
|
||||||
|
"openaiApiKey": { "message": "OpenAI API Key" },
|
||||||
"settingsTmdbLangLabel": { "message": "Language for TMDB & UI" },
|
"settingsTmdbLangLabel": { "message": "Language for TMDB & UI" },
|
||||||
"settingsPhpUrlLabel": { "message": "Server URL for Adding Streams" },
|
"settingsPhpUrlLabel": { "message": "Server URL for Adding Streams" },
|
||||||
"settingsPhpUrlPlaceholder": { "message": "https://your-server.com/path/to/script.php" },
|
"settingsPhpUrlPlaceholder": { "message": "https://your-server.com/path/to/script.php" },
|
||||||
@ -345,5 +346,17 @@
|
|||||||
"m3uInstruction3": { "message": "Click the download button." },
|
"m3uInstruction3": { "message": "Click the download button." },
|
||||||
"m3uInstruction4": { "message": "Import the .m3u file into your compatible player." },
|
"m3uInstruction4": { "message": "Import the .m3u file into your compatible player." },
|
||||||
"settingsRegionLabel": { "message": "Region for content discovery" },
|
"settingsRegionLabel": { "message": "Region for content discovery" },
|
||||||
"allRegions": { "message": "All regions" }
|
"allRegions": { "message": "All regions" },
|
||||||
|
"chatOpen": { "message": "Open Chat" },
|
||||||
|
"chatTitle": { "message": "AI Assistant" },
|
||||||
|
"chatClose": { "message": "X" },
|
||||||
|
"chatPlaceholder": { "message": "Type your message..." },
|
||||||
|
"chatSend": { "message": "➤" },
|
||||||
|
"chatGptError": { "message": "Sorry, I couldn't get a response. Please try again." },
|
||||||
|
"chatWelcome": { "message": "Welcome! I'm your CinePlex assistant. Ask me about movies, series, or anything else you'd like to know." },
|
||||||
|
"chatApiKeyMissing": { "message": "OpenAI API key is not set. Please configure it in the extension settings." },
|
||||||
|
"chatApiInvalidResponse": { "message": "The API returned an invalid response. Please try again." },
|
||||||
|
"chatApiError": { "message": "Error communicating with the AI assistant" },
|
||||||
|
"downloadAll": { "message": "Download All" },
|
||||||
|
"download": { "message": "Download" }
|
||||||
}
|
}
|
@ -66,6 +66,7 @@
|
|||||||
"settingsApiServer": { "message": "Configuración de API y Servidor" },
|
"settingsApiServer": { "message": "Configuración de API y Servidor" },
|
||||||
"settingsTmdbApiLabel": { "message": "Clave de API de TMDB (Opcional)" },
|
"settingsTmdbApiLabel": { "message": "Clave de API de TMDB (Opcional)" },
|
||||||
"settingsTmdbApiPlaceholder": { "message": "Se usará la clave por defecto si se deja en blanco" },
|
"settingsTmdbApiPlaceholder": { "message": "Se usará la clave por defecto si se deja en blanco" },
|
||||||
|
"openaiApiKey": { "message": "Clave API de OpenAI" },
|
||||||
"settingsTmdbLangLabel": { "message": "Idioma para TMDB y la interfaz" },
|
"settingsTmdbLangLabel": { "message": "Idioma para TMDB y la interfaz" },
|
||||||
"settingsPhpUrlLabel": { "message": "URL del Servidor para Añadir Streams" },
|
"settingsPhpUrlLabel": { "message": "URL del Servidor para Añadir Streams" },
|
||||||
"settingsPhpUrlPlaceholder": { "message": "https://tu-servidor.com/ruta/al/script.php" },
|
"settingsPhpUrlPlaceholder": { "message": "https://tu-servidor.com/ruta/al/script.php" },
|
||||||
@ -345,5 +346,17 @@
|
|||||||
"m3uInstruction3": { "message": "Haz clic en el botón de descarga." },
|
"m3uInstruction3": { "message": "Haz clic en el botón de descarga." },
|
||||||
"m3uInstruction4": { "message": "Importa el archivo .m3u en tu reproductor compatible." },
|
"m3uInstruction4": { "message": "Importa el archivo .m3u en tu reproductor compatible." },
|
||||||
"settingsRegionLabel": { "message": "Región para descubrimiento de contenido" },
|
"settingsRegionLabel": { "message": "Región para descubrimiento de contenido" },
|
||||||
"allRegions": { "message": "Todas las regiones" }
|
"allRegions": { "message": "Todas las regiones" },
|
||||||
|
"chatOpen": { "message": "Abrir Chat" },
|
||||||
|
"chatTitle": { "message": "Asistente IA" },
|
||||||
|
"chatClose": { "message": "X" },
|
||||||
|
"chatPlaceholder": { "message": "Escribe tu mensaje..." },
|
||||||
|
"chatSend": { "message": "➤" },
|
||||||
|
"chatGptError": { "message": "Lo siento, no pude obtener una respuesta. Inténtalo de nuevo." },
|
||||||
|
"chatWelcome": { "message": "¡Bienvenido! Soy tu asistente de CinePlex. Pregúntame sobre películas, series o cualquier otra cosa que quieras saber." },
|
||||||
|
"chatApiKeyMissing": { "message": "La clave de la API de OpenAI no está configurada. Por favor, configúrala en los ajustes de la extensión." },
|
||||||
|
"chatApiInvalidResponse": { "message": "La API ha devuelto una respuesta no válida. Por favor, inténtalo de nuevo." },
|
||||||
|
"chatApiError": { "message": "Error al comunicarse con el asistente de IA" },
|
||||||
|
"downloadAll": { "message": "Descargar todo" },
|
||||||
|
"download": { "message": "Descargar" }
|
||||||
}
|
}
|
@ -66,6 +66,7 @@
|
|||||||
"settingsApiServer": { "message": "Configuration API et Serveur" },
|
"settingsApiServer": { "message": "Configuration API et Serveur" },
|
||||||
"settingsTmdbApiLabel": { "message": "Clé API de TMDB (Optionnel)" },
|
"settingsTmdbApiLabel": { "message": "Clé API de TMDB (Optionnel)" },
|
||||||
"settingsTmdbApiPlaceholder": { "message": "Utilisera la clé par défaut si laissé vide" },
|
"settingsTmdbApiPlaceholder": { "message": "Utilisera la clé par défaut si laissé vide" },
|
||||||
|
"openaiApiKey": { "message": "Clé API OpenAI" },
|
||||||
"settingsTmdbLangLabel": { "message": "Langue pour TMDB & UI" },
|
"settingsTmdbLangLabel": { "message": "Langue pour TMDB & UI" },
|
||||||
"settingsPhpUrlLabel": { "message": "URL du Serveur pour Ajout de Flux" },
|
"settingsPhpUrlLabel": { "message": "URL du Serveur pour Ajout de Flux" },
|
||||||
"settingsPhpUrlPlaceholder": { "message": "https://votre-serveur.com/chemin/vers/script.php" },
|
"settingsPhpUrlPlaceholder": { "message": "https://votre-serveur.com/chemin/vers/script.php" },
|
||||||
@ -345,5 +346,17 @@
|
|||||||
"m3uInstruction3": { "message": "Cliquez sur le bouton de téléchargement." },
|
"m3uInstruction3": { "message": "Cliquez sur le bouton de téléchargement." },
|
||||||
"m3uInstruction4": { "message": "Importez le fichier .m3u dans votre lecteur compatible." },
|
"m3uInstruction4": { "message": "Importez le fichier .m3u dans votre lecteur compatible." },
|
||||||
"settingsRegionLabel": { "message": "Région pour la découverte de contenu" },
|
"settingsRegionLabel": { "message": "Région pour la découverte de contenu" },
|
||||||
"allRegions": { "message": "Toutes les régions" }
|
"allRegions": { "message": "Toutes les régions" },
|
||||||
|
"chatOpen": { "message": "Ouvrir le chat" },
|
||||||
|
"chatTitle": { "message": "Assistant IA" },
|
||||||
|
"chatClose": { "message": "X" },
|
||||||
|
"chatPlaceholder": { "message": "Écrivez votre message..." },
|
||||||
|
"chatSend": { "message": "➤" },
|
||||||
|
"chatGptError": { "message": "Désolé, je n'ai pas pu obtenir de réponse. Veuillez réessayer." },
|
||||||
|
"chatWelcome": { "message": "Bienvenue ! Je suis votre assistant CinePlex. Posez-moi des questions sur les films, les séries ou tout ce que vous voulez savoir." },
|
||||||
|
"chatApiKeyMissing": { "message": "La clé API OpenAI n'est pas définie. Veuillez la configurer dans les paramètres de l'extension." },
|
||||||
|
"chatApiInvalidResponse": { "message": "L'API a renvoyé une réponse non valide. Veuillez réessayer." },
|
||||||
|
"chatApiError": { "message": "Erreur de communication avec l'assistant IA" },
|
||||||
|
"downloadAll": { "message": "Tout télécharger" },
|
||||||
|
"download": { "message": "Télécharger" }
|
||||||
}
|
}
|
@ -66,6 +66,7 @@
|
|||||||
"settingsApiServer": { "message": "Configurazione API e Server" },
|
"settingsApiServer": { "message": "Configurazione API e Server" },
|
||||||
"settingsTmdbApiLabel": { "message": "Chiave API TMDB (Opzionale)" },
|
"settingsTmdbApiLabel": { "message": "Chiave API TMDB (Opzionale)" },
|
||||||
"settingsTmdbApiPlaceholder": { "message": "Verrà usata la chiave predefinita se lasciato vuoto" },
|
"settingsTmdbApiPlaceholder": { "message": "Verrà usata la chiave predefinita se lasciato vuoto" },
|
||||||
|
"openaiApiKey": { "message": "Chiave API OpenAI" },
|
||||||
"settingsTmdbLangLabel": { "message": "Lingua per TMDB e Interfaccia" },
|
"settingsTmdbLangLabel": { "message": "Lingua per TMDB e Interfaccia" },
|
||||||
"settingsPhpUrlLabel": { "message": "URL del server per aggiungere gli stream" },
|
"settingsPhpUrlLabel": { "message": "URL del server per aggiungere gli stream" },
|
||||||
"settingsPhpUrlPlaceholder": { "message": "https://tuo-server.com/percorso/dello/script.php" },
|
"settingsPhpUrlPlaceholder": { "message": "https://tuo-server.com/percorso/dello/script.php" },
|
||||||
@ -345,5 +346,17 @@
|
|||||||
"m3uInstruction3": { "message": "Clicca sul pulsante di download." },
|
"m3uInstruction3": { "message": "Clicca sul pulsante di download." },
|
||||||
"m3uInstruction4": { "message": "Importa il file .m3u nel tuo lettore compatibile." },
|
"m3uInstruction4": { "message": "Importa il file .m3u nel tuo lettore compatibile." },
|
||||||
"settingsRegionLabel": { "message": "Regione per la scoperta di contenuti" },
|
"settingsRegionLabel": { "message": "Regione per la scoperta di contenuti" },
|
||||||
"allRegions": { "message": "Tutte le regioni" }
|
"allRegions": { "message": "Tutte le regioni" },
|
||||||
|
"chatOpen": { "message": "Apri chat" },
|
||||||
|
"chatTitle": { "message": "Assistente AI" },
|
||||||
|
"chatClose": { "message": "X" },
|
||||||
|
"chatPlaceholder": { "message": "Scrivi il tuo messaggio..." },
|
||||||
|
"chatSend": { "message": "➤" },
|
||||||
|
"chatGptError": { "message": "Spiacenti, non sono riuscito a ottenere una risposta. Per favore, riprova." },
|
||||||
|
"chatWelcome": { "message": "Benvenuto! Sono il tuo assistente CinePlex. Chiedimi di film, serie o qualsiasi altra cosa tu voglia sapere." },
|
||||||
|
"chatApiKeyMissing": { "message": "La chiave API di OpenAI non è impostata. Configurala nelle impostazioni dell'estensione." },
|
||||||
|
"chatApiInvalidResponse": { "message": "L'API ha restituito una risposta non valida. Per favore, riprova." },
|
||||||
|
"chatApiError": { "message": "Errore di comunicazione con l'assistente AI" },
|
||||||
|
"downloadAll": { "message": "Scarica tutto" },
|
||||||
|
"download": { "message": "Scarica" }
|
||||||
}
|
}
|
@ -66,6 +66,7 @@
|
|||||||
"settingsApiServer": { "message": "Configuração de API e Servidor" },
|
"settingsApiServer": { "message": "Configuração de API e Servidor" },
|
||||||
"settingsTmdbApiLabel": { "message": "Chave da API do TMDB (Opcional)" },
|
"settingsTmdbApiLabel": { "message": "Chave da API do TMDB (Opcional)" },
|
||||||
"settingsTmdbApiPlaceholder": { "message": "Usará a chave padrão se deixado em branco" },
|
"settingsTmdbApiPlaceholder": { "message": "Usará a chave padrão se deixado em branco" },
|
||||||
|
"openaiApiKey": { "message": "Chave da API OpenAI" },
|
||||||
"settingsTmdbLangLabel": { "message": "Idioma para TMDB e Interface" },
|
"settingsTmdbLangLabel": { "message": "Idioma para TMDB e Interface" },
|
||||||
"settingsPhpUrlLabel": { "message": "URL do Servidor para Adicionar Streams" },
|
"settingsPhpUrlLabel": { "message": "URL do Servidor para Adicionar Streams" },
|
||||||
"settingsPhpUrlPlaceholder": { "message": "https://seu-servidor.com/caminho/para/script.php" },
|
"settingsPhpUrlPlaceholder": { "message": "https://seu-servidor.com/caminho/para/script.php" },
|
||||||
@ -345,5 +346,17 @@
|
|||||||
"m3uInstruction3": { "message": "Clique no botão de download." },
|
"m3uInstruction3": { "message": "Clique no botão de download." },
|
||||||
"m3uInstruction4": { "message": "Importe o arquivo .m3u para o seu reprodutor compatível." },
|
"m3uInstruction4": { "message": "Importe o arquivo .m3u para o seu reprodutor compatível." },
|
||||||
"settingsRegionLabel": { "message": "Região para descoberta de conteúdo" },
|
"settingsRegionLabel": { "message": "Região para descoberta de conteúdo" },
|
||||||
"allRegions": { "message": "Todas as regiões" }
|
"allRegions": { "message": "Todas as regiões" },
|
||||||
|
"chatOpen": { "message": "Abrir chat" },
|
||||||
|
"chatTitle": { "message": "Assistente de IA" },
|
||||||
|
"chatClose": { "message": "X" },
|
||||||
|
"chatPlaceholder": { "message": "Digite sua mensagem..." },
|
||||||
|
"chatSend": { "message": "➤" },
|
||||||
|
"chatGptError": { "message": "Desculpe, não consegui obter uma resposta. Por favor, tente novamente." },
|
||||||
|
"chatWelcome": { "message": "Bem-vindo! Eu sou seu assistente CinePlex. Pergunte-me sobre filmes, séries ou qualquer outra coisa que você queira saber." },
|
||||||
|
"chatApiKeyMissing": { "message": "A chave da API OpenAI não está definida. Configure-a nas configurações da extensão." },
|
||||||
|
"chatApiInvalidResponse": { "message": "A API retornou uma resposta inválida. Por favor, tente novamente." },
|
||||||
|
"chatApiError": { "message": "Erro ao se comunicar com o assistente de IA" },
|
||||||
|
"downloadAll": { "message": "Baixar tudo" },
|
||||||
|
"download": { "message": "Baixar" }
|
||||||
}
|
}
|
271
css/main.css
271
css/main.css
@ -2936,9 +2936,7 @@ body.miniplayer-active #musicPlayerContainer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.fab-btn {
|
.fab-btn {
|
||||||
position: fixed;
|
position: relative;
|
||||||
bottom: 2rem;
|
|
||||||
right: 2rem;
|
|
||||||
width: 60px;
|
width: 60px;
|
||||||
height: 60px;
|
height: 60px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
@ -3909,3 +3907,270 @@ body.miniplayer-active { padding-bottom: 85px; }
|
|||||||
opacity: 1;
|
opacity: 1;
|
||||||
transform: translateY(0);
|
transform: translateY(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* --- chat.css --- */
|
||||||
|
#fab-container {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 2rem;
|
||||||
|
right: 2rem;
|
||||||
|
z-index: 1050;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row-reverse;
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: flex-end;
|
||||||
|
transition: bottom 0.5s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.miniplayer-active #fab-container {
|
||||||
|
bottom: calc(85px + 2rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-fab, .fab-btn {
|
||||||
|
position: relative;
|
||||||
|
right: auto;
|
||||||
|
bottom: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-fab {
|
||||||
|
width: 60px;
|
||||||
|
height: 60px;
|
||||||
|
background: var(--gradient);
|
||||||
|
color: var(--primary);
|
||||||
|
border-radius: 50%;
|
||||||
|
border: none;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 1.8rem;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 5px 20px rgba(0, 224, 255, 0.3);
|
||||||
|
transition: var(--transition);
|
||||||
|
}
|
||||||
|
.chat-fab:hover {
|
||||||
|
transform: scale(1.1);
|
||||||
|
box-shadow: 0 8px 25px rgba(0, 224, 255, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-window {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 95px;
|
||||||
|
right: 2rem;
|
||||||
|
width: 400px;
|
||||||
|
height: 520px;
|
||||||
|
max-width: 90vw;
|
||||||
|
max-height: 70vh;
|
||||||
|
background-color: var(--primary);
|
||||||
|
border-radius: var(--border-radius-lg);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
z-index: 1051;
|
||||||
|
backdrop-filter: blur(15px);
|
||||||
|
-webkit-backdrop-filter: blur(15px);
|
||||||
|
background: rgba(10, 10, 15, 0.8);
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
.light-theme .chat-window {
|
||||||
|
background: rgba(244, 247, 250, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.8rem 1.2rem;
|
||||||
|
background-color: rgba(255,255,255,0.05);
|
||||||
|
border-bottom: 1px solid var(--glass-border);
|
||||||
|
cursor: move;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.chat-title {
|
||||||
|
font-family: 'Orbitron', sans-serif;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 1.1rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.chat-close-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 1.2rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: var(--transition);
|
||||||
|
}
|
||||||
|
.chat-close-btn:hover {
|
||||||
|
color: var(--accent);
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-messages {
|
||||||
|
flex-grow: 1;
|
||||||
|
padding: 1rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-messages::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-messages::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-messages::-webkit-scrollbar-thumb {
|
||||||
|
background-color: var(--glass-border);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message {
|
||||||
|
padding: 0.8rem 1.2rem;
|
||||||
|
border-radius: var(--border-radius-md);
|
||||||
|
max-width: 85%;
|
||||||
|
line-height: 1.6;
|
||||||
|
word-wrap: break-word;
|
||||||
|
}
|
||||||
|
.message p {
|
||||||
|
margin: 0;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
.message p:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-message {
|
||||||
|
background: var(--gradient);
|
||||||
|
color: var(--primary);
|
||||||
|
align-self: flex-end;
|
||||||
|
border-bottom-right-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-message {
|
||||||
|
background-color: var(--secondary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
align-self: flex-start;
|
||||||
|
border-bottom-left-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-action-buttons {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: .5rem;
|
||||||
|
margin-top: .8rem;
|
||||||
|
padding-top: .8rem;
|
||||||
|
border-top: 1px solid var(--glass-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-action-buttons button {
|
||||||
|
background-color: var(--glass);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
padding: .4rem .8rem;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: .8rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-action-buttons button:hover {
|
||||||
|
background-color: var(--accent);
|
||||||
|
color: var(--primary);
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-message code {
|
||||||
|
background-color: var(--primary);
|
||||||
|
padding: 0.2em 0.4em;
|
||||||
|
margin: 0;
|
||||||
|
font-size: 85%;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.typing-indicator {
|
||||||
|
display: flex;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
.typing-indicator span {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
background-color: var(--text-secondary);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: typing 1s infinite;
|
||||||
|
}
|
||||||
|
.typing-indicator span:nth-child(2) { animation-delay: 0.2s; }
|
||||||
|
.typing-indicator span:nth-child(3) { animation-delay: 0.4s; }
|
||||||
|
|
||||||
|
@keyframes typing {
|
||||||
|
0%, 100% { transform: translateY(0); }
|
||||||
|
50% { transform: translateY(-5px); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-input-form {
|
||||||
|
display: flex;
|
||||||
|
padding: 0.8rem;
|
||||||
|
border-top: 1px solid var(--glass-border);
|
||||||
|
gap: 0.8rem;
|
||||||
|
background-color: rgba(255,255,255,0.05);
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-input {
|
||||||
|
flex-grow: 1;
|
||||||
|
background: var(--glass);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 0.6rem 1rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
resize: none;
|
||||||
|
font-family: 'Montserrat', sans-serif;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
.chat-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 0 0 2px rgba(0, 224, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-send-btn {
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--primary);
|
||||||
|
border: none;
|
||||||
|
border-radius: 50%;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
font-size: 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: var(--transition);
|
||||||
|
}
|
||||||
|
.chat-send-btn:hover {
|
||||||
|
background: var(--accent-dark);
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
.chat-send-btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
body.miniplayer-active #fab-container {
|
||||||
|
bottom: calc(110px + 1rem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.chat-window {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
bottom: 0;
|
||||||
|
right: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
max-height: none;
|
||||||
|
}
|
||||||
|
}
|
342
js/chat.js
Normal file
342
js/chat.js
Normal file
@ -0,0 +1,342 @@
|
|||||||
|
import { state } from './state.js';
|
||||||
|
import { showNotification, _ } from './utils.js';
|
||||||
|
import { getFromDB } from './db.js';
|
||||||
|
import { showItemDetails, addStreamToList, downloadM3U } from './ui.js';
|
||||||
|
|
||||||
|
export class Chat {
|
||||||
|
constructor() {
|
||||||
|
this.dom = {
|
||||||
|
fab: document.getElementById('chat-fab'),
|
||||||
|
window: document.getElementById('chat-window'),
|
||||||
|
header: document.querySelector('.chat-header'),
|
||||||
|
closeBtn: document.getElementById('chat-close-btn'),
|
||||||
|
messagesContainer: document.getElementById('chat-messages'),
|
||||||
|
inputForm: document.getElementById('chat-input-form'),
|
||||||
|
input: document.getElementById('chat-input'),
|
||||||
|
sendBtn: document.getElementById('chat-send-btn')
|
||||||
|
};
|
||||||
|
this.isOpen = false;
|
||||||
|
this.isDragging = false;
|
||||||
|
this.offset = { x: 0, y: 0 };
|
||||||
|
this.conversationHistory = [];
|
||||||
|
|
||||||
|
this.bindEvents();
|
||||||
|
}
|
||||||
|
|
||||||
|
bindEvents() {
|
||||||
|
this.dom.fab.addEventListener('click', () => this.toggle());
|
||||||
|
this.dom.closeBtn.addEventListener('click', () => this.close());
|
||||||
|
this.dom.inputForm.addEventListener('submit', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
this.sendMessage();
|
||||||
|
});
|
||||||
|
this.dom.input.addEventListener('input', this.autoResizeTextarea.bind(this));
|
||||||
|
this.dom.input.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
this.sendMessage();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this.dom.header.addEventListener('mousedown', this.startDrag.bind(this));
|
||||||
|
document.addEventListener('mousemove', this.drag.bind(this));
|
||||||
|
document.addEventListener('mouseup', this.stopDrag.bind(this));
|
||||||
|
}
|
||||||
|
|
||||||
|
toggle() {
|
||||||
|
this.isOpen ? this.close() : this.open();
|
||||||
|
}
|
||||||
|
|
||||||
|
open() {
|
||||||
|
if (this.isOpen) return;
|
||||||
|
this.isOpen = true;
|
||||||
|
|
||||||
|
this.dom.window.style.top = '';
|
||||||
|
this.dom.window.style.left = '';
|
||||||
|
this.dom.window.style.bottom = '95px';
|
||||||
|
this.dom.window.style.right = '2rem';
|
||||||
|
|
||||||
|
this.dom.window.style.display = 'flex';
|
||||||
|
gsap.fromTo(this.dom.window, { opacity: 0, scale: 0.9, y: 20 }, { opacity: 1, scale: 1, y: 0, duration: 0.3, ease: 'power3.out' });
|
||||||
|
gsap.to(this.dom.fab, { scale: 0, opacity: 0, duration: 0.2, ease: 'power2.in' });
|
||||||
|
|
||||||
|
if (this.conversationHistory.length === 0) {
|
||||||
|
this.addMessage(_('chatWelcome'), 'assistant');
|
||||||
|
this.conversationHistory.push({ role: 'assistant', content: _('chatWelcome') });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
close() {
|
||||||
|
if (!this.isOpen) return;
|
||||||
|
this.isOpen = false;
|
||||||
|
gsap.to(this.dom.window, { opacity: 0, scale: 0.9, y: 20, duration: 0.3, ease: 'power2.in', onComplete: () => {
|
||||||
|
this.dom.window.style.display = 'none';
|
||||||
|
}});
|
||||||
|
gsap.fromTo(this.dom.fab, { scale: 0, opacity: 0 }, { scale: 1, opacity: 1, duration: 0.3, ease: 'back.out(1.7)', delay: 0.2 });
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendMessage() {
|
||||||
|
const userInput = this.dom.input.value.trim();
|
||||||
|
if (!userInput) return;
|
||||||
|
|
||||||
|
this.addMessage(userInput, 'user');
|
||||||
|
this.conversationHistory.push({ role: 'user', content: userInput });
|
||||||
|
this.dom.input.value = '';
|
||||||
|
this.autoResizeTextarea();
|
||||||
|
this.dom.sendBtn.disabled = true;
|
||||||
|
|
||||||
|
this.addTypingIndicator();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await this.getOpenAIResponse();
|
||||||
|
this.removeTypingIndicator();
|
||||||
|
this.addMessage(response, 'assistant');
|
||||||
|
this.conversationHistory.push({ role: 'assistant', content: response });
|
||||||
|
} catch (error) {
|
||||||
|
this.removeTypingIndicator();
|
||||||
|
this.addMessage(error.message, 'assistant', true);
|
||||||
|
} finally {
|
||||||
|
this.dom.sendBtn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
addMessage(text, sender, isError = false) {
|
||||||
|
const messageEl = document.createElement('div');
|
||||||
|
messageEl.classList.add('message', `${sender}-message`);
|
||||||
|
if(isError) messageEl.style.color = 'var(--danger)';
|
||||||
|
|
||||||
|
const p = document.createElement('p');
|
||||||
|
p.textContent = text;
|
||||||
|
messageEl.appendChild(p);
|
||||||
|
|
||||||
|
if (sender === 'assistant' && !isError) {
|
||||||
|
this.addMediaActionButtons(messageEl, text);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.dom.messagesContainer.appendChild(messageEl);
|
||||||
|
this.scrollToBottom();
|
||||||
|
}
|
||||||
|
|
||||||
|
addTypingIndicator() {
|
||||||
|
const indicator = document.createElement('div');
|
||||||
|
indicator.id = 'typing-indicator';
|
||||||
|
indicator.classList.add('message', 'assistant-message', 'typing-indicator');
|
||||||
|
indicator.innerHTML = '<span></span><span></span><span></span>';
|
||||||
|
this.dom.messagesContainer.appendChild(indicator);
|
||||||
|
this.scrollToBottom();
|
||||||
|
}
|
||||||
|
|
||||||
|
removeTypingIndicator() {
|
||||||
|
const indicator = document.getElementById('typing-indicator');
|
||||||
|
if (indicator) indicator.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
async getLocalContent() {
|
||||||
|
const movieEntries = await getFromDB('movies');
|
||||||
|
const seriesEntries = await getFromDB('series');
|
||||||
|
|
||||||
|
const processEntries = (entries, type) => {
|
||||||
|
return entries.flatMap(entry =>
|
||||||
|
(entry.titulos || []).map(titulo => ({ ...titulo, type }))
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const movies = processEntries(movieEntries, 'movie');
|
||||||
|
const series = processEntries(seriesEntries, 'show');
|
||||||
|
|
||||||
|
return { movies, series };
|
||||||
|
}
|
||||||
|
|
||||||
|
async findLocalContent(title) {
|
||||||
|
const { movies, series } = await this.getLocalContent();
|
||||||
|
const searchTerm = title.trim().toLowerCase();
|
||||||
|
|
||||||
|
const allContent = [...movies, ...series];
|
||||||
|
|
||||||
|
let foundContent = allContent.find(m => m.title.toLowerCase() === searchTerm);
|
||||||
|
if (foundContent) return foundContent;
|
||||||
|
|
||||||
|
foundContent = allContent.find(m => m.title.toLowerCase().includes(searchTerm));
|
||||||
|
return foundContent;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getOpenAIResponse() {
|
||||||
|
const apiKey = state.settings.openaiApiKey;
|
||||||
|
if (!apiKey) {
|
||||||
|
return _('chatApiKeyMissing');
|
||||||
|
}
|
||||||
|
|
||||||
|
const systemPrompt = `You are a helpful assistant for a movie and TV show enthusiast named CinePlex Assistant. Your main goal is to provide information about films, series, etc. When you identify a movie or series title in your response, you MUST enclose it in double quotes, for example: "The Matrix". Do not mention the user's local library or your ability to access it.`;
|
||||||
|
|
||||||
|
const messagesForApi = [
|
||||||
|
{ role: 'system', content: systemPrompt },
|
||||||
|
...this.conversationHistory
|
||||||
|
];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('https://api.proxyapi.ru/openai/v1/chat/completions', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${apiKey}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: 'gpt-4o-2024-05-13',
|
||||||
|
messages: messagesForApi,
|
||||||
|
max_tokens: 250,
|
||||||
|
temperature: 0.7
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => ({}));
|
||||||
|
const errorMessage = errorData?.error?.message || errorData?.error || `Error HTTP ${response.status}`;
|
||||||
|
console.error('Proxy API Error Response:', errorData);
|
||||||
|
throw new Error(errorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.choices && data.choices[0] && data.choices[0].message && data.choices[0].message.content) {
|
||||||
|
return data.choices[0].message.content;
|
||||||
|
} else {
|
||||||
|
console.error('Invalid successful response format from proxy:', data);
|
||||||
|
throw new Error(_('chatApiInvalidResponse'));
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('OpenAI Proxy API call failed:', error);
|
||||||
|
showNotification(_('chatApiError'), 'error');
|
||||||
|
return _('chatApiError') + `: ${error.message}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
autoResizeTextarea() {
|
||||||
|
this.dom.input.style.height = 'auto';
|
||||||
|
const scrollHeight = this.dom.input.scrollHeight;
|
||||||
|
if (scrollHeight > 200) {
|
||||||
|
this.dom.input.style.height = '200px';
|
||||||
|
this.dom.input.style.overflowY = 'auto';
|
||||||
|
} else {
|
||||||
|
this.dom.input.style.height = `${scrollHeight}px`;
|
||||||
|
this.dom.input.style.overflowY = 'hidden';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
scrollToBottom() {
|
||||||
|
this.dom.messagesContainer.scrollTop = this.dom.messagesContainer.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
startDrag(e) {
|
||||||
|
if (e.target !== this.dom.header && e.target !== this.dom.header.querySelector('.chat-title')) return;
|
||||||
|
this.isDragging = true;
|
||||||
|
const rect = this.dom.window.getBoundingClientRect();
|
||||||
|
this.offset.x = e.clientX - rect.left;
|
||||||
|
this.offset.y = e.clientY - rect.top;
|
||||||
|
this.dom.header.style.cursor = 'grabbing';
|
||||||
|
}
|
||||||
|
|
||||||
|
drag(e) {
|
||||||
|
if (!this.isDragging) return;
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
let newX = e.clientX - this.offset.x;
|
||||||
|
let newY = e.clientY - this.offset.y;
|
||||||
|
|
||||||
|
const winWidth = this.dom.window.offsetWidth;
|
||||||
|
const winHeight = this.dom.window.offsetHeight;
|
||||||
|
const docWidth = document.documentElement.clientWidth;
|
||||||
|
const docHeight = document.documentElement.clientHeight;
|
||||||
|
|
||||||
|
newX = Math.max(0, Math.min(newX, docWidth - winWidth));
|
||||||
|
newY = Math.max(0, Math.min(newY, docHeight - winHeight));
|
||||||
|
|
||||||
|
this.dom.window.style.left = `${newX}px`;
|
||||||
|
this.dom.window.style.top = `${newY}px`;
|
||||||
|
this.dom.window.style.bottom = 'auto';
|
||||||
|
this.dom.window.style.right = 'auto';
|
||||||
|
}
|
||||||
|
|
||||||
|
stopDrag() {
|
||||||
|
this.isDragging = false;
|
||||||
|
this.dom.header.style.cursor = 'move';
|
||||||
|
}
|
||||||
|
|
||||||
|
async addMediaActionButtons(messageEl, text) {
|
||||||
|
const contentTitles = this.extractContentTitles(text);
|
||||||
|
if (contentTitles.length === 0) return;
|
||||||
|
|
||||||
|
const allContent = [];
|
||||||
|
for (const title of contentTitles) {
|
||||||
|
const content = await this.findLocalContent(title);
|
||||||
|
if (content) {
|
||||||
|
allContent.push(content);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allContent.length > 0) {
|
||||||
|
allContent.forEach(content => {
|
||||||
|
const itemContainer = document.createElement('div');
|
||||||
|
itemContainer.className = 'chat-item-actions';
|
||||||
|
|
||||||
|
const titleEl = document.createElement('span');
|
||||||
|
titleEl.className = 'chat-action-title';
|
||||||
|
titleEl.textContent = content.title;
|
||||||
|
itemContainer.appendChild(titleEl);
|
||||||
|
|
||||||
|
const buttonContainer = document.createElement('div');
|
||||||
|
buttonContainer.className = 'chat-action-buttons';
|
||||||
|
|
||||||
|
const buttons = [
|
||||||
|
{ label: _('moreInfo'), action: 'info', icon: 'ℹ️' },
|
||||||
|
{ label: _('addStream'), action: 'stream', icon: '▶️' },
|
||||||
|
{ label: _('download'), action: 'download', icon: '⬇️' }
|
||||||
|
];
|
||||||
|
|
||||||
|
buttons.forEach(btnInfo => {
|
||||||
|
const button = document.createElement('button');
|
||||||
|
button.innerHTML = `${btnInfo.icon} ${btnInfo.label}`;
|
||||||
|
button.addEventListener('click', () => this.handleMediaAction(btnInfo.action, content, button));
|
||||||
|
buttonContainer.appendChild(button);
|
||||||
|
});
|
||||||
|
itemContainer.appendChild(buttonContainer);
|
||||||
|
messageEl.appendChild(itemContainer);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (allContent.length > 1) {
|
||||||
|
const downloadAllButtonContainer = document.createElement('div');
|
||||||
|
downloadAllButtonContainer.className = 'chat-action-buttons chat-download-all';
|
||||||
|
const downloadAllButton = document.createElement('button');
|
||||||
|
downloadAllButton.innerHTML = `⬇️ ${_('downloadAll')}`;
|
||||||
|
downloadAllButton.addEventListener('click', (e) => {
|
||||||
|
const streams = allContent.map(content => ({ title: content.title, type: content.type }));
|
||||||
|
downloadM3U(streams, e.target);
|
||||||
|
});
|
||||||
|
downloadAllButtonContainer.appendChild(downloadAllButton);
|
||||||
|
messageEl.appendChild(downloadAllButtonContainer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extractContentTitles(text) {
|
||||||
|
const matches = text.match(/"([^"]+)"/g);
|
||||||
|
if (!matches) return [];
|
||||||
|
return matches.map(match => match.substring(1, match.length - 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
handleMediaAction(action, content, buttonEl) {
|
||||||
|
switch (action) {
|
||||||
|
case 'info':
|
||||||
|
showItemDetails(Number(content.id), content.type);
|
||||||
|
this.close();
|
||||||
|
break;
|
||||||
|
case 'stream':
|
||||||
|
addStreamToList(content.title, content.type, buttonEl);
|
||||||
|
break;
|
||||||
|
case 'download':
|
||||||
|
downloadM3U(content.title, content.type, buttonEl);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
showNotification(`Acción desconocida: ${action}`, 'warning');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -2,4 +2,5 @@ export const config = {
|
|||||||
defaultApiKey: '4e44d9029b1270a757cddc766a1bcb63',
|
defaultApiKey: '4e44d9029b1270a757cddc766a1bcb63',
|
||||||
dbName: 'PlexDB',
|
dbName: 'PlexDB',
|
||||||
dbVersion: 9,
|
dbVersion: 9,
|
||||||
|
defaultOpenaiApiKey: 'sk-r5HgSL5o102oGaXEuF13P5sNqpLtDVDq',
|
||||||
};
|
};
|
@ -3,6 +3,7 @@ import { config } from './config.js';
|
|||||||
import { initDB, getFromDB } from './db.js';
|
import { initDB, getFromDB } from './db.js';
|
||||||
import { MusicPlayer } from './musicPlayer.js';
|
import { MusicPlayer } from './musicPlayer.js';
|
||||||
import { ActivityViewer } from './activityViewer.js';
|
import { ActivityViewer } from './activityViewer.js';
|
||||||
|
import { Chat } from './chat.js';
|
||||||
import { setupEventListeners } from './eventListeners.js';
|
import { setupEventListeners } from './eventListeners.js';
|
||||||
import { fetchAllProviders, renderProviders, getRegions } from './providers.js';
|
import { fetchAllProviders, renderProviders, getRegions } from './providers.js';
|
||||||
import { loadInitialContent, initializeFavorites, initializeUserData, loadLocalContent, applyTheme, applyHeroVisibility } from './ui.js';
|
import { loadInitialContent, initializeFavorites, initializeUserData, loadLocalContent, applyTheme, applyHeroVisibility } from './ui.js';
|
||||||
@ -15,12 +16,10 @@ async function loadSettings() {
|
|||||||
state.settings = { ...state.settings, ...settingsData[0] };
|
state.settings = { ...state.settings, ...settingsData[0] };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure a default region is set if none exists
|
|
||||||
if (!state.settings.watchRegion) {
|
if (!state.settings.watchRegion) {
|
||||||
state.settings.watchRegion = 'US';
|
state.settings.watchRegion = 'US';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure language is always set, fallback to UI language
|
|
||||||
if (!state.settings.language) {
|
if (!state.settings.language) {
|
||||||
state.settings.language = chrome.i18n.getUILanguage().split('-')[0];
|
state.settings.language = chrome.i18n.getUILanguage().split('-')[0];
|
||||||
}
|
}
|
||||||
@ -36,7 +35,6 @@ async function loadSettings() {
|
|||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Could not load settings from DB, using defaults.", error);
|
console.error("Could not load settings from DB, using defaults.", error);
|
||||||
// Fallback to defaults in case of any error
|
|
||||||
state.settings.watchRegion = 'US';
|
state.settings.watchRegion = 'US';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -54,7 +52,7 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||||||
state.musicPlayer = new MusicPlayer();
|
state.musicPlayer = new MusicPlayer();
|
||||||
state.musicPlayer.setDB(state.db);
|
state.musicPlayer.setDB(state.db);
|
||||||
state.activityViewer = new ActivityViewer(document.getElementById('activityViewerModal'));
|
state.activityViewer = new ActivityViewer(document.getElementById('activityViewerModal'));
|
||||||
|
state.chat = new Chat();
|
||||||
|
|
||||||
initializeFavorites();
|
initializeFavorites();
|
||||||
initializeUserData();
|
initializeUserData();
|
||||||
|
59
js/php-script-generator.js
Normal file
59
js/php-script-generator.js
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
import { showNotification, _ } from './utils.js';
|
||||||
|
|
||||||
|
export const phpScriptGenerator = (() => {
|
||||||
|
let dom = {};
|
||||||
|
|
||||||
|
function cacheDom() {
|
||||||
|
const settingsModal = document.getElementById('settingsModal');
|
||||||
|
if (!settingsModal) return false;
|
||||||
|
|
||||||
|
dom.secretKeyCheck = settingsModal.querySelector('#phpSecretKeyCheck');
|
||||||
|
dom.secretKey = settingsModal.querySelector('#phpSecretKey');
|
||||||
|
dom.savePath = settingsModal.querySelector('#phpSavePath');
|
||||||
|
dom.filename = settingsModal.querySelector('#phpFilename');
|
||||||
|
dom.fileActionAppendRadio = settingsModal.querySelector('#phpFileActionAppend');
|
||||||
|
dom.generatedCode = settingsModal.querySelector('#generatedPhpCode');
|
||||||
|
dom.generateBtn = settingsModal.querySelector('#generatePhpScriptBtn');
|
||||||
|
dom.copyBtn = settingsModal.querySelector('#copyPhpScriptBtn');
|
||||||
|
|
||||||
|
return dom.generateBtn && dom.copyBtn;
|
||||||
|
}
|
||||||
|
|
||||||
|
function init() {
|
||||||
|
if (!cacheDom()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
dom.generateBtn.addEventListener('click', generatePhpScript);
|
||||||
|
dom.copyBtn.addEventListener('click', copyScript);
|
||||||
|
}
|
||||||
|
|
||||||
|
function generatePhpScript() {
|
||||||
|
const useSecretKey = dom.secretKeyCheck.checked;
|
||||||
|
const secretKey = dom.secretKey.value.trim();
|
||||||
|
const savePath = dom.savePath.value.trim();
|
||||||
|
const filename = dom.filename.value.trim() || 'CinePlex_Playlist.m3u';
|
||||||
|
const appendToFile = dom.fileActionAppendRadio.checked;
|
||||||
|
|
||||||
|
let script = `<?php\nheader('Content-Type: application/json');\nheader('Access-Control-Allow-Origin: *');\nheader('Access-Control-Allow-Methods: POST, OPTIONS');\nheader('Access-Control-Allow-Headers: Content-Type, Origin, X-Secret-Key');\n\nif ($_SERVER['REQUEST_METHOD'] == 'OPTIONS') {\n http_response_code(200);\n exit(0);\n}\n\ndefine('SAVE_DIRECTORY', '${savePath.replace(/'/g, "\\'")}');\ndefine('FILENAME', '${filename.replace(/'/g, "\\'")}');\ndefine('FILE_ACTION_APPEND', ${appendToFile ? 'true' : 'false'});\n${useSecretKey ? `define('SECRET_KEY', '${secretKey.replace(/'/g, "\\'")}');` : ''}\n\nfunction sendResponse($success, $message, $filename = '', $http_code = 200) {\n if (!$success && $http_code === 200) {\n $http_code = 400;\n }\n http_response_code($http_code);\n echo json_encode(['success' => $success, 'message' => $message, 'filename' => $filename]);\n exit;\n}\n`;
|
||||||
|
if (useSecretKey) {
|
||||||
|
script += `\n$auth_key = isset($_SERVER['HTTP_X_SECRET_KEY']) ? $_SERVER['HTTP_X_SECRET_KEY'] : '';\nif (!defined('SECRET_KEY') || SECRET_KEY === '' || $auth_key !== SECRET_KEY) {\n sendResponse(false, 'Acceso no autorizado. Clave secreta inválida o no proporcionada.', '', 403);\n}\n`;
|
||||||
|
}
|
||||||
|
script += `\n$json_data = file_get_contents('php://input');\n$data = json_decode($json_data, true);\n\nif (json_last_error() !== JSON_ERROR_NONE) {\n sendResponse(false, 'Error: Datos JSON inválidos.');\n}\n\nif (!isset($data['streams']) || !is_array($data['streams']) || empty($data['streams'])) {\n sendResponse(false, 'Error: El JSON debe contener un array "streams" no vacío.');\n}\n\n$save_dir = SAVE_DIRECTORY !== '' ? rtrim(SAVE_DIRECTORY, '/\\') : __DIR__;\n\nif (!is_dir($save_dir) || !is_writable($save_dir)) {\n sendResponse(false, 'Error del servidor: El directorio de destino no existe o no tiene permisos de escritura.', '', 500);\n}\n\n$safe_filename = preg_replace('/[^\\w\\s._-]/', '', basename(FILENAME));\n$safe_filename = preg_replace('/\\s+/', '_', $safe_filename);\n$target_path = $save_dir . DIRECTORY_SEPARATOR . $safe_filename;\n\n$content_to_write = "";\n\nif (FILE_ACTION_APPEND) {\n $file_exists = file_exists($target_path);\n if (!$file_exists) {\n $content_to_write .= "#EXTM3U\\n";\n }\n foreach ($data['streams'] as $stream) {\n if (isset($stream['extinf'], $stream['url'])) {\n $content_to_write .= trim($stream['extinf']) . "\\n";\n $content_to_write .= trim($stream['url']) . "\\n";\n }\n }\n if (file_put_contents($target_path, $content_to_write, FILE_APPEND | LOCK_EX) !== false) {\n sendResponse(true, 'Streams añadidos correctamente al archivo.', $safe_filename, 200);\n } else {\n sendResponse(false, 'Error del servidor: No se pudo añadir contenido al archivo.', '', 500);\n }\n} else { // Overwrite mode\n $content_to_write = "#EXTM3U\\n";\n foreach ($data['streams'] as $stream) {\n if (isset($stream['extinf'], $stream['url'])) {\n $content_to_write .= trim($stream['extinf']) . "\\n";\n $content_to_write .= trim($stream['url']) . "\\n";\n }\n }\n if (file_put_contents($target_path, $content_to_write, LOCK_EX) !== false) {\n sendResponse(true, 'Archivo de streams sobrescrito correctamente.', $safe_filename, 201);\n } else {\n sendResponse(false, 'Error del servidor: No se pudo escribir el archivo.', '', 500);\n }\n}\n?>`;
|
||||||
|
dom.generatedCode.value = script;
|
||||||
|
showNotification(_("scriptGenerated"), "success");
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyScript() {
|
||||||
|
if (!dom.generatedCode.value || dom.generatedCode.value.trim() === '') {
|
||||||
|
showNotification(_("errorGeneratingScript"), "warning");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
navigator.clipboard.writeText(dom.generatedCode.value).then(() => {
|
||||||
|
showNotification(_("scriptCopied"), "success");
|
||||||
|
}).catch(err => {
|
||||||
|
showNotification(_("errorCopyingScript"), "error");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { init };
|
||||||
|
})();
|
@ -87,9 +87,11 @@ async function parseAndStoreSectionItems(contentXml, storeName, serverData) {
|
|||||||
if (type === 'movie' || type === 'show') {
|
if (type === 'movie' || type === 'show') {
|
||||||
const itemSelector = type === 'movie' ? 'Video' : 'Directory';
|
const itemSelector = type === 'movie' ? 'Video' : 'Directory';
|
||||||
items = Array.from(contentXml.querySelectorAll(itemSelector)).map(el => ({
|
items = Array.from(contentXml.querySelectorAll(itemSelector)).map(el => ({
|
||||||
|
id: el.getAttribute('ratingKey'),
|
||||||
title: el.getAttribute('title'),
|
title: el.getAttribute('title'),
|
||||||
year: el.getAttribute('year'),
|
year: el.getAttribute('year'),
|
||||||
genre: el.querySelector('Genre')?.getAttribute('tag') || _('noGenre')
|
genre: el.querySelector('Genre')?.getAttribute('tag') || _('noGenre'),
|
||||||
|
type: type
|
||||||
}));
|
}));
|
||||||
} else if (type === 'artist' || type === 'photo') {
|
} else if (type === 'artist' || type === 'photo') {
|
||||||
items = Array.from(contentXml.querySelectorAll('Directory')).map(el => ({
|
items = Array.from(contentXml.querySelectorAll('Directory')).map(el => ({
|
||||||
|
@ -1,3 +1,5 @@
|
|||||||
|
import { config } from './config.js';
|
||||||
|
|
||||||
export const state = {
|
export const state = {
|
||||||
currentPage: 1,
|
currentPage: 1,
|
||||||
currentView: 'movies',
|
currentView: 'movies',
|
||||||
@ -5,6 +7,7 @@ export const state = {
|
|||||||
settings: {
|
settings: {
|
||||||
id: 'user_settings',
|
id: 'user_settings',
|
||||||
apiKey: '',
|
apiKey: '',
|
||||||
|
openaiApiKey: config.defaultOpenaiApiKey || '',
|
||||||
theme: 'dark',
|
theme: 'dark',
|
||||||
showHero: true,
|
showHero: true,
|
||||||
language: 'es',
|
language: 'es',
|
||||||
@ -46,6 +49,7 @@ export const state = {
|
|||||||
isScanningJellyfin: false,
|
isScanningJellyfin: false,
|
||||||
musicPlayer: null,
|
musicPlayer: null,
|
||||||
activityViewer: null,
|
activityViewer: null,
|
||||||
|
chat: null,
|
||||||
currentContentFetchController: null,
|
currentContentFetchController: null,
|
||||||
plexScanAbortController: null,
|
plexScanAbortController: null,
|
||||||
aceEditor: null,
|
aceEditor: null,
|
||||||
|
45
js/ui.js
45
js/ui.js
@ -1462,48 +1462,7 @@ export async function addStreamToList(title, type, buttonElement = null) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function downloadM3U(title, type, buttonElement = null) {
|
export async function downloadM3U(items, buttonElement = null) { if (state.isDownloadingM3U) return; state.isDownloadingM3U = true; let originalButtonContent = null; if (buttonElement) { originalButtonContent = buttonElement.innerHTML; buttonElement.disabled = true; buttonElement.innerHTML = `<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>`; } const itemsArray = Array.isArray(items) ? items : [{ title: items, type: arguments[1] }]; const collectiveTitle = itemsArray.length > 1 ? 'CinePlex_Playlist' : itemsArray[0].title; showNotification(_('generatingM3U', collectiveTitle), "info"); try { let m3uContent = "#EXTM3U\n"; let streamsFound = 0; for (const item of itemsArray) { try { const streamData = await fetchAllAvailableStreams(item.title, item.type); if (streamData.success && streamData.streams.length > 0) { streamsFound += streamData.streams.length; streamData.streams.forEach(stream => { m3uContent += `${stream.extinf}\n${stream.url}\n`; }); } } catch (error) { console.warn(`Could not fetch streams for ${item.title}:`, error); } } if (streamsFound === 0) throw new Error(_('noStreamsFoundForSelection')); const blob = new Blob([m3uContent], { type: "audio/x-mpegurl;charset=utf-8" }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `${collectiveTitle.replace(/[^a-z0-9]/gi, '_')}.m3u`; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); showNotification(_('m3uDownloaded', collectiveTitle), 'success'); } catch (error) { showNotification(_('errorGeneratingM3U', error.message), "error"); } finally { state.isDownloadingM3U = false; if (buttonElement && originalButtonContent) { buttonElement.innerHTML = originalButtonContent; buttonElement.disabled = false; } }}
|
||||||
if (state.isDownloadingM3U) return;
|
|
||||||
state.isDownloadingM3U = true;
|
|
||||||
let originalButtonContent = null;
|
|
||||||
if (buttonElement) {
|
|
||||||
originalButtonContent = buttonElement.innerHTML;
|
|
||||||
buttonElement.disabled = true;
|
|
||||||
buttonElement.innerHTML = `<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
showNotification(_('generatingM3U', title), "info");
|
|
||||||
|
|
||||||
try {
|
|
||||||
const streamData = await fetchAllAvailableStreams(title, type);
|
|
||||||
|
|
||||||
if (!streamData.success || streamData.streams.length === 0) throw new Error(streamData.message);
|
|
||||||
|
|
||||||
let m3uContent = "#EXTM3U\n";
|
|
||||||
streamData.streams.forEach(stream => {
|
|
||||||
m3uContent += `${stream.extinf}\n${stream.url}\n`;
|
|
||||||
});
|
|
||||||
|
|
||||||
const blob = new Blob([m3uContent], { type: "audio/x-mpegurl;charset=utf-8" });
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
const a = document.createElement('a');
|
|
||||||
a.href = url;
|
|
||||||
a.download = `${title.replace(/[^a-z0-9]/gi, '_')}.m3u`;
|
|
||||||
document.body.appendChild(a);
|
|
||||||
a.click();
|
|
||||||
document.body.removeChild(a);
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
showNotification(_('m3uDownloaded', title), 'success');
|
|
||||||
} catch (error) {
|
|
||||||
showNotification(_('errorGeneratingM3U', error.message), "error");
|
|
||||||
} finally {
|
|
||||||
state.isDownloadingM3U = false;
|
|
||||||
if (buttonElement && originalButtonContent) {
|
|
||||||
buttonElement.innerHTML = originalButtonContent;
|
|
||||||
buttonElement.disabled = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function showTrailer(key) {
|
export function showTrailer(key) {
|
||||||
const lightbox = document.getElementById('video-lightbox');
|
const lightbox = document.getElementById('video-lightbox');
|
||||||
@ -1563,6 +1522,7 @@ export function activateSettingsTab(tabId) {
|
|||||||
|
|
||||||
export function openSettingsModal() {
|
export function openSettingsModal() {
|
||||||
document.getElementById('tmdbApiKey').value = state.settings.apiKey;
|
document.getElementById('tmdbApiKey').value = state.settings.apiKey;
|
||||||
|
document.getElementById('openaiApiKey').value = state.settings.openaiApiKey || '';
|
||||||
document.getElementById('phpScriptUrl').value = state.settings.phpScriptUrl || '';
|
document.getElementById('phpScriptUrl').value = state.settings.phpScriptUrl || '';
|
||||||
document.getElementById('lightModeToggle').checked = state.settings.theme === 'light';
|
document.getElementById('lightModeToggle').checked = state.settings.theme === 'light';
|
||||||
document.getElementById('showHeroToggle').checked = state.settings.showHero;
|
document.getElementById('showHeroToggle').checked = state.settings.showHero;
|
||||||
@ -1589,6 +1549,7 @@ export async function saveSettings() {
|
|||||||
const newSettings = {
|
const newSettings = {
|
||||||
id: 'user_settings',
|
id: 'user_settings',
|
||||||
apiKey: document.getElementById('tmdbApiKey').value.trim(),
|
apiKey: document.getElementById('tmdbApiKey').value.trim(),
|
||||||
|
openaiApiKey: document.getElementById('openaiApiKey').value.trim(),
|
||||||
theme: document.getElementById('lightModeToggle').checked ? 'light' : 'dark',
|
theme: document.getElementById('lightModeToggle').checked ? 'light' : 'dark',
|
||||||
showHero: document.getElementById('showHeroToggle').checked,
|
showHero: document.getElementById('showHeroToggle').checked,
|
||||||
phpScriptUrl: document.getElementById('phpScriptUrl').value.trim(),
|
phpScriptUrl: document.getElementById('phpScriptUrl').value.trim(),
|
||||||
|
39
plex.html
39
plex.html
@ -331,8 +331,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button id="fab-music-player" class="fab-btn" style="display: none;" title="__MSG_openMusicPlayer__"><i class="fas fa-music"></i></button>
|
|
||||||
|
|
||||||
<div class="spinner" id="spinner"></div>
|
<div class="spinner" id="spinner"></div>
|
||||||
|
|
||||||
<div class="modal fade" id="activityViewerModal" tabindex="-1" aria-labelledby="activityViewerModalLabel" aria-hidden="true" role="dialog" aria-modal="true">
|
<div class="modal fade" id="activityViewerModal" tabindex="-1" aria-labelledby="activityViewerModalLabel" aria-hidden="true" role="dialog" aria-modal="true">
|
||||||
@ -400,6 +398,11 @@
|
|||||||
<label for="tmdbApiKey" class="form-label">__MSG_settingsTmdbApiLabel__</label>
|
<label for="tmdbApiKey" class="form-label">__MSG_settingsTmdbApiLabel__</label>
|
||||||
<input type="password" class="form-control" id="tmdbApiKey" placeholder="__MSG_settingsTmdbApiPlaceholder__">
|
<input type="password" class="form-control" id="tmdbApiKey" placeholder="__MSG_settingsTmdbApiPlaceholder__">
|
||||||
</div>
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="openaiApiKey" class="form-label">__MSG_settingsOpenAiApiLabel__</label>
|
||||||
|
<label for="openaiApiKey" class="form-label">__MSG_openaiApiKey__</label>
|
||||||
|
<input type="password" class="form-control" id="openaiApiKey" placeholder="__MSG_settingsOpenAiApiPlaceholder__">
|
||||||
|
</div>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="region-filter" class="form-label">__MSG_settingsRegionLabel__</label>
|
<label for="region-filter" class="form-label">__MSG_settingsRegionLabel__</label>
|
||||||
<select class="form-control filter-select" id="region-filter">
|
<select class="form-control filter-select" id="region-filter">
|
||||||
@ -742,6 +745,37 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="fab-container">
|
||||||
|
<button id="fab-music-player" class="fab-btn" style="display: none;" title="__MSG_openMusicPlayer__"><i class="fas fa-music"></i></button>
|
||||||
|
<div id="chat-fab" class="chat-fab" data-i18n-title="chatOpen" title="Open Chat">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="28" height="28">
|
||||||
|
<path d="M20 2H4C2.9 2 2 2.9 2 4V22L6 18H20C21.1 18 22 17.1 22 16V4C22 2.9 21.1 2 20 2ZM20 16H5.2L4 17.2V4H20V16Z"/>
|
||||||
|
<path d="M7.5 8.5h9v2h-9zM7.5 11.5h6v2h-6z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="chat-window" class="chat-window" style="display: none;">
|
||||||
|
<div class="chat-header">
|
||||||
|
<h3 class="chat-title" data-i18n="chatTitle">AI Assistant</h3>
|
||||||
|
<button id="chat-close-btn" class="chat-close-btn" data-i18n-title="chatClose" title="Close">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="20" height="20">
|
||||||
|
<path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="chat-messages" class="chat-messages">
|
||||||
|
</div>
|
||||||
|
<form id="chat-input-form" class="chat-input-container">
|
||||||
|
<textarea id="chat-input" class="chat-input" data-i18n-placeholder="chatPlaceholder" placeholder="Type your message..." rows="1"></textarea>
|
||||||
|
<button id="chat-send-btn" type="submit" class="chat-send-btn" data-i18n-title="chatSend" title="Send">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="24" height="24">
|
||||||
|
<path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script src="lib/bootstrap.bundle.min.js"></script>
|
<script src="lib/bootstrap.bundle.min.js"></script>
|
||||||
<script src="lib/gsap.min.js"></script>
|
<script src="lib/gsap.min.js"></script>
|
||||||
<script src="lib/ScrollTrigger.min.js"></script>
|
<script src="lib/ScrollTrigger.min.js"></script>
|
||||||
@ -752,6 +786,7 @@
|
|||||||
<script type="module" src="js/main.js"></script>
|
<script type="module" src="js/main.js"></script>
|
||||||
<script type="module" src="js/activityViewer.js"></script>
|
<script type="module" src="js/activityViewer.js"></script>
|
||||||
<script type="module" src="js/m3u-generator.js"></script>
|
<script type="module" src="js/m3u-generator.js"></script>
|
||||||
|
<script type="module" src="js/chat.js"></script>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
|
Loading…
x
Reference in New Issue
Block a user