[call-me] - add support for multilanguages, update dep

This commit is contained in:
Miroslav Pejic
2026-02-07 20:44:09 +01:00
parent 9b74d12821
commit 0d9c1e8660
13 changed files with 1211 additions and 27 deletions
+86
View File
@@ -0,0 +1,86 @@
# i18n Support - Quick Start
The Call-me application now supports multiple languages! 🌍
## Supported Languages
- 🇬🇧 English (en)
- 🇪🇸 Spanish (es)
- 🇫🇷 French (fr)
- 🇮🇹 Italian (it)
- 🇩🇪 German (de)
## Features
- ✅ Automatic language detection from browser
- ✅ Language persistence in localStorage
- ✅ Real-time language switching
- ✅ JSON-based translations
- ✅ RESTful translations API
## Usage
### For Users
1. Open the application
2. Click the sidebar button (users icon)
3. Go to "Settings" tab
4. Select your preferred language from the dropdown (with flag emojis)
### For Developers
**Add translation to HTML:**
```html
<button data-i18n="signIn.button">Sign In</button>
<input data-i18n-placeholder="signIn.username" placeholder="Enter username" />
```
**Use in JavaScript:**
```javascript
const text = t('signIn.button'); // Returns: "Sign In"
const message = t('room.userJoined', { username: 'John' }); // Returns: "John joined the call"
```
**API Endpoint:**
```bash
GET /translations/:locale
# Example: GET /translations/es
```
## Files Structure
```
locales/
├── en.json # English translations
├── es.json # Spanish translations
├── fr.json # French translations
├── it.json # Italian translations
└── de.json # German translations
public/
└── i18n.js # Client-side i18n library
doc/
└── i18n.md # Complete documentation
```
## Quick Test
```bash
# Test English translations
curl http://localhost:8000/translations/en
# Test Spanish translations
curl http://localhost:8000/translations/es
```
## Documentation
For complete documentation, see [doc/i18n.md](doc/i18n.md)
## Based On
Implementation follows the [Crowdin Node.js i18n guide](https://crowdin.com/blog/nodejs-i18n-and-localization)
+38
View File
@@ -13,6 +13,7 @@ const helmet = require('helmet');
const path = require('path');
const yaml = require('js-yaml');
const swaggerUi = require('swagger-ui-express');
const i18n = require('i18n');
const packageJson = require('../package.json');
// Logs
@@ -100,6 +101,20 @@ const corsOptions = {
// Create Express application
const app = express();
// Configure i18n
i18n.configure({
locales: ['en', 'es', 'fr', 'it', 'de'],
defaultLocale: 'en',
directory: path.join(__dirname, '../locales'),
objectNotation: true,
updateFiles: false,
syncFiles: false,
api: {
__: 'translate',
__n: 'translateN',
},
});
// Server configurations
const port = process.env.PORT || 4000;
const host = process.env.HOST || `http://localhost:${port}`;
@@ -199,6 +214,29 @@ app.get('/randomImage', async (req, res) => {
}
});
// Get translations for a specific language
app.get('/translations/:locale', (req, res) => {
try {
const locale = req.params.locale || 'en';
const validLocales = ['en', 'es', 'fr', 'it', 'de'];
if (!validLocales.includes(locale)) {
return res.status(400).json({ error: 'Invalid locale' });
}
const translationPath = path.join(__dirname, '../locales', `${locale}.json`);
const translations = JSON.parse(fs.readFileSync(translationPath, 'utf8'));
res.json({
locale: locale,
translations: translations,
});
} catch (error) {
log.error('Error fetching translations', error.message);
res.status(500).json({ error: 'Error loading translations' });
}
});
// Direct Join room
app.get('/join/', (req, res) => {
if (Object.keys(req.query).length > 0) {
+255
View File
@@ -0,0 +1,255 @@
# Internationalization (i18n) Implementation Guide
This document explains how the internationalization (i18n) system is implemented in the Call-me application.
## Overview
The application now supports multiple languages using a JSON-based translation system. The implementation follows best practices from the Crowdin Node.js i18n guide.
## Supported Languages
- **English (en)** - Default
- **Spanish (es)**
- **French (fr)**
- **Italian (it)**
- **German (de)**
## Architecture
### Backend (Server-side)
#### Dependencies
- **i18n**: Node.js internationalization library
#### Configuration
The i18n module is configured in [app/server.js](app/server.js):
```javascript
i18n.configure({
locales: ['en', 'es', 'fr', 'it', 'de'],
defaultLocale: 'en',
directory: path.join(__dirname, '../locales'),
objectNotation: true,
updateFiles: false,
syncFiles: false,
});
```
#### API Endpoint
**GET** `/translations/:locale`
Returns translations for the specified locale:
```json
{
"locale": "en",
"translations": {
"appName": "Call-me",
"signIn": {
"title": "Sign In",
"username": "Enter username"
}
}
}
```
### Frontend (Client-side)
#### Core Files
- **[public/i18n.js](public/i18n.js)**: Client-side i18n library
- **[locales/\*.json](locales/)**: Translation files
#### Key Features
1. **Automatic Language Detection**
- Checks localStorage for saved preference
- Falls back to browser language
- Defaults to English if unsupported
2. **Translation Function**
```javascript
t('signIn.username'); // Returns: "Enter username"
t('room.userJoined', { username: 'John' }); // Returns: "John joined the call"
```
3. **HTML Attributes**
- `data-i18n`: Translates element text content
- `data-i18n-placeholder`: Translates input placeholder
- `data-i18n-title`: Translates element title/tooltip
4. **Language Switcher**
Located in the Settings tab, allows users to change language in real-time. Each language option is displayed with its corresponding flag emoji (🇬🇧 English, 🇪🇸 Español, 🇫🇷 Français, 🇮🇹 Italiano, 🇩🇪 Deutsch) for easy visual identification.
## Translation Files Structure
All translation files are located in the `locales/` directory:
```
locales/
├── en.json # English
├── es.json # Spanish
├── fr.json # French
├── it.json # Italian
└── de.json # German
```
### JSON Structure
```json
{
"appName": "Call-me",
"appTitle": "Call-me - Instant Video Calls",
"signIn": {
"title": "Sign In",
"username": "Enter username",
"button": "Sign In"
},
"room": {
"users": "Users",
"chat": "Chat"
},
"settings": {
"language": "Language",
"selectLanguage": "Select Language"
}
}
```
## Adding New Languages
1. Create a new JSON file in `locales/` directory (e.g., `locales/pt.json` for Portuguese)
2. Copy the structure from `locales/en.json` and translate all values
3. Update the server configuration in `app/server.js`:
```javascript
i18n.configure({
locales: ['en', 'es', 'fr', 'it', 'de', 'pt'], // Add 'pt'
// ...
});
```
4. Update the frontend language selector in `public/i18n.js`:
```javascript
const supportedLocales = ['en', 'es', 'fr', 'it', 'de', 'pt']; // Add 'pt'
```
5. Add the option to the language selector in `public/index.html` with the appropriate flag emoji:
```html
<option value="pt">🇵🇹 Português</option>
```
## Adding New Translation Keys
1. Add the key to all language files in `locales/`:
```json
{
"newSection": {
"newKey": "Translation text"
}
}
```
2. Use in HTML with data attributes:
```html
<button data-i18n="newSection.newKey">Default Text</button>
```
3. Or use in JavaScript:
```javascript
const text = t('newSection.newKey');
```
## Dynamic Text Replacement
For translations with dynamic values, use placeholder syntax:
**Translation file:**
```json
{
"room": {
"userJoined": "__username__ joined the call"
}
}
```
**Usage:**
```javascript
const message = t('room.userJoined', { username: 'Alice' });
// Result: "Alice joined the call"
```
## Best Practices
1. **Key Naming**: Use dot notation for nested keys (e.g., `settings.language`)
2. **Consistency**: Keep the same structure across all language files
3. **Placeholders**: Use `__placeholder__` format for dynamic values
4. **Fallbacks**: Always provide English translations as fallback
5. **Context**: Group related translations under common parent keys
## Testing
To test the implementation:
1. Start the server:
```bash
npm start
```
2. Open the application in a browser
3. Navigate to Settings tab
4. Select different languages from the dropdown
5. Verify that UI elements update correctly
## Integration with Crowdin
To use with Crowdin for collaborative translation:
1. Install Crowdin CLI: `npm install -g @crowdin/cli`
2. Create a `crowdin.yml` configuration file
3. Upload source files: `crowdin upload sources`
4. Download translations: `crowdin download`
## API Documentation
The translations endpoint is documented in the Swagger API documentation under `/api/v1/docs`.
## Troubleshooting
**Issue**: Translations not loading
- Check browser console for errors
- Verify the locale file exists in `locales/` directory
- Ensure the API endpoint is accessible
**Issue**: Some text not translating
- Verify the HTML element has the correct `data-i18n` attribute
- Check that the translation key exists in the locale file
- Ensure `translatePage()` is called after DOM updates
**Issue**: Language not persisting
- Check browser localStorage
- Ensure the locale is in the `supportedLocales` array
## Future Enhancements
- Add more languages (Portuguese, Chinese, Japanese, etc.)
- Implement pluralization support
- Add date/time localization
- Support RTL languages (Arabic, Hebrew)
- Add translation management UI
- Integrate with professional translation services
## Resources
- [i18n npm package](https://www.npmjs.com/package/i18n)
- [Crowdin Node.js i18n Guide](https://crowdin.com/blog/nodejs-i18n-and-localization)
- [IANA Language Subtag Registry](https://www.iana.org/assignments/language-subtag-registry)
+77
View File
@@ -0,0 +1,77 @@
{
"appName": "Call-me",
"appTitle": "Call-me - Sofortige Videoanrufe",
"appDescription": "Ihre erste Wahl für sofortige Videoanrufe!",
"signIn": {
"title": "Anmelden",
"username": "Benutzernamen eingeben",
"button": "Anmelden",
"enterUsername": "Bitte geben Sie Ihren Benutzernamen ein"
},
"room": {
"sessionTime": "Sitzungszeit",
"localUsername": "Sie",
"remoteUsername": "Remote-Benutzer",
"waiting": "Warte darauf, dass jemand beitritt...",
"connecting": "Verbinden...",
"userJoined": "__username__ ist dem Anruf beigetreten",
"userLeft": "__username__ hat den Anruf verlassen",
"users": "Benutzer",
"chat": "Chat",
"searchUsers": "Benutzer suchen..."
},
"controls": {
"microphone": "Mikrofon",
"camera": "Kamera",
"screenShare": "Bildschirm Teilen",
"endCall": "Anruf Beenden",
"settings": "Einstellungen",
"fullscreen": "Vollbild"
},
"messages": {
"microphoneEnabled": "Mikrofon aktiviert",
"microphoneDisabled": "Mikrofon deaktiviert",
"cameraEnabled": "Kamera aktiviert",
"cameraDisabled": "Kamera deaktiviert",
"screenShareStarted": "Bildschirmfreigabe gestartet",
"screenShareStopped": "Bildschirmfreigabe beendet",
"callEnded": "Anruf beendet",
"connectionFailed": "Verbindung fehlgeschlagen",
"permissionDenied": "Berechtigung verweigert",
"error": "Ein Fehler ist aufgetreten",
"copied": "In die Zwischenablage kopiert",
"invalidPassword": "Ungültiges Passwort"
},
"errors": {
"noUsername": "Benutzername erforderlich",
"connectionLost": "Verbindung verloren",
"mediaDevices": "Zugriff auf Mediengeräte nicht möglich",
"noSupport": "Ihr Browser unterstützt diese Funktion nicht"
},
"settings": {
"language": "Sprache",
"selectLanguage": "Sprache Auswählen",
"audioInput": "Mikrofon",
"videoInput": "Kamera",
"audioOutput": "Lautsprecher",
"resolution": "Videoauflösung",
"save": "Speichern",
"cancel": "Abbrechen",
"settings": "Einstellungen",
"testDevices": "Geräte Testen",
"refresh": "Aktualisieren",
"saveMessages": "Nachrichten Speichern",
"clearAll": "Alles Löschen"
},
"chat": {
"addEmoji": "Emoji hinzufügen",
"typeMessage": "Nachricht eingeben...",
"sendMessage": "Nachricht senden"
},
"api": {
"unauthorized": "Nicht autorisierter Zugriff",
"invalidApiKey": "Ungültiger API-Schlüssel",
"serverError": "Serverfehler",
"notFound": "Ressource nicht gefunden"
}
}
+77
View File
@@ -0,0 +1,77 @@
{
"appName": "Call-me",
"appTitle": "Call-me - Instant Video Calls",
"appDescription": "Your Go-To for Instant Video Calls!",
"signIn": {
"title": "Sign In",
"username": "Enter username",
"button": "Sign In",
"enterUsername": "Please enter your username"
},
"room": {
"sessionTime": "Session Time",
"localUsername": "You",
"remoteUsername": "Remote User",
"waiting": "Waiting for someone to join...",
"connecting": "Connecting...",
"userJoined": "__username__ joined the call",
"userLeft": "__username__ left the call",
"users": "Users",
"chat": "Chat",
"searchUsers": "Search users..."
},
"controls": {
"microphone": "Microphone",
"camera": "Camera",
"screenShare": "Share Screen",
"endCall": "End Call",
"settings": "Settings",
"fullscreen": "Fullscreen"
},
"messages": {
"microphoneEnabled": "Microphone enabled",
"microphoneDisabled": "Microphone disabled",
"cameraEnabled": "Camera enabled",
"cameraDisabled": "Camera disabled",
"screenShareStarted": "Screen sharing started",
"screenShareStopped": "Screen sharing stopped",
"callEnded": "Call ended",
"connectionFailed": "Connection failed",
"permissionDenied": "Permission denied",
"error": "An error occurred",
"copied": "Copied to clipboard",
"invalidPassword": "Invalid password"
},
"errors": {
"noUsername": "Username is required",
"connectionLost": "Connection lost",
"mediaDevices": "Could not access media devices",
"noSupport": "Your browser does not support this feature"
},
"settings": {
"language": "Language",
"selectLanguage": "Select Language",
"audioInput": "Microphone",
"videoInput": "Camera",
"audioOutput": "Speaker",
"resolution": "Video Resolution",
"save": "Save",
"cancel": "Cancel",
"settings": "Settings",
"testDevices": "Test Devices",
"refresh": "Refresh",
"saveMessages": "Save Messages",
"clearAll": "Clear All"
},
"chat": {
"addEmoji": "Add emoji",
"typeMessage": "Type a message...",
"sendMessage": "Send message"
},
"api": {
"unauthorized": "Unauthorized access",
"invalidApiKey": "Invalid API key",
"serverError": "Server error",
"notFound": "Resource not found"
}
}
+77
View File
@@ -0,0 +1,77 @@
{
"appName": "Call-me",
"appTitle": "Call-me - Videollamadas Instantáneas",
"appDescription": "¡Tu opción para videollamadas instantáneas!",
"signIn": {
"title": "Iniciar Sesión",
"username": "Ingrese nombre de usuario",
"button": "Iniciar Sesión",
"enterUsername": "Por favor ingrese su nombre de usuario"
},
"room": {
"sessionTime": "Tiempo de Sesión",
"localUsername": "Tú",
"remoteUsername": "Usuario Remoto",
"waiting": "Esperando a que alguien se una...",
"connecting": "Conectando...",
"userJoined": "__username__ se unió a la llamada",
"userLeft": "__username__ salió de la llamada",
"users": "Usuarios",
"chat": "Chat",
"searchUsers": "Buscar usuarios..."
},
"controls": {
"microphone": "Micrófono",
"camera": "Cámara",
"screenShare": "Compartir Pantalla",
"endCall": "Finalizar Llamada",
"settings": "Configuración",
"fullscreen": "Pantalla Completa"
},
"messages": {
"microphoneEnabled": "Micrófono activado",
"microphoneDisabled": "Micrófono desactivado",
"cameraEnabled": "Cámara activada",
"cameraDisabled": "Cámara desactivada",
"screenShareStarted": "Compartir pantalla iniciado",
"screenShareStopped": "Compartir pantalla detenido",
"callEnded": "Llamada finalizada",
"connectionFailed": "Conexión fallida",
"permissionDenied": "Permiso denegado",
"error": "Ocurrió un error",
"copied": "Copiado al portapapeles",
"invalidPassword": "Contraseña inválida"
},
"errors": {
"noUsername": "Se requiere nombre de usuario",
"connectionLost": "Conexión perdida",
"mediaDevices": "No se pudo acceder a los dispositivos multimedia",
"noSupport": "Su navegador no soporta esta función"
},
"settings": {
"language": "Idioma",
"selectLanguage": "Seleccionar Idioma",
"audioInput": "Micrófono",
"videoInput": "Cámara",
"audioOutput": "Altavoz",
"resolution": "Resolución de Video",
"save": "Guardar",
"cancel": "Cancelar",
"settings": "Configuración",
"testDevices": "Probar Dispositivos",
"refresh": "Actualizar",
"saveMessages": "Guardar Mensajes",
"clearAll": "Borrar Todo"
},
"chat": {
"addEmoji": "Agregar emoji",
"typeMessage": "Escribe un mensaje...",
"sendMessage": "Enviar mensaje"
},
"api": {
"unauthorized": "Acceso no autorizado",
"invalidApiKey": "Clave de API inválida",
"serverError": "Error del servidor",
"notFound": "Recurso no encontrado"
}
}
+77
View File
@@ -0,0 +1,77 @@
{
"appName": "Call-me",
"appTitle": "Call-me - Appels Vidéo Instantanés",
"appDescription": "Votre solution pour les appels vidéo instantanés!",
"signIn": {
"title": "Se Connecter",
"username": "Entrez le nom d'utilisateur",
"button": "Se Connecter",
"enterUsername": "Veuillez entrer votre nom d'utilisateur"
},
"room": {
"sessionTime": "Temps de Session",
"localUsername": "Vous",
"remoteUsername": "Utilisateur Distant",
"waiting": "En attente de quelqu'un pour rejoindre...",
"connecting": "Connexion...",
"userJoined": "__username__ a rejoint l'appel",
"userLeft": "__username__ a quitté l'appel",
"users": "Utilisateurs",
"chat": "Chat",
"searchUsers": "Rechercher des utilisateurs..."
},
"controls": {
"microphone": "Microphone",
"camera": "Caméra",
"screenShare": "Partager l'Écran",
"endCall": "Terminer l'Appel",
"settings": "Paramètres",
"fullscreen": "Plein Écran"
},
"messages": {
"microphoneEnabled": "Microphone activé",
"microphoneDisabled": "Microphone désactivé",
"cameraEnabled": "Caméra activée",
"cameraDisabled": "Caméra désactivée",
"screenShareStarted": "Partage d'écran démarré",
"screenShareStopped": "Partage d'écran arrêté",
"callEnded": "Appel terminé",
"connectionFailed": "Échec de la connexion",
"permissionDenied": "Permission refusée",
"error": "Une erreur s'est produite",
"copied": "Copié dans le presse-papiers",
"invalidPassword": "Mot de passe invalide"
},
"errors": {
"noUsername": "Le nom d'utilisateur est requis",
"connectionLost": "Connexion perdue",
"mediaDevices": "Impossible d'accéder aux périphériques multimédias",
"noSupport": "Votre navigateur ne prend pas en charge cette fonctionnalité"
},
"settings": {
"language": "Langue",
"selectLanguage": "Sélectionner la Langue",
"audioInput": "Microphone",
"videoInput": "Caméra",
"audioOutput": "Haut-parleur",
"resolution": "Résolution Vidéo",
"save": "Enregistrer",
"cancel": "Annuler",
"settings": "Paramètres",
"testDevices": "Tester les Appareils",
"refresh": "Actualiser",
"saveMessages": "Enregistrer les Messages",
"clearAll": "Tout Effacer"
},
"chat": {
"addEmoji": "Ajouter un emoji",
"typeMessage": "Tapez un message...",
"sendMessage": "Envoyer le message"
},
"api": {
"unauthorized": "Accès non autorisé",
"invalidApiKey": "Clé API invalide",
"serverError": "Erreur du serveur",
"notFound": "Ressource non trouvée"
}
}
+77
View File
@@ -0,0 +1,77 @@
{
"appName": "Call-me",
"appTitle": "Call-me - Videochiamate Istantanee",
"appDescription": "La tua scelta per videochiamate istantanee!",
"signIn": {
"title": "Accedi",
"username": "Inserisci nome utente",
"button": "Accedi",
"enterUsername": "Inserisci il tuo nome utente"
},
"room": {
"sessionTime": "Tempo di Sessione",
"localUsername": "Tu",
"remoteUsername": "Utente Remoto",
"waiting": "In attesa che qualcuno si unisca...",
"connecting": "Connessione...",
"userJoined": "__username__ è entrato nella chiamata",
"userLeft": "__username__ ha lasciato la chiamata",
"users": "Utenti",
"chat": "Chat",
"searchUsers": "Cerca utenti..."
},
"controls": {
"microphone": "Microfono",
"camera": "Fotocamera",
"screenShare": "Condividi Schermo",
"endCall": "Termina Chiamata",
"settings": "Impostazioni",
"fullscreen": "Schermo Intero"
},
"messages": {
"microphoneEnabled": "Microfono attivato",
"microphoneDisabled": "Microfono disattivato",
"cameraEnabled": "Fotocamera attivata",
"cameraDisabled": "Fotocamera disattivata",
"screenShareStarted": "Condivisione schermo avviata",
"screenShareStopped": "Condivisione schermo interrotta",
"callEnded": "Chiamata terminata",
"connectionFailed": "Connessione fallita",
"permissionDenied": "Permesso negato",
"error": "Si è verificato un errore",
"copied": "Copiato negli appunti",
"invalidPassword": "Password non valida"
},
"errors": {
"noUsername": "Il nome utente è obbligatorio",
"connectionLost": "Connessione persa",
"mediaDevices": "Impossibile accedere ai dispositivi multimediali",
"noSupport": "Il tuo browser non supporta questa funzionalità"
},
"settings": {
"language": "Lingua",
"selectLanguage": "Seleziona Lingua",
"audioInput": "Microfono",
"videoInput": "Fotocamera",
"audioOutput": "Altoparlante",
"resolution": "Risoluzione Video",
"save": "Salva",
"cancel": "Annulla",
"settings": "Impostazioni",
"testDevices": "Prova Dispositivi",
"refresh": "Aggiorna",
"saveMessages": "Salva Messaggi",
"clearAll": "Cancella Tutto"
},
"chat": {
"addEmoji": "Aggiungi emoji",
"typeMessage": "Scrivi un messaggio...",
"sendMessage": "Invia messaggio"
},
"api": {
"unauthorized": "Accesso non autorizzato",
"invalidApiKey": "Chiave API non valida",
"serverError": "Errore del server",
"notFound": "Risorsa non trovata"
}
}
+139 -6
View File
@@ -1,22 +1,23 @@
{
"name": "call-me",
"version": "1.2.85",
"version": "1.2.86",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "call-me",
"version": "1.2.85",
"version": "1.2.86",
"license": "AGPLv3",
"dependencies": {
"@ngrok/ngrok": "1.7.0",
"axios": "^1.13.4",
"colors": "^1.4.0",
"cors": "^2.8.6",
"dotenv": "^17.2.3",
"dotenv": "^17.2.4",
"express": "^5.2.1",
"helmet": "^8.1.0",
"httpolyglot": "0.1.2",
"i18n": "^0.15.3",
"js-yaml": "4.1.1",
"socket.io": "^4.8.3",
"swagger-ui-express": "5.0.1"
@@ -26,6 +27,50 @@
"prettier": "3.8.1"
}
},
"node_modules/@messageformat/core": {
"version": "3.4.0",
"resolved": "https://registry.npmjs.org/@messageformat/core/-/core-3.4.0.tgz",
"integrity": "sha512-NgCFubFFIdMWJGN5WuQhHCNmzk7QgiVfrViFxcS99j7F5dDS5EP6raR54I+2ydhe4+5/XTn/YIEppFaqqVWHsw==",
"license": "MIT",
"dependencies": {
"@messageformat/date-skeleton": "^1.0.0",
"@messageformat/number-skeleton": "^1.0.0",
"@messageformat/parser": "^5.1.0",
"@messageformat/runtime": "^3.0.1",
"make-plural": "^7.0.0",
"safe-identifier": "^0.4.1"
}
},
"node_modules/@messageformat/date-skeleton": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@messageformat/date-skeleton/-/date-skeleton-1.1.0.tgz",
"integrity": "sha512-rmGAfB1tIPER+gh3p/RgA+PVeRE/gxuQ2w4snFWPF5xtb5mbWR7Cbw7wCOftcUypbD6HVoxrVdyyghPm3WzP5A==",
"license": "MIT"
},
"node_modules/@messageformat/number-skeleton": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@messageformat/number-skeleton/-/number-skeleton-1.2.0.tgz",
"integrity": "sha512-xsgwcL7J7WhlHJ3RNbaVgssaIwcEyFkBqxHdcdaiJzwTZAWEOD8BuUFxnxV9k5S0qHN3v/KzUpq0IUpjH1seRg==",
"license": "MIT"
},
"node_modules/@messageformat/parser": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/@messageformat/parser/-/parser-5.1.1.tgz",
"integrity": "sha512-3p0YRGCcTUCYvBKLIxtDDyrJ0YijGIwrTRu1DT8gIviIDZru8H23+FkY6MJBzM1n9n20CiM4VeDYuBsrrwnLjg==",
"license": "MIT",
"dependencies": {
"moo": "^0.5.1"
}
},
"node_modules/@messageformat/runtime": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@messageformat/runtime/-/runtime-3.0.2.tgz",
"integrity": "sha512-dkIPDCjXcfhSHgNE1/qV6TeczQZR59Yx0xXeafVKgK3QVWoxc38ljwpksUpnzCGvN151KUbCJTDZVmahtf1YZw==",
"license": "MIT",
"dependencies": {
"make-plural": "^7.0.0"
}
},
"node_modules/@ngrok/ngrok": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/@ngrok/ngrok/-/ngrok-1.7.0.tgz",
@@ -609,9 +654,9 @@
}
},
"node_modules/dotenv": {
"version": "17.2.3",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz",
"integrity": "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==",
"version": "17.2.4",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.4.tgz",
"integrity": "sha512-mudtfb4zRB4bVvdj0xRo+e6duH1csJRM8IukBqfTRvHotn9+LBXB8ynAidP9zHqoRC/fsllXgk4kCKlR21fIhw==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
@@ -857,6 +902,15 @@
"node": ">= 0.6"
}
},
"node_modules/fast-printf": {
"version": "1.6.10",
"resolved": "https://registry.npmjs.org/fast-printf/-/fast-printf-1.6.10.tgz",
"integrity": "sha512-GwTgG9O4FVIdShhbVF3JxOgSBY2+ePGsu2V/UONgoCPzF9VY6ZdBMKsHKCYQHZwNk3qNouUolRDsgVxcVA5G1w==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=10.0"
}
},
"node_modules/fill-range": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
@@ -1131,6 +1185,49 @@
"node": ">=0.10.0"
}
},
"node_modules/i18n": {
"version": "0.15.3",
"resolved": "https://registry.npmjs.org/i18n/-/i18n-0.15.3.tgz",
"integrity": "sha512-tW/AA5R4lJZLnd60Agcd0PfXB1C2G7UqTrdNewuv/SIYdxcHkCE8w4Zx1SgCjJ+2BLuAAGIG/KXb/xNYF1lO5Q==",
"license": "MIT",
"dependencies": {
"@messageformat/core": "^3.4.0",
"debug": "^4.4.3",
"fast-printf": "^1.6.10",
"make-plural": "^7.4.0",
"math-interval-parser": "^2.0.1",
"mustache": "^4.2.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/mashpie"
}
},
"node_modules/i18n/node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/i18n/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/iconv-lite": {
"version": "0.7.0",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz",
@@ -1227,6 +1324,21 @@
"js-yaml": "bin/js-yaml.js"
}
},
"node_modules/make-plural": {
"version": "7.5.0",
"resolved": "https://registry.npmjs.org/make-plural/-/make-plural-7.5.0.tgz",
"integrity": "sha512-0booA+aVYyVFoR67JBHdfVk0U08HmrBH2FrtmBqBa+NldlqXv/G2Z9VQuQq6Wgp2jDWdybEWGfBkk1cq5264WA==",
"license": "Unicode-DFS-2016"
},
"node_modules/math-interval-parser": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/math-interval-parser/-/math-interval-parser-2.0.1.tgz",
"integrity": "sha512-VmlAmb0UJwlvMyx8iPhXUDnVW1F9IrGEd9CIOmv+XL8AErCUUuozoDMrgImvnYt2A+53qVX/tPW6YJurMKYsvA==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -1288,12 +1400,27 @@
"node": "*"
}
},
"node_modules/moo": {
"version": "0.5.2",
"resolved": "https://registry.npmjs.org/moo/-/moo-0.5.2.tgz",
"integrity": "sha512-iSAJLHYKnX41mKcJKjqvnAN9sf0LMDTXDEvFv+ffuRR9a1MIuXLjMNL6EsnDHSkKLTWNqQQ5uo61P4EbU4NU+Q==",
"license": "BSD-3-Clause"
},
"node_modules/ms": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==",
"license": "MIT"
},
"node_modules/mustache": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/mustache/-/mustache-4.2.0.tgz",
"integrity": "sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==",
"license": "MIT",
"bin": {
"mustache": "bin/mustache"
}
},
"node_modules/negotiator": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
@@ -1562,6 +1689,12 @@
],
"license": "MIT"
},
"node_modules/safe-identifier": {
"version": "0.4.2",
"resolved": "https://registry.npmjs.org/safe-identifier/-/safe-identifier-0.4.2.tgz",
"integrity": "sha512-6pNbSMW6OhAi9j+N8V+U715yBQsaWJ7eyEUaOrawX+isg5ZxhUlV1NipNtgaKHmFGiABwt+ZF04Ii+3Xjkg+8w==",
"license": "ISC"
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+3 -2
View File
@@ -1,6 +1,6 @@
{
"name": "call-me",
"version": "1.2.85",
"version": "1.2.86",
"description": "Your Go-To for Instant Video Calls",
"author": "Miroslav Pejic - miroslav.pejic.85@gmail.com",
"license": "AGPLv3",
@@ -23,10 +23,11 @@
"axios": "^1.13.4",
"colors": "^1.4.0",
"cors": "^2.8.6",
"dotenv": "^17.2.3",
"dotenv": "^17.2.4",
"express": "^5.2.1",
"helmet": "^8.1.0",
"httpolyglot": "0.1.2",
"i18n": "^0.15.3",
"js-yaml": "4.1.1",
"socket.io": "^4.8.3",
"swagger-ui-express": "5.0.1"
+217
View File
@@ -0,0 +1,217 @@
// i18n.js - Client-side internationalization support
'use strict';
// Global i18n object
const i18n = {
currentLocale: 'en',
translations: {},
defaultLocale: 'en',
};
/**
* Initialize i18n
*/
async function initI18n() {
// Get saved locale from localStorage or use browser language or default
const savedLocale = localStorage.getItem('locale');
const browserLocale = navigator.language.split('-')[0]; // Get 'en' from 'en-US'
const supportedLocales = ['en', 'es', 'fr', 'it', 'de'];
// Determine which locale to use
if (savedLocale && supportedLocales.includes(savedLocale)) {
i18n.currentLocale = savedLocale;
} else if (supportedLocales.includes(browserLocale)) {
i18n.currentLocale = browserLocale;
} else {
i18n.currentLocale = i18n.defaultLocale;
}
// Load translations
await loadTranslations(i18n.currentLocale);
// Set up language selector
setupLanguageSelector();
// Translate the page
translatePage();
console.log('i18n initialized with locale:', i18n.currentLocale);
}
/**
* Load translations from the server
* @param {string} locale - The locale to load
*/
async function loadTranslations(locale) {
try {
const response = await fetch(`/translations/${locale}`);
if (!response.ok) {
throw new Error(`Failed to load translations for locale: ${locale}`);
}
const data = await response.json();
i18n.translations = data.translations;
i18n.currentLocale = locale;
localStorage.setItem('locale', locale);
} catch (error) {
console.error('Error loading translations:', error);
// Fallback to English if loading fails
if (locale !== 'en') {
await loadTranslations('en');
}
}
}
/**
* Get translation for a key
* @param {string} key - The translation key (supports dot notation, e.g., 'signIn.title')
* @param {object} replacements - Object with placeholder replacements
* @returns {string} - The translated string
*/
function t(key, replacements = {}) {
const keys = key.split('.');
let value = i18n.translations;
// Navigate through nested keys
for (const k of keys) {
if (value && typeof value === 'object' && k in value) {
value = value[k];
} else {
console.warn(`Translation key not found: ${key}`);
return key; // Return the key itself if translation not found
}
}
// If value is an object, return the key (shouldn't happen with proper keys)
if (typeof value === 'object') {
console.warn(`Translation key is an object, not a string: ${key}`);
return key;
}
// Replace placeholders in the translation
let translation = value;
for (const [placeholder, replacement] of Object.entries(replacements)) {
translation = translation.replace(new RegExp(`__${placeholder}__`, 'g'), replacement);
}
return translation;
}
/**
* Change the current language
* @param {string} locale - The new locale
*/
async function changeLanguage(locale) {
await loadTranslations(locale);
translatePage();
// Update language selector
const languageSelect = document.getElementById('languageSelect');
if (languageSelect) {
languageSelect.value = locale;
}
}
/**
* Setup language selector event listener
*/
function setupLanguageSelector() {
const languageSelect = document.getElementById('languageSelect');
if (languageSelect) {
// Set current locale
languageSelect.value = i18n.currentLocale;
// Add change event listener
languageSelect.addEventListener('change', async (e) => {
const newLocale = e.target.value;
await changeLanguage(newLocale);
});
}
}
/**
* Translate all elements with data-i18n attribute
*/
function translatePage() {
// Translate elements with data-i18n attribute
document.querySelectorAll('[data-i18n]').forEach((element) => {
const key = element.getAttribute('data-i18n');
const translation = t(key);
// Update the appropriate property based on element type
if (element.tagName === 'INPUT' || element.tagName === 'TEXTAREA') {
if (element.type === 'button' || element.type === 'submit') {
element.value = translation;
} else {
element.placeholder = translation;
}
} else {
element.textContent = translation;
}
});
// Translate elements with data-i18n-placeholder attribute
document.querySelectorAll('[data-i18n-placeholder]').forEach((element) => {
const key = element.getAttribute('data-i18n-placeholder');
element.placeholder = t(key);
});
// Translate elements with data-i18n-title attribute (for tooltips)
document.querySelectorAll('[data-i18n-title]').forEach((element) => {
const key = element.getAttribute('data-i18n-title');
element.title = t(key);
});
// Update document title
document.title = t('appTitle');
// Custom translations for specific elements that need special handling
updateCustomTranslations();
}
/**
* Update custom translations for specific elements
*/
function updateCustomTranslations() {
// Update app title
const appTitle = document.getElementById('appTitle');
if (appTitle) appTitle.textContent = t('appTitle');
// Update app name
const appName = document.getElementById('appName');
if (appName) appName.textContent = t('appName');
// Update settings title for language
const settingsTitles = document.querySelectorAll('.settings-title');
if (settingsTitles.length > 0) {
settingsTitles[0].innerHTML = '<i class="fas fa-globe"></i> ' + t('settings.language');
}
if (settingsTitles.length > 1) {
settingsTitles[1].innerHTML = '<i class="fas fa-video"></i> Media Devices';
}
if (settingsTitles.length > 2) {
settingsTitles[2].innerHTML = '<i class="fas fa-comments"></i> Chat Settings';
}
}
/**
* Helper function to show translated messages using SweetAlert
* @param {string} titleKey - Translation key for the title
* @param {string} textKey - Translation key for the text
* @param {string} icon - Icon type (success, error, warning, info)
*/
function showTranslatedAlert(titleKey, textKey, icon = 'info') {
if (typeof Swal !== 'undefined') {
Swal.fire({
title: t(titleKey),
text: t(textKey),
icon: icon,
});
}
}
// Initialize i18n when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initI18n);
} else {
initI18n();
}
+56 -14
View File
@@ -66,15 +66,23 @@
<div class="row justify-content-center">
<div class="col-md-6">
<div class="card">
<div class="card-header"><h1 id="appName">Call-me</h1></div>
<div class="card-header"><h1 id="appName" data-i18n="appName">Call-me</h1></div>
<div class="card-body">
<!-- Sign-in Form -->
<div class="mb-3">
<!-- Input field for entering the username -->
<input id="usernameIn" type="text" placeholder="Enter username" required />
<input
id="usernameIn"
type="text"
placeholder="Enter username"
data-i18n-placeholder="signIn.username"
required
/>
</div>
<!-- Sign-in button -->
<button id="signInBtn" class="btn btn-primary">Sign In</button>
<button id="signInBtn" class="btn btn-primary" data-i18n="signIn.button">
Sign In
</button>
</div>
</div>
</div>
@@ -194,14 +202,14 @@
<div class="user-sidebar-header">
<div class="sidebar-tabs">
<button id="usersTab" class="sidebar-tab active" data-tab="users">
<i class="fas fa-users"></i> Users
<i class="fas fa-users"></i> <span data-i18n="room.users">Users</span>
</button>
<button id="chatTab" class="sidebar-tab" data-tab="chat">
<i class="fas fa-comments"></i> Chat
<i class="fas fa-comments"></i> <span data-i18n="room.chat">Chat</span>
<span id="chatNotification" class="chat-notification hidden">0</span>
</button>
<button id="settingsTab" class="sidebar-tab" data-tab="settings">
<i class="fas fa-cog"></i> Settings
<i class="fas fa-cog"></i> <span data-i18n="settings.settings">Settings</span>
</button>
</div>
@@ -213,7 +221,13 @@
<!-- Users Tab Content -->
<div id="usersContent" class="tab-content active">
<div class="user-search-bar">
<input type="text" id="userSearchInput" placeholder="Search users..." autocomplete="off" />
<input
type="text"
id="userSearchInput"
placeholder="Search users..."
data-i18n-placeholder="room.searchUsers"
autocomplete="off"
/>
</div>
<ul id="userList" class="user-list"></ul>
</div>
@@ -222,11 +236,23 @@
<div id="chatContent" class="tab-content mt-5">
<div id="chatMessages" class="chat-messages"></div>
<form id="chatForm" class="chat-form" autocomplete="off">
<button type="button" id="emojiBtn" class="btn btn-emoji" title="Add emoji">
<button
type="button"
id="emojiBtn"
class="btn btn-emoji"
title="Add emoji"
data-i18n-title="chat.addEmoji"
>
<i class="fas fa-smile"></i>
</button>
<input id="chatInput" type="text" placeholder="Type a message..." autocomplete="off" />
<button type="submit" class="btn btn-primary btn-send-chat">
<input
id="chatInput"
type="text"
placeholder="Type a message..."
data-i18n-placeholder="chat.typeMessage"
autocomplete="off"
/>
<button type="submit" class="btn btn-primary btn-send-chat" data-i18n-title="chat.sendMessage">
<i class="fas fa-paper-plane"></i>
</button>
</form>
@@ -236,6 +262,21 @@
<!-- Settings Tab Content -->
<div id="settingsContent" class="tab-content">
<div class="settings-section">
<h3 class="settings-title"><i class="fas fa-globe"></i> Language</h3>
<div class="setting-group">
<label for="languageSelect" class="setting-label">
<i class="fas fa-language"></i> Select Language
</label>
<select id="languageSelect" class="setting-select">
<option value="en">🇬🇧 English</option>
<option value="es">🇪🇸 Español</option>
<option value="fr">🇫🇷 Français</option>
<option value="it">🇮🇹 Italiano</option>
<option value="de">🇩🇪 Deutsch</option>
</select>
</div>
<h3 class="settings-title"><i class="fas fa-video"></i> Media Devices</h3>
<div class="setting-group">
@@ -265,20 +306,20 @@
<div class="settings-actions">
<button id="testDevicesBtn" class="btn btn-success btn-test-devices">
<i class="fas fa-play"></i> Test Devices
<i class="fas fa-play"></i> <span data-i18n="settings.testDevices">Test Devices</span>
</button>
<button id="refreshDevicesBtn" class="btn btn-secondary btn-refresh-devices">
<i class="fas fa-sync"></i> Refresh
<i class="fas fa-sync"></i> <span data-i18n="settings.refresh">Refresh</span>
</button>
</div>
<h3 class="settings-title"><i class="fas fa-comments"></i> Chat Settings</h3>
<div class="settings-actions">
<button id="saveChatBtn" class="btn btn-success btn-save-chat">
<i class="fas fa-download"></i> Save Messages
<i class="fas fa-download"></i> <span data-i18n="settings.saveMessages">Save Messages</span>
</button>
<button id="clearChatBtn" class="btn btn-danger btn-clear-chat">
<i class="fas fa-trash"></i> Clear All
<i class="fas fa-trash"></i> <span data-i18n="settings.clearAll">Clear All</span>
</button>
</div>
</div>
@@ -288,6 +329,7 @@
<!-- JavaScript libraries for WebSocket and custom client code -->
<script src="/socket.io/socket.io.js"></script>
<script src="config.js"></script>
<script src="i18n.js"></script>
<script src="client.js"></script>
<!-- Include UaParser -->
+32 -5
View File
@@ -670,7 +670,7 @@ input {
display: flex;
top: 0;
right: 0;
width: 350px;
width: 430px;
height: 100%;
background: rgba(30, 32, 36, 0.98);
flex-direction: column;
@@ -700,11 +700,22 @@ input {
padding: 0.5rem 1rem;
background: rgba(24, 25, 28, 0.95);
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
gap: 0.5rem;
overflow: visible;
}
.sidebar-tabs {
display: flex;
gap: 4px;
flex: 1;
min-width: 0;
overflow-x: auto;
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* IE and Edge */
}
.sidebar-tabs::-webkit-scrollbar {
display: none; /* Chrome, Safari, Opera */
}
.sidebar-tab {
@@ -720,6 +731,14 @@ input {
display: flex;
align-items: center;
gap: 6px;
white-space: nowrap;
flex-shrink: 0;
overflow: visible;
}
.sidebar-tab span {
white-space: nowrap;
overflow: visible;
}
.sidebar-tab:hover {
@@ -772,6 +791,8 @@ input {
cursor: pointer;
border-radius: 6px;
transition: all 0.2s;
flex-shrink: 0;
min-width: 40px;
}
.btn-exit-sidebar:hover {
@@ -1175,15 +1196,21 @@ input {
.sidebar-tabs {
flex: 1;
justify-content: center;
justify-content: flex-start;
overflow-x: auto;
}
.sidebar-tab {
flex: 1;
flex: 0 0 auto;
text-align: center;
padding: 8px 4px;
font-size: 0.75rem;
padding: 8px 8px;
font-size: 0.8rem;
gap: 4px;
min-width: fit-content;
}
.sidebar-tab span {
display: inline;
}
.sidebar-tab i {