Major PWA and mobile UI improvements
- Fixed PWA installation on Android by correcting manifest.json icon configuration - Made UI mobile-friendly with compact layout and sticky record button - Implemented auto-translation after transcription stops - Updated branding from 'Voice Translator' to 'Talk2Me' throughout - Added reverse proxy support with ProxyFix middleware - Created diagnostic tools for PWA troubleshooting - Added proper HTTP headers for service worker and manifest - Improved mobile CSS with responsive design - Fixed JavaScript bundling with webpack configuration - Updated service worker cache versioning - Added comprehensive PWA documentation These changes ensure the app works properly as a PWA on Android devices and provides a better mobile user experience.
This commit is contained in:
parent
b5f2b53262
commit
d818ec7d73
54
.env.template
Normal file
54
.env.template
Normal file
@ -0,0 +1,54 @@
|
||||
# Talk2Me Environment Configuration Template
|
||||
# Copy this file to .env and update with your values
|
||||
|
||||
# Flask Configuration
|
||||
FLASK_ENV=production
|
||||
SECRET_KEY=your-secret-key-here-change-this
|
||||
|
||||
# Security Settings for HTTPS/Reverse Proxy
|
||||
SESSION_COOKIE_SECURE=true
|
||||
SESSION_COOKIE_SAMESITE=Lax
|
||||
PREFERRED_URL_SCHEME=https
|
||||
|
||||
# TTS Server Configuration
|
||||
TTS_SERVER_URL=http://localhost:5050/v1/audio/speech
|
||||
TTS_API_KEY=your-tts-api-key-here
|
||||
|
||||
# Whisper Configuration
|
||||
WHISPER_MODEL_SIZE=base
|
||||
WHISPER_DEVICE=auto
|
||||
|
||||
# Ollama Configuration
|
||||
OLLAMA_HOST=http://localhost:11434
|
||||
OLLAMA_MODEL=gemma3:27b
|
||||
|
||||
# Admin Configuration
|
||||
ADMIN_TOKEN=your-admin-token-here-change-this
|
||||
|
||||
# CORS Configuration (comma-separated)
|
||||
CORS_ORIGINS=https://talk2me.dr74.net,http://localhost:5000
|
||||
ADMIN_CORS_ORIGINS=https://talk2me.dr74.net
|
||||
|
||||
# Rate Limiting
|
||||
RATE_LIMIT_ENABLED=true
|
||||
RATE_LIMIT_STORAGE_URL=memory://
|
||||
|
||||
# Feature Flags
|
||||
ENABLE_PUSH_NOTIFICATIONS=true
|
||||
ENABLE_OFFLINE_MODE=true
|
||||
ENABLE_STREAMING=true
|
||||
ENABLE_MULTI_SPEAKER=true
|
||||
|
||||
# Logging
|
||||
LOG_LEVEL=INFO
|
||||
LOG_FILE=logs/talk2me.log
|
||||
|
||||
# Upload Configuration
|
||||
UPLOAD_FOLDER=/tmp/talk2me_uploads
|
||||
MAX_CONTENT_LENGTH=52428800
|
||||
MAX_AUDIO_SIZE=26214400
|
||||
MAX_JSON_SIZE=1048576
|
||||
|
||||
# Worker Configuration (for Gunicorn)
|
||||
WORKER_CONNECTIONS=1000
|
||||
WORKER_TIMEOUT=120
|
155
REVERSE_PROXY.md
Normal file
155
REVERSE_PROXY.md
Normal file
@ -0,0 +1,155 @@
|
||||
# Nginx Reverse Proxy Configuration for Talk2Me
|
||||
|
||||
## Nginx Configuration
|
||||
|
||||
Add the following to your Nginx configuration for the domain `talk2me.dr74.net`:
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name talk2me.dr74.net;
|
||||
|
||||
# SSL configuration
|
||||
ssl_certificate /path/to/ssl/cert.pem;
|
||||
ssl_certificate_key /path/to/ssl/key.pem;
|
||||
|
||||
# Security headers
|
||||
add_header X-Content-Type-Options nosniff;
|
||||
add_header X-Frame-Options SAMEORIGIN;
|
||||
add_header X-XSS-Protection "1; mode=block";
|
||||
add_header Referrer-Policy "no-referrer-when-downgrade";
|
||||
|
||||
# Proxy settings
|
||||
location / {
|
||||
proxy_pass http://localhost:5000; # Adjust port as needed
|
||||
proxy_http_version 1.1;
|
||||
|
||||
# Important headers for PWA and WebSocket support
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
|
||||
# WebSocket support
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
|
||||
# Timeouts for long-running requests
|
||||
proxy_connect_timeout 60s;
|
||||
proxy_send_timeout 60s;
|
||||
proxy_read_timeout 60s;
|
||||
|
||||
# Buffer settings
|
||||
proxy_buffering off;
|
||||
proxy_buffer_size 4k;
|
||||
proxy_buffers 8 4k;
|
||||
proxy_busy_buffers_size 8k;
|
||||
|
||||
# Disable cache for dynamic content
|
||||
proxy_cache_bypass 1;
|
||||
proxy_no_cache 1;
|
||||
}
|
||||
|
||||
# Static files with caching
|
||||
location /static/ {
|
||||
proxy_pass http://localhost:5000/static/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
|
||||
# Cache static files
|
||||
expires 30d;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
# Service worker needs special handling
|
||||
location /service-worker.js {
|
||||
proxy_pass http://localhost:5000/service-worker.js;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
|
||||
# No cache for service worker
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||
add_header Pragma "no-cache";
|
||||
expires 0;
|
||||
}
|
||||
|
||||
# Manifest file
|
||||
location /static/manifest.json {
|
||||
proxy_pass http://localhost:5000/static/manifest.json;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
|
||||
# Allow manifest to be cached briefly
|
||||
expires 1h;
|
||||
add_header Cache-Control "public";
|
||||
add_header Content-Type "application/manifest+json";
|
||||
}
|
||||
}
|
||||
|
||||
# Redirect HTTP to HTTPS
|
||||
server {
|
||||
listen 80;
|
||||
server_name talk2me.dr74.net;
|
||||
return 301 https://$server_name$request_uri;
|
||||
}
|
||||
```
|
||||
|
||||
## Flask Application Configuration
|
||||
|
||||
Set these environment variables for the Talk2Me application:
|
||||
|
||||
```bash
|
||||
# Add to .env file or set as environment variables
|
||||
FLASK_ENV=production
|
||||
SESSION_COOKIE_SECURE=true
|
||||
SESSION_COOKIE_SAMESITE=Lax
|
||||
PREFERRED_URL_SCHEME=https
|
||||
|
||||
# If using a non-standard port
|
||||
# SERVER_NAME=talk2me.dr74.net
|
||||
```
|
||||
|
||||
## Testing the Configuration
|
||||
|
||||
1. **Check SSL Certificate**:
|
||||
```bash
|
||||
curl -I https://talk2me.dr74.net
|
||||
```
|
||||
|
||||
2. **Verify Service Worker**:
|
||||
```bash
|
||||
curl https://talk2me.dr74.net/service-worker.js
|
||||
```
|
||||
|
||||
3. **Check Manifest**:
|
||||
```bash
|
||||
curl https://talk2me.dr74.net/static/manifest.json
|
||||
```
|
||||
|
||||
4. **Test PWA Installation**:
|
||||
- Visit https://talk2me.dr74.net in Chrome
|
||||
- Open Developer Tools (F12)
|
||||
- Go to Application tab
|
||||
- Check "Manifest" section for any errors
|
||||
- Check "Service Workers" section
|
||||
|
||||
## Common Issues and Solutions
|
||||
|
||||
### Issue: Icons not loading
|
||||
- Ensure static files are being served correctly
|
||||
- Check Nginx error logs: `tail -f /var/log/nginx/error.log`
|
||||
|
||||
### Issue: Service Worker not registering
|
||||
- Verify HTTPS is working correctly
|
||||
- Check browser console for errors
|
||||
- Ensure service worker scope is correct
|
||||
|
||||
### Issue: "Add to Home Screen" not appearing
|
||||
- Clear browser cache and data
|
||||
- Ensure all manifest requirements are met
|
||||
- Check Chrome's PWA criteria in DevTools Lighthouse
|
||||
|
||||
### Issue: WebSocket connections failing
|
||||
- Verify Nginx has WebSocket headers configured
|
||||
- Check if firewall allows WebSocket connections
|
31
app.py
31
app.py
@ -32,6 +32,9 @@ load_dotenv()
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Import ProxyFix for reverse proxy support
|
||||
from werkzeug.middleware.proxy_fix import ProxyFix
|
||||
|
||||
# Import configuration and secrets management
|
||||
from config import init_app as init_config
|
||||
from secrets_manager import init_app as init_secrets
|
||||
@ -82,6 +85,16 @@ def with_error_boundary(func):
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
# Apply ProxyFix middleware for reverse proxy support
|
||||
# This ensures the app correctly handles X-Forwarded-* headers from Nginx
|
||||
app.wsgi_app = ProxyFix(
|
||||
app.wsgi_app,
|
||||
x_for=1, # Number of reverse proxies setting X-Forwarded-For
|
||||
x_proto=1, # Number of reverse proxies setting X-Forwarded-Proto
|
||||
x_host=1, # Number of reverse proxies setting X-Forwarded-Host
|
||||
x_prefix=1 # Number of reverse proxies setting X-Forwarded-Prefix
|
||||
)
|
||||
|
||||
# Initialize configuration and secrets management
|
||||
init_config(app)
|
||||
init_secrets(app)
|
||||
@ -348,7 +361,23 @@ def apple_touch_icon_120_precomposed():
|
||||
# Add this route to your Flask app
|
||||
@app.route('/service-worker.js')
|
||||
def service_worker():
|
||||
return app.send_static_file('service-worker.js')
|
||||
response = app.send_static_file('service-worker.js')
|
||||
response.headers['Content-Type'] = 'application/javascript'
|
||||
response.headers['Service-Worker-Allowed'] = '/'
|
||||
response.headers['Cache-Control'] = 'no-cache, no-store, must-revalidate'
|
||||
return response
|
||||
|
||||
@app.route('/manifest.json')
|
||||
@app.route('/static/manifest.json')
|
||||
def manifest():
|
||||
response = app.send_static_file('manifest.json')
|
||||
response.headers['Content-Type'] = 'application/manifest+json'
|
||||
response.headers['Cache-Control'] = 'public, max-age=3600'
|
||||
return response
|
||||
|
||||
@app.route('/check-pwa-status.html')
|
||||
def check_pwa_status():
|
||||
return app.send_static_file('check-pwa-status.html')
|
||||
|
||||
# Make sure static files are served properly
|
||||
app.static_folder = 'static'
|
||||
|
168
check-pwa-status.html
Normal file
168
check-pwa-status.html
Normal file
@ -0,0 +1,168 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>PWA Installation Checker</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
padding: 20px;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.check {
|
||||
margin: 10px 0;
|
||||
padding: 10px;
|
||||
border-radius: 5px;
|
||||
}
|
||||
.pass {
|
||||
background-color: #d4edda;
|
||||
color: #155724;
|
||||
}
|
||||
.fail {
|
||||
background-color: #f8d7da;
|
||||
color: #721c24;
|
||||
}
|
||||
.info {
|
||||
background-color: #d1ecf1;
|
||||
color: #0c5460;
|
||||
}
|
||||
pre {
|
||||
background: #f4f4f4;
|
||||
padding: 10px;
|
||||
border-radius: 5px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>PWA Installation Status Checker</h1>
|
||||
<div id="results"></div>
|
||||
|
||||
<script>
|
||||
const results = document.getElementById('results');
|
||||
|
||||
function addResult(message, status = 'info') {
|
||||
const div = document.createElement('div');
|
||||
div.className = `check ${status}`;
|
||||
div.innerHTML = message;
|
||||
results.appendChild(div);
|
||||
}
|
||||
|
||||
// Check HTTPS
|
||||
if (location.protocol === 'https:' || location.hostname === 'localhost') {
|
||||
addResult('✅ HTTPS/Localhost: ' + location.protocol + '//' + location.hostname, 'pass');
|
||||
} else {
|
||||
addResult('❌ Not HTTPS: PWAs require HTTPS (or localhost)', 'fail');
|
||||
}
|
||||
|
||||
// Check Service Worker support
|
||||
if ('serviceWorker' in navigator) {
|
||||
addResult('✅ Service Worker API supported', 'pass');
|
||||
|
||||
// Check registration
|
||||
navigator.serviceWorker.getRegistration().then(reg => {
|
||||
if (reg) {
|
||||
addResult('✅ Service Worker registered: ' + reg.scope, 'pass');
|
||||
addResult('Service Worker state: ' + (reg.active ? 'active' : 'not active'), 'info');
|
||||
} else {
|
||||
addResult('❌ No Service Worker registered', 'fail');
|
||||
}
|
||||
});
|
||||
} else {
|
||||
addResult('❌ Service Worker API not supported', 'fail');
|
||||
}
|
||||
|
||||
// Check manifest
|
||||
const manifestLink = document.querySelector('link[rel="manifest"]');
|
||||
if (manifestLink) {
|
||||
addResult('✅ Manifest link found: ' + manifestLink.href, 'pass');
|
||||
|
||||
// Fetch and validate manifest
|
||||
fetch(manifestLink.href)
|
||||
.then(response => response.json())
|
||||
.then(manifest => {
|
||||
addResult('Manifest loaded successfully', 'info');
|
||||
|
||||
// Check required fields
|
||||
const required = ['name', 'short_name', 'start_url', 'display', 'icons'];
|
||||
required.forEach(field => {
|
||||
if (manifest[field]) {
|
||||
addResult(`✅ Manifest has ${field}: ${JSON.stringify(manifest[field])}`, 'pass');
|
||||
} else {
|
||||
addResult(`❌ Manifest missing ${field}`, 'fail');
|
||||
}
|
||||
});
|
||||
|
||||
// Check icons
|
||||
if (manifest.icons && manifest.icons.length > 0) {
|
||||
const has192 = manifest.icons.some(icon => icon.sizes && icon.sizes.includes('192'));
|
||||
const has512 = manifest.icons.some(icon => icon.sizes && icon.sizes.includes('512'));
|
||||
|
||||
if (has192) addResult('✅ Has 192x192 icon', 'pass');
|
||||
else addResult('❌ Missing 192x192 icon', 'fail');
|
||||
|
||||
if (has512) addResult('✅ Has 512x512 icon', 'pass');
|
||||
else addResult('⚠️ Missing 512x512 icon (recommended)', 'info');
|
||||
|
||||
// Check icon purposes
|
||||
manifest.icons.forEach((icon, i) => {
|
||||
addResult(`Icon ${i + 1}: ${icon.sizes} - purpose: ${icon.purpose || 'not specified'}`, 'info');
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
addResult('❌ Failed to load manifest: ' + error.message, 'fail');
|
||||
});
|
||||
} else {
|
||||
addResult('❌ No manifest link found in HTML', 'fail');
|
||||
}
|
||||
|
||||
// Check installability
|
||||
window.addEventListener('beforeinstallprompt', (e) => {
|
||||
e.preventDefault();
|
||||
addResult('✅ Browser considers app installable (beforeinstallprompt fired)', 'pass');
|
||||
|
||||
// Show install criteria met
|
||||
const criteria = [
|
||||
'HTTPS or localhost',
|
||||
'Valid manifest with required fields',
|
||||
'Service Worker with fetch handler',
|
||||
'Icons (192x192 minimum)',
|
||||
'Not already installed'
|
||||
];
|
||||
|
||||
addResult('<strong>Installation criteria met:</strong><br>' + criteria.join('<br>'), 'info');
|
||||
});
|
||||
|
||||
// Check if already installed
|
||||
if (window.matchMedia('(display-mode: standalone)').matches) {
|
||||
addResult('✅ App is already installed (running in standalone mode)', 'pass');
|
||||
}
|
||||
|
||||
// Additional Chrome-specific checks
|
||||
if (navigator.userAgent.includes('Chrome')) {
|
||||
addResult('Chrome browser detected - checking Chrome-specific requirements', 'info');
|
||||
|
||||
setTimeout(() => {
|
||||
// If no beforeinstallprompt event fired after 3 seconds
|
||||
if (!window.installPromptFired) {
|
||||
addResult('⚠️ beforeinstallprompt event not fired after 3 seconds', 'info');
|
||||
addResult('Possible reasons:<br>' +
|
||||
'- App already installed<br>' +
|
||||
'- User dismissed install prompt recently<br>' +
|
||||
'- Missing PWA criteria<br>' +
|
||||
'- Chrome needs a user gesture to show prompt', 'info');
|
||||
}
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
// Log all checks completed
|
||||
setTimeout(() => {
|
||||
addResult('<br><strong>All checks completed</strong>', 'info');
|
||||
console.log('PWA Status Check Complete');
|
||||
}, 4000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
121
diagnose-pwa.py
Executable file
121
diagnose-pwa.py
Executable file
@ -0,0 +1,121 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
PWA Diagnostic Script for Talk2Me
|
||||
Checks common PWA installation issues
|
||||
"""
|
||||
|
||||
import requests
|
||||
import json
|
||||
import sys
|
||||
from urllib.parse import urljoin
|
||||
|
||||
def check_pwa(base_url):
|
||||
"""Check PWA requirements for the given URL"""
|
||||
|
||||
if not base_url.startswith(('http://', 'https://')):
|
||||
base_url = 'https://' + base_url
|
||||
|
||||
if not base_url.endswith('/'):
|
||||
base_url += '/'
|
||||
|
||||
print(f"Checking PWA for: {base_url}\n")
|
||||
|
||||
# Check HTTPS
|
||||
if not base_url.startswith('https://'):
|
||||
print("❌ PWA requires HTTPS (except for localhost)")
|
||||
return
|
||||
else:
|
||||
print("✅ HTTPS is enabled")
|
||||
|
||||
# Check main page
|
||||
try:
|
||||
response = requests.get(base_url, timeout=10)
|
||||
if response.status_code == 200:
|
||||
print("✅ Main page loads successfully")
|
||||
else:
|
||||
print(f"❌ Main page returned status code: {response.status_code}")
|
||||
except Exception as e:
|
||||
print(f"❌ Error loading main page: {e}")
|
||||
return
|
||||
|
||||
# Check manifest
|
||||
manifest_url = urljoin(base_url, '/static/manifest.json')
|
||||
print(f"\nChecking manifest at: {manifest_url}")
|
||||
try:
|
||||
response = requests.get(manifest_url, timeout=10)
|
||||
if response.status_code == 200:
|
||||
print("✅ Manifest file found")
|
||||
|
||||
# Parse manifest
|
||||
try:
|
||||
manifest = response.json()
|
||||
print(f" - Name: {manifest.get('name', 'Not set')}")
|
||||
print(f" - Short name: {manifest.get('short_name', 'Not set')}")
|
||||
print(f" - Display: {manifest.get('display', 'Not set')}")
|
||||
print(f" - Start URL: {manifest.get('start_url', 'Not set')}")
|
||||
print(f" - Icons: {len(manifest.get('icons', []))} defined")
|
||||
|
||||
# Check icons
|
||||
for icon in manifest.get('icons', []):
|
||||
icon_url = urljoin(base_url, icon['src'])
|
||||
try:
|
||||
icon_response = requests.head(icon_url, timeout=5)
|
||||
if icon_response.status_code == 200:
|
||||
print(f" ✅ {icon['sizes']}: {icon['src']}")
|
||||
else:
|
||||
print(f" ❌ {icon['sizes']}: {icon['src']} (Status: {icon_response.status_code})")
|
||||
except:
|
||||
print(f" ❌ {icon['sizes']}: {icon['src']} (Failed to load)")
|
||||
|
||||
except json.JSONDecodeError:
|
||||
print("❌ Manifest is not valid JSON")
|
||||
else:
|
||||
print(f"❌ Manifest returned status code: {response.status_code}")
|
||||
except Exception as e:
|
||||
print(f"❌ Error loading manifest: {e}")
|
||||
|
||||
# Check service worker
|
||||
sw_url = urljoin(base_url, '/service-worker.js')
|
||||
print(f"\nChecking service worker at: {sw_url}")
|
||||
try:
|
||||
response = requests.get(sw_url, timeout=10)
|
||||
if response.status_code == 200:
|
||||
print("✅ Service worker file found")
|
||||
content_type = response.headers.get('Content-Type', '')
|
||||
if 'javascript' in content_type:
|
||||
print("✅ Service worker has correct content type")
|
||||
else:
|
||||
print(f"⚠️ Service worker content type: {content_type}")
|
||||
else:
|
||||
print(f"❌ Service worker returned status code: {response.status_code}")
|
||||
except Exception as e:
|
||||
print(f"❌ Error loading service worker: {e}")
|
||||
|
||||
# Check favicon
|
||||
favicon_url = urljoin(base_url, '/static/icons/favicon.ico')
|
||||
print(f"\nChecking favicon at: {favicon_url}")
|
||||
try:
|
||||
response = requests.head(favicon_url, timeout=5)
|
||||
if response.status_code == 200:
|
||||
print("✅ Favicon found")
|
||||
else:
|
||||
print(f"⚠️ Favicon returned status code: {response.status_code}")
|
||||
except Exception as e:
|
||||
print(f"⚠️ Error loading favicon: {e}")
|
||||
|
||||
print("\n" + "="*50)
|
||||
print("PWA Installation Tips:")
|
||||
print("1. Clear browser cache and app data")
|
||||
print("2. Visit the site in Chrome on Android")
|
||||
print("3. Wait a few seconds for the install prompt")
|
||||
print("4. Or tap menu (⋮) → 'Add to Home screen'")
|
||||
print("5. Check Chrome DevTools → Application → Manifest")
|
||||
print("="*50)
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) > 1:
|
||||
url = sys.argv[1]
|
||||
else:
|
||||
url = input("Enter the URL to check (e.g., talk2me.dr74.net): ")
|
||||
|
||||
check_pwa(url)
|
1652
package-lock.json
generated
1652
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
15
package.json
15
package.json
@ -4,9 +4,10 @@
|
||||
"description": "Real-time voice translation web application",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"watch": "tsc --watch",
|
||||
"dev": "tsc --watch",
|
||||
"build": "webpack",
|
||||
"build-tsc": "tsc",
|
||||
"watch": "webpack --watch",
|
||||
"dev": "webpack --watch",
|
||||
"clean": "rm -rf static/js/dist",
|
||||
"type-check": "tsc --noEmit"
|
||||
},
|
||||
@ -20,7 +21,9 @@
|
||||
"license": "ISC",
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.10.0",
|
||||
"typescript": "^5.3.0"
|
||||
},
|
||||
"dependencies": {}
|
||||
"ts-loader": "^9.5.2",
|
||||
"typescript": "^5.3.0",
|
||||
"webpack": "^5.99.9",
|
||||
"webpack-cli": "^6.0.1"
|
||||
}
|
||||
}
|
218
static/check-pwa-status.html
Normal file
218
static/check-pwa-status.html
Normal file
@ -0,0 +1,218 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>PWA Status Check - Talk2Me</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
padding: 20px;
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.status {
|
||||
padding: 10px;
|
||||
margin: 10px 0;
|
||||
border-radius: 5px;
|
||||
}
|
||||
.success {
|
||||
background: #d4edda;
|
||||
color: #155724;
|
||||
}
|
||||
.error {
|
||||
background: #f8d7da;
|
||||
color: #721c24;
|
||||
}
|
||||
.warning {
|
||||
background: #fff3cd;
|
||||
color: #856404;
|
||||
}
|
||||
.info {
|
||||
background: #d1ecf1;
|
||||
color: #0c5460;
|
||||
}
|
||||
button {
|
||||
padding: 10px 20px;
|
||||
margin: 10px 0;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
}
|
||||
pre {
|
||||
background: #f5f5f5;
|
||||
padding: 10px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Talk2Me PWA Status Check</h1>
|
||||
|
||||
<div id="results"></div>
|
||||
|
||||
<h2>Actions</h2>
|
||||
<button onclick="testInstall()">Test Install Prompt</button>
|
||||
<button onclick="clearPWA()">Clear PWA Data</button>
|
||||
<button onclick="location.reload()">Refresh Page</button>
|
||||
|
||||
<script>
|
||||
const results = document.getElementById('results');
|
||||
|
||||
function addResult(message, type = 'info') {
|
||||
const div = document.createElement('div');
|
||||
div.className = `status ${type}`;
|
||||
div.textContent = message;
|
||||
results.appendChild(div);
|
||||
}
|
||||
|
||||
// Check HTTPS
|
||||
if (location.protocol === 'https:' || location.hostname === 'localhost') {
|
||||
addResult('✓ HTTPS enabled', 'success');
|
||||
} else {
|
||||
addResult('✗ HTTPS required for PWA', 'error');
|
||||
}
|
||||
|
||||
// Check Service Worker support
|
||||
if ('serviceWorker' in navigator) {
|
||||
addResult('✓ Service Worker supported', 'success');
|
||||
|
||||
// Check registration
|
||||
navigator.serviceWorker.getRegistration().then(reg => {
|
||||
if (reg) {
|
||||
addResult(`✓ Service Worker registered (scope: ${reg.scope})`, 'success');
|
||||
if (reg.active) {
|
||||
addResult('✓ Service Worker is active', 'success');
|
||||
} else if (reg.installing) {
|
||||
addResult('⚠ Service Worker is installing', 'warning');
|
||||
} else if (reg.waiting) {
|
||||
addResult('⚠ Service Worker is waiting', 'warning');
|
||||
}
|
||||
} else {
|
||||
addResult('✗ Service Worker not registered', 'error');
|
||||
}
|
||||
});
|
||||
} else {
|
||||
addResult('✗ Service Worker not supported', 'error');
|
||||
}
|
||||
|
||||
// Check manifest
|
||||
const manifestLink = document.querySelector('link[rel="manifest"]');
|
||||
if (manifestLink) {
|
||||
addResult('✓ Manifest link found', 'success');
|
||||
|
||||
fetch(manifestLink.href)
|
||||
.then(r => r.json())
|
||||
.then(manifest => {
|
||||
addResult('✓ Manifest loaded successfully', 'success');
|
||||
|
||||
// Check required fields
|
||||
const required = ['name', 'short_name', 'start_url', 'display', 'icons'];
|
||||
required.forEach(field => {
|
||||
if (manifest[field]) {
|
||||
addResult(`✓ Manifest has ${field}`, 'success');
|
||||
} else {
|
||||
addResult(`✗ Manifest missing ${field}`, 'error');
|
||||
}
|
||||
});
|
||||
|
||||
// Check icons
|
||||
if (manifest.icons && manifest.icons.length > 0) {
|
||||
const has192 = manifest.icons.some(i => i.sizes && i.sizes.includes('192'));
|
||||
const has512 = manifest.icons.some(i => i.sizes && i.sizes.includes('512'));
|
||||
|
||||
if (has192) addResult('✓ Has 192x192 icon', 'success');
|
||||
else addResult('✗ Missing 192x192 icon', 'error');
|
||||
|
||||
if (has512) addResult('✓ Has 512x512 icon', 'success');
|
||||
else addResult('⚠ Missing 512x512 icon (recommended)', 'warning');
|
||||
|
||||
// Check icon purposes
|
||||
manifest.icons.forEach((icon, i) => {
|
||||
if (icon.purpose) {
|
||||
addResult(`Icon ${i+1}: purpose="${icon.purpose}"`, 'info');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Show manifest content
|
||||
const pre = document.createElement('pre');
|
||||
pre.textContent = JSON.stringify(manifest, null, 2);
|
||||
results.appendChild(pre);
|
||||
})
|
||||
.catch(err => {
|
||||
addResult(`✗ Failed to load manifest: ${err}`, 'error');
|
||||
});
|
||||
} else {
|
||||
addResult('✗ No manifest link found', 'error');
|
||||
}
|
||||
|
||||
// Check if already installed
|
||||
if (window.matchMedia('(display-mode: standalone)').matches) {
|
||||
addResult('✓ App is running in standalone mode (already installed)', 'success');
|
||||
} else {
|
||||
addResult('App is running in browser mode', 'info');
|
||||
}
|
||||
|
||||
// Listen for install prompt
|
||||
let deferredPrompt;
|
||||
window.addEventListener('beforeinstallprompt', (e) => {
|
||||
e.preventDefault();
|
||||
deferredPrompt = e;
|
||||
addResult('✓ Install prompt is available!', 'success');
|
||||
addResult('Chrome recognizes this as an installable PWA', 'success');
|
||||
});
|
||||
|
||||
// Check Chrome version
|
||||
const userAgent = navigator.userAgent;
|
||||
const chromeMatch = userAgent.match(/Chrome\/(\d+)/);
|
||||
if (chromeMatch) {
|
||||
const version = parseInt(chromeMatch[1]);
|
||||
addResult(`Chrome version: ${version}`, 'info');
|
||||
if (version < 90) {
|
||||
addResult('⚠ Chrome version is old, consider updating', 'warning');
|
||||
}
|
||||
}
|
||||
|
||||
// Test install
|
||||
function testInstall() {
|
||||
if (deferredPrompt) {
|
||||
deferredPrompt.prompt();
|
||||
deferredPrompt.userChoice.then((choiceResult) => {
|
||||
if (choiceResult.outcome === 'accepted') {
|
||||
addResult('✓ User accepted the install prompt', 'success');
|
||||
} else {
|
||||
addResult('User dismissed the install prompt', 'warning');
|
||||
}
|
||||
deferredPrompt = null;
|
||||
});
|
||||
} else {
|
||||
addResult('No install prompt available. Chrome may not recognize this as installable.', 'error');
|
||||
addResult('Try: Menu (⋮) → Add to Home screen', 'info');
|
||||
}
|
||||
}
|
||||
|
||||
// Clear PWA data
|
||||
function clearPWA() {
|
||||
if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.getRegistrations().then(function(registrations) {
|
||||
for(let registration of registrations) {
|
||||
registration.unregister();
|
||||
}
|
||||
addResult('Service Workers unregistered', 'info');
|
||||
});
|
||||
}
|
||||
|
||||
if ('caches' in window) {
|
||||
caches.keys().then(function(names) {
|
||||
for (let name of names) {
|
||||
caches.delete(name);
|
||||
}
|
||||
addResult('Caches cleared', 'info');
|
||||
});
|
||||
}
|
||||
|
||||
addResult('PWA data cleared. Reload the page to re-register.', 'info');
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
@ -422,6 +422,41 @@
|
||||
max-width: 300px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* Make the entire layout more compact on mobile */
|
||||
body {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* Reduce spacing on mobile */
|
||||
.mb-3 {
|
||||
margin-bottom: 0.5rem !important;
|
||||
}
|
||||
|
||||
.mb-4 {
|
||||
margin-bottom: 0.75rem !important;
|
||||
}
|
||||
|
||||
/* Compact cards on mobile */
|
||||
.card {
|
||||
margin-bottom: 8px !important;
|
||||
}
|
||||
|
||||
/* Hide less important elements on small screens */
|
||||
.text-muted.small {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
/* Adjust button sizes */
|
||||
.btn {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
/* Make dropdowns more compact */
|
||||
.form-select {
|
||||
font-size: 0.875rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Streaming translation styles */
|
||||
|
@ -714,6 +714,21 @@ function initApp(): void {
|
||||
language: data.detected_language || sourceLanguage.value,
|
||||
timestamp: new Date().toISOString()
|
||||
} as TranscriptionRecord);
|
||||
|
||||
// Automatically trigger translation
|
||||
setTimeout(async () => {
|
||||
statusIndicator.textContent = 'Automatically translating...';
|
||||
statusIndicator.classList.add('processing');
|
||||
try {
|
||||
await performTranslation();
|
||||
} catch (error) {
|
||||
console.error('Auto-translation failed:', error);
|
||||
statusIndicator.textContent = 'Transcription complete - Translation failed';
|
||||
statusIndicator.classList.remove('processing');
|
||||
statusIndicator.classList.add('warning');
|
||||
setTimeout(() => statusIndicator.classList.remove('warning'), 2000);
|
||||
}
|
||||
}, 500); // Small delay for better UX
|
||||
} else {
|
||||
sourceText.innerHTML = `<p class="text-danger fade-in">Error: ${data.error}</p>`;
|
||||
statusIndicator.textContent = 'Transcription failed';
|
||||
@ -756,8 +771,8 @@ function initApp(): void {
|
||||
}
|
||||
);
|
||||
|
||||
// Translate button click event
|
||||
translateBtn.addEventListener('click', errorBoundary.wrapAsync(async function() {
|
||||
// Function to perform translation (extracted from button handler)
|
||||
const performTranslation = async function(): Promise<void> {
|
||||
if (!currentSourceText) {
|
||||
return;
|
||||
}
|
||||
@ -794,7 +809,10 @@ function initApp(): void {
|
||||
}
|
||||
|
||||
// No cache hit, proceed with API call
|
||||
// Don't update status if already showing 'Automatically translating...'
|
||||
if (!statusIndicator.textContent?.includes('Automatically translating')) {
|
||||
statusIndicator.textContent = 'Translating...';
|
||||
}
|
||||
|
||||
// Use streaming if enabled
|
||||
if (streamingEnabled && navigator.onLine) {
|
||||
@ -992,12 +1010,19 @@ function initApp(): void {
|
||||
statusIndicator.textContent = 'Translation failed';
|
||||
}
|
||||
}
|
||||
}, 'translation', async () => {
|
||||
};
|
||||
|
||||
// Translate button click event (now just calls performTranslation)
|
||||
translateBtn.addEventListener('click', errorBoundary.wrapAsync(
|
||||
performTranslation,
|
||||
'translation',
|
||||
async () => {
|
||||
hideProgress();
|
||||
hideLoadingOverlay();
|
||||
translatedText.innerHTML = '<p class="text-danger">Translation failed. Please try again.</p>';
|
||||
statusIndicator.textContent = 'Translation error - please retry';
|
||||
}));
|
||||
}
|
||||
));
|
||||
|
||||
// Play source text
|
||||
playSource.addEventListener('click', function() {
|
||||
@ -1398,7 +1423,7 @@ function initInstallPrompt(): void {
|
||||
const installButton = document.createElement('button');
|
||||
installButton.style.display = 'none';
|
||||
installButton.classList.add('btn', 'btn-success', 'fixed-bottom', 'm-3');
|
||||
installButton.innerHTML = 'Install Voice Translator <i class="fas fa-download ml-2"></i>';
|
||||
installButton.innerHTML = 'Install Talk2Me <i class="fas fa-download ml-2"></i>';
|
||||
document.body.appendChild(installButton);
|
||||
|
||||
window.addEventListener('beforeinstallprompt', (e: Event) => {
|
||||
|
@ -1,30 +1,42 @@
|
||||
{
|
||||
"name": "Talk2Me",
|
||||
"short_name": "Translator",
|
||||
"description": "Translate spoken language between multiple languages with speech input and output",
|
||||
"short_name": "Talk2Me",
|
||||
"description": "Real-time voice translation app - translate spoken language instantly",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"orientation": "portrait",
|
||||
"background_color": "#ffffff",
|
||||
"theme_color": "#007bff",
|
||||
"icons": [
|
||||
{
|
||||
"src": "./static/icons/icon-192x192.png",
|
||||
"src": "/static/icons/icon-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "./static/icons/icon-512x512.png",
|
||||
"src": "/static/icons/icon-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
}
|
||||
],
|
||||
"screenshots": [
|
||||
"shortcuts": [
|
||||
{
|
||||
"src": "./static/screenshots/screenshot1.png",
|
||||
"sizes": "1280x720",
|
||||
"type": "image/png"
|
||||
"name": "Start Recording",
|
||||
"short_name": "Record",
|
||||
"description": "Start voice recording for translation",
|
||||
"url": "/?action=record",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/static/icons/icon-192x192.png",
|
||||
"sizes": "192x192"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"categories": ["productivity", "utilities", "education"],
|
||||
"prefer_related_applications": false,
|
||||
"related_applications": []
|
||||
}
|
||||
|
41
static/pwa-update.js
Normal file
41
static/pwa-update.js
Normal file
@ -0,0 +1,41 @@
|
||||
// PWA Update Helper
|
||||
// This script helps force PWA updates on clients
|
||||
|
||||
// Force service worker update
|
||||
if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.getRegistrations().then(function(registrations) {
|
||||
for(let registration of registrations) {
|
||||
registration.unregister().then(function() {
|
||||
console.log('Service worker unregistered');
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Re-register after a short delay
|
||||
setTimeout(function() {
|
||||
navigator.serviceWorker.register('/service-worker.js')
|
||||
.then(function(registration) {
|
||||
console.log('Service worker re-registered');
|
||||
registration.update();
|
||||
});
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
// Clear all caches
|
||||
if ('caches' in window) {
|
||||
caches.keys().then(function(names) {
|
||||
for (let name of names) {
|
||||
caches.delete(name);
|
||||
console.log('Cache cleared:', name);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Reload manifest
|
||||
var link = document.querySelector('link[rel="manifest"]');
|
||||
if (link) {
|
||||
link.href = link.href + '?v=' + Date.now();
|
||||
console.log('Manifest reloaded');
|
||||
}
|
||||
|
||||
console.log('PWA update complete. Please reload the page.');
|
@ -1,10 +1,11 @@
|
||||
// Service Worker for Talk2Me PWA
|
||||
|
||||
const CACHE_NAME = 'voice-translator-v1';
|
||||
const CACHE_NAME = 'talk2me-v4'; // Increment version to force update
|
||||
const ASSETS_TO_CACHE = [
|
||||
'/',
|
||||
'/static/manifest.json',
|
||||
'/static/css/styles.css',
|
||||
'/static/js/dist/app.js',
|
||||
'/static/js/dist/app.bundle.js',
|
||||
'/static/icons/icon-192x192.png',
|
||||
'/static/icons/icon-512x512.png',
|
||||
'/static/icons/favicon.ico',
|
||||
|
@ -2,21 +2,107 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
|
||||
<title>Talk2Me</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
|
||||
<link rel="icon" href="/favicon.ico" sizes="any">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no, viewport-fit=cover">
|
||||
<title>Talk2Me - Real-time Voice Translation</title>
|
||||
|
||||
<!-- Icons for various platforms -->
|
||||
<link rel="icon" href="/static/icons/favicon.ico" sizes="any">
|
||||
<link rel="apple-touch-icon" href="/static/icons/apple-icon-180x180.png">
|
||||
<link rel="apple-touch-icon" sizes="120x120" href="/static/icons/apple-icon-120x120.png">
|
||||
<link rel="apple-touch-icon" sizes="152x152" href="/static/icons/apple-icon-152x152.png">
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/static/icons/apple-icon-180x180.png">
|
||||
<link rel="apple-touch-icon" sizes="167x167" href="/static/icons/apple-icon-167x167.png">
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/static/icons/apple-icon-180x180.png">
|
||||
<style>
|
||||
body {
|
||||
padding-top: 20px;
|
||||
padding-bottom: 20px;
|
||||
padding-top: 10px;
|
||||
padding-bottom: 10px;
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
|
||||
/* Mobile-first approach */
|
||||
@media (max-width: 768px) {
|
||||
.container {
|
||||
padding-left: 10px;
|
||||
padding-right: 10px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 0.5rem !important;
|
||||
}
|
||||
|
||||
.card {
|
||||
margin-bottom: 10px !important;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: 0.75rem !important;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
padding: 0.5rem 0.75rem !important;
|
||||
}
|
||||
|
||||
.card-header h5 {
|
||||
font-size: 1rem;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.text-display {
|
||||
min-height: 60px !important;
|
||||
max-height: 100px;
|
||||
overflow-y: auto;
|
||||
padding: 10px !important;
|
||||
margin-bottom: 10px !important;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.language-select {
|
||||
padding: 5px 10px !important;
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 10px !important;
|
||||
}
|
||||
|
||||
.btn-action {
|
||||
padding: 5px 10px !important;
|
||||
font-size: 0.875rem;
|
||||
margin: 2px !important;
|
||||
}
|
||||
|
||||
.record-btn {
|
||||
width: 60px !important;
|
||||
height: 60px !important;
|
||||
font-size: 24px !important;
|
||||
margin: 10px auto !important;
|
||||
}
|
||||
|
||||
.status-indicator {
|
||||
font-size: 0.8rem !important;
|
||||
margin-top: 5px !important;
|
||||
}
|
||||
|
||||
/* Hide speaker toolbar on mobile by default */
|
||||
#speakerToolbar {
|
||||
position: fixed;
|
||||
bottom: 70px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 100;
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
|
||||
#conversationView {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 50%;
|
||||
z-index: 99;
|
||||
border-radius: 15px 15px 0 0 !important;
|
||||
margin: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.record-btn {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
@ -91,19 +177,39 @@
|
||||
font-style: italic;
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
/* Ensure record button area is always visible on mobile */
|
||||
@media (max-width: 768px) {
|
||||
.record-section {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
background: white;
|
||||
padding: 10px 0;
|
||||
box-shadow: 0 -2px 10px rgba(0,0,0,0.1);
|
||||
z-index: 50;
|
||||
margin-left: -10px;
|
||||
margin-right: -10px;
|
||||
padding-left: 10px;
|
||||
padding-right: 10px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- PWA Meta Tags -->
|
||||
<meta name="description" content="Translate spoken language between multiple languages with speech input and output">
|
||||
<meta name="description" content="Real-time voice translation app - translate spoken language instantly">
|
||||
<meta name="theme-color" content="#007bff">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<meta name="apple-mobile-web-app-title" content="Translator">
|
||||
<meta name="apple-mobile-web-app-title" content="Talk2Me">
|
||||
<meta name="application-name" content="Talk2Me">
|
||||
<meta name="msapplication-TileColor" content="#007bff">
|
||||
<meta name="msapplication-TileImage" content="/static/icons/icon-192x192.png">
|
||||
|
||||
<!-- PWA Icons and Manifest -->
|
||||
<link rel="manifest" href="/static/manifest.json">
|
||||
<link rel="icon" type="image/png" href="/static/icons/icon-192x192.png">
|
||||
<link rel="apple-touch-icon" href="/static/icons/apple-icon-180x180.png">
|
||||
<link rel="icon" type="image/png" sizes="192x192" href="/static/icons/icon-192x192.png">
|
||||
<link rel="icon" type="image/png" sizes="512x512" href="/static/icons/icon-512x512.png">
|
||||
|
||||
<!-- Apple Splash Screens -->
|
||||
<link rel="apple-touch-startup-image" href="/static/splash/apple-splash-2048-2732.png" media="(device-width: 1024px) and (device-height: 1366px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)">
|
||||
@ -199,7 +305,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-center">
|
||||
<div class="text-center record-section">
|
||||
<button id="recordBtn" class="btn btn-primary record-btn">
|
||||
<i class="fas fa-microphone"></i>
|
||||
</button>
|
||||
@ -211,12 +317,13 @@
|
||||
<i class="fas fa-sync"></i> Active: <span id="activeRequests">0</span>
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-center mt-3">
|
||||
<button id="translateBtn" class="btn btn-success" disabled>
|
||||
<i class="fas fa-language"></i> Translate
|
||||
<div class="mt-2">
|
||||
<button id="translateBtn" class="btn btn-outline-secondary btn-sm" disabled title="Translation happens automatically after transcription">
|
||||
<i class="fas fa-redo"></i> Re-translate
|
||||
</button>
|
||||
<small class="text-muted d-block mt-1">Translation happens automatically after transcription</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3">
|
||||
@ -399,6 +506,6 @@
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script src="/static/js/dist/app.js"></script>
|
||||
<script src="/static/js/dist/app.bundle.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
121
validate-pwa.html
Normal file
121
validate-pwa.html
Normal file
@ -0,0 +1,121 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>PWA Validation - Talk2Me</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; padding: 20px; }
|
||||
.status { padding: 10px; margin: 10px 0; border-radius: 5px; }
|
||||
.success { background: #d4edda; color: #155724; }
|
||||
.error { background: #f8d7da; color: #721c24; }
|
||||
.info { background: #d1ecf1; color: #0c5460; }
|
||||
img { max-width: 100px; height: auto; margin: 10px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Talk2Me PWA Validation</h1>
|
||||
|
||||
<h2>Manifest Check</h2>
|
||||
<div id="manifest-status"></div>
|
||||
|
||||
<h2>Icon Check</h2>
|
||||
<div id="icon-status"></div>
|
||||
|
||||
<h2>Service Worker Check</h2>
|
||||
<div id="sw-status"></div>
|
||||
|
||||
<h2>Installation Test</h2>
|
||||
<button id="install-btn" style="display:none; padding: 10px 20px; font-size: 16px;">Install Talk2Me</button>
|
||||
<div id="install-status"></div>
|
||||
|
||||
<script>
|
||||
// Check manifest
|
||||
fetch('/static/manifest.json')
|
||||
.then(res => res.json())
|
||||
.then(manifest => {
|
||||
const status = document.getElementById('manifest-status');
|
||||
status.innerHTML = `
|
||||
<div class="status success">✓ Manifest loaded successfully</div>
|
||||
<div class="status info">Name: ${manifest.name}</div>
|
||||
<div class="status info">Short Name: ${manifest.short_name}</div>
|
||||
<div class="status info">Icons: ${manifest.icons.length} defined</div>
|
||||
`;
|
||||
|
||||
// Check icons
|
||||
const iconStatus = document.getElementById('icon-status');
|
||||
manifest.icons.forEach(icon => {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'status success';
|
||||
div.innerHTML = `✓ ${icon.src} (${icon.sizes}) - ${icon.purpose}`;
|
||||
iconStatus.appendChild(div);
|
||||
iconStatus.appendChild(img);
|
||||
};
|
||||
img.onerror = () => {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'status error';
|
||||
div.innerHTML = `✗ ${icon.src} (${icon.sizes}) - Failed to load`;
|
||||
iconStatus.appendChild(div);
|
||||
};
|
||||
img.src = icon.src;
|
||||
img.style.maxWidth = '50px';
|
||||
});
|
||||
})
|
||||
.catch(err => {
|
||||
document.getElementById('manifest-status').innerHTML =
|
||||
`<div class="status error">✗ Failed to load manifest: ${err.message}</div>`;
|
||||
});
|
||||
|
||||
// Check service worker
|
||||
if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.getRegistration()
|
||||
.then(reg => {
|
||||
const status = document.getElementById('sw-status');
|
||||
if (reg) {
|
||||
status.innerHTML = `
|
||||
<div class="status success">✓ Service Worker is registered</div>
|
||||
<div class="status info">Scope: ${reg.scope}</div>
|
||||
<div class="status info">State: ${reg.active ? 'Active' : 'Not Active'}</div>
|
||||
`;
|
||||
} else {
|
||||
status.innerHTML = '<div class="status error">✗ Service Worker not registered</div>';
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
document.getElementById('sw-status').innerHTML =
|
||||
`<div class="status error">✗ Service Worker error: ${err.message}</div>`;
|
||||
});
|
||||
} else {
|
||||
document.getElementById('sw-status').innerHTML =
|
||||
'<div class="status error">✗ Service Workers not supported</div>';
|
||||
}
|
||||
|
||||
// Check install prompt
|
||||
let deferredPrompt;
|
||||
window.addEventListener('beforeinstallprompt', (e) => {
|
||||
e.preventDefault();
|
||||
deferredPrompt = e;
|
||||
document.getElementById('install-btn').style.display = 'block';
|
||||
document.getElementById('install-status').innerHTML =
|
||||
'<div class="status success">✓ App is installable</div>';
|
||||
});
|
||||
|
||||
document.getElementById('install-btn').addEventListener('click', async () => {
|
||||
if (deferredPrompt) {
|
||||
deferredPrompt.prompt();
|
||||
const { outcome } = await deferredPrompt.userChoice;
|
||||
document.getElementById('install-status').innerHTML +=
|
||||
`<div class="status info">User ${outcome} the install</div>`;
|
||||
deferredPrompt = null;
|
||||
}
|
||||
});
|
||||
|
||||
// Check if already installed
|
||||
if (window.matchMedia('(display-mode: standalone)').matches) {
|
||||
document.getElementById('install-status').innerHTML =
|
||||
'<div class="status success">✓ App is already installed</div>';
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
23
webpack.config.js
Normal file
23
webpack.config.js
Normal file
@ -0,0 +1,23 @@
|
||||
const path = require('path');
|
||||
|
||||
module.exports = {
|
||||
entry: './static/js/src/app.ts',
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
test: /\.tsx?$/,
|
||||
use: 'ts-loader',
|
||||
exclude: /node_modules/,
|
||||
},
|
||||
],
|
||||
},
|
||||
resolve: {
|
||||
extensions: ['.tsx', '.ts', '.js'],
|
||||
},
|
||||
output: {
|
||||
filename: 'app.bundle.js',
|
||||
path: path.resolve(__dirname, 'static/js/dist'),
|
||||
},
|
||||
mode: 'production',
|
||||
devtool: 'source-map',
|
||||
};
|
Loading…
Reference in New Issue
Block a user