606 lines
21 KiB
HTML
606 lines
21 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Textpipe</title>
|
|
<style>
|
|
:root {
|
|
--bg-primary: #1B1B1B;
|
|
--bg-secondary: #292929;
|
|
--bg-card: #333333;
|
|
--accent: #FFA31A;
|
|
--text-primary: #FFFFFF;
|
|
--text-secondary: #AAAAAA;
|
|
--error: #FF5252;
|
|
--success: #4CAF50;
|
|
}
|
|
|
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
|
|
body {
|
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
background: var(--bg-primary);
|
|
color: var(--text-primary);
|
|
min-height: 100vh;
|
|
}
|
|
|
|
/* Login */
|
|
#login-screen {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
min-height: 100vh;
|
|
padding: 20px;
|
|
}
|
|
|
|
#login-screen h1 { color: var(--accent); margin-bottom: 8px; font-size: 2.5rem; }
|
|
#login-screen .subtitle { color: var(--text-secondary); margin-bottom: 30px; }
|
|
|
|
.login-form { width: 100%; max-width: 360px; }
|
|
|
|
input, textarea {
|
|
width: 100%;
|
|
padding: 14px 16px;
|
|
border: 1px solid #444;
|
|
border-radius: 8px;
|
|
background: var(--bg-secondary);
|
|
color: var(--text-primary);
|
|
font-size: 1rem;
|
|
margin-bottom: 12px;
|
|
}
|
|
|
|
input:focus, textarea:focus { outline: none; border-color: var(--accent); }
|
|
|
|
button {
|
|
width: 100%;
|
|
padding: 14px;
|
|
border: none;
|
|
border-radius: 8px;
|
|
background: var(--accent);
|
|
color: #000;
|
|
font-size: 1rem;
|
|
font-weight: 600;
|
|
cursor: pointer;
|
|
}
|
|
|
|
button:hover { opacity: 0.9; }
|
|
.error-msg { color: var(--error); margin-top: 10px; text-align: center; font-size: 0.9rem; }
|
|
|
|
/* Main App */
|
|
#main-app { display: none; height: 100vh; display: none; flex-direction: column; }
|
|
|
|
/* Header */
|
|
header {
|
|
background: var(--bg-secondary);
|
|
padding: 12px 16px;
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
border-bottom: 1px solid #444;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.header-left { display: flex; align-items: center; gap: 12px; }
|
|
.header-left h1 { color: var(--accent); font-size: 1.3rem; }
|
|
.sim-info { color: var(--text-secondary); font-size: 0.85rem; }
|
|
|
|
.header-right { display: flex; align-items: center; gap: 8px; }
|
|
|
|
.icon-btn {
|
|
background: transparent;
|
|
color: var(--text-secondary);
|
|
width: 40px;
|
|
height: 40px;
|
|
padding: 0;
|
|
border-radius: 50%;
|
|
font-size: 1.2rem;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
|
|
.icon-btn:hover { background: rgba(255,255,255,0.1); color: var(--text-primary); }
|
|
|
|
/* Conversations */
|
|
#conversations {
|
|
flex: 1;
|
|
overflow-y: auto;
|
|
padding: 8px;
|
|
}
|
|
|
|
.conversation-item {
|
|
display: flex;
|
|
align-items: center;
|
|
padding: 14px;
|
|
background: var(--bg-card);
|
|
border-radius: 12px;
|
|
margin-bottom: 8px;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.conversation-item:hover { background: #3a3a3a; }
|
|
|
|
.avatar {
|
|
width: 48px;
|
|
height: 48px;
|
|
border-radius: 50%;
|
|
background: var(--accent);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
font-weight: 600;
|
|
color: #000;
|
|
margin-right: 14px;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.conv-info { flex: 1; min-width: 0; }
|
|
.conv-address { font-weight: 600; margin-bottom: 2px; }
|
|
.conv-number { color: var(--text-secondary); font-size: 0.8rem; margin-bottom: 2px; }
|
|
.conv-preview { color: var(--text-secondary); font-size: 0.9rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
.conv-time { color: var(--text-secondary); font-size: 0.8rem; flex-shrink: 0; margin-left: 10px; }
|
|
|
|
.empty-state { text-align: center; padding: 60px 20px; color: var(--text-secondary); }
|
|
|
|
/* FAB */
|
|
.fab {
|
|
position: fixed;
|
|
bottom: 24px;
|
|
right: 24px;
|
|
width: 56px;
|
|
height: 56px;
|
|
border-radius: 50%;
|
|
background: var(--accent);
|
|
color: #000;
|
|
font-size: 1.8rem;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
box-shadow: 0 4px 12px rgba(0,0,0,0.4);
|
|
cursor: pointer;
|
|
border: none;
|
|
}
|
|
|
|
.fab:hover { transform: scale(1.05); }
|
|
|
|
/* Thread View */
|
|
#thread-view {
|
|
display: none;
|
|
flex-direction: column;
|
|
height: 100vh;
|
|
}
|
|
|
|
.thread-header {
|
|
background: var(--bg-secondary);
|
|
padding: 12px 16px;
|
|
display: flex;
|
|
align-items: center;
|
|
border-bottom: 1px solid #444;
|
|
}
|
|
|
|
.back-btn {
|
|
background: transparent;
|
|
color: var(--text-primary);
|
|
width: 40px;
|
|
padding: 0;
|
|
font-size: 1.4rem;
|
|
margin-right: 12px;
|
|
}
|
|
|
|
#thread-address { line-height: 1.3; }
|
|
|
|
.thread-messages {
|
|
flex: 1;
|
|
overflow-y: auto;
|
|
padding: 16px;
|
|
}
|
|
|
|
.message-bubble {
|
|
max-width: 80%;
|
|
padding: 10px 14px;
|
|
border-radius: 18px;
|
|
margin-bottom: 8px;
|
|
word-wrap: break-word;
|
|
}
|
|
|
|
.msg-in { background: var(--bg-card); border-bottom-left-radius: 4px; }
|
|
.msg-out { background: var(--accent); color: #000; margin-left: auto; border-bottom-right-radius: 4px; }
|
|
.msg-time { font-size: 0.7rem; opacity: 0.7; margin-top: 4px; }
|
|
|
|
.thread-compose {
|
|
padding: 12px;
|
|
background: var(--bg-secondary);
|
|
display: flex;
|
|
gap: 10px;
|
|
border-top: 1px solid #444;
|
|
}
|
|
|
|
.thread-compose input {
|
|
flex: 1;
|
|
margin: 0;
|
|
border-radius: 24px;
|
|
}
|
|
|
|
.send-btn {
|
|
width: 48px;
|
|
height: 48px;
|
|
border-radius: 50%;
|
|
padding: 0;
|
|
font-size: 1.2rem;
|
|
}
|
|
|
|
/* Compose Modal */
|
|
.modal-overlay {
|
|
display: none;
|
|
position: fixed;
|
|
top: 0; left: 0; right: 0; bottom: 0;
|
|
background: rgba(0,0,0,0.7);
|
|
align-items: center;
|
|
justify-content: center;
|
|
z-index: 100;
|
|
}
|
|
|
|
|
|
.input-hint {
|
|
display: block;
|
|
margin-top: -8px;
|
|
margin-bottom: 12px;
|
|
font-size: 12px;
|
|
color: #888;
|
|
}
|
|
|
|
.modal {
|
|
background: var(--bg-secondary);
|
|
border-radius: 16px;
|
|
padding: 24px;
|
|
width: 90%;
|
|
max-width: 400px;
|
|
}
|
|
|
|
.modal h2 { color: var(--accent); margin-bottom: 20px; }
|
|
.modal textarea { min-height: 100px; resize: vertical; }
|
|
.modal-actions { display: flex; gap: 10px; margin-top: 16px; }
|
|
.modal-actions button { flex: 1; }
|
|
|
|
.cancel-btn {
|
|
background: transparent;
|
|
color: var(--text-secondary);
|
|
border: 1px solid #444;
|
|
}
|
|
|
|
/* About Modal */
|
|
.about-content { color: var(--text-secondary); line-height: 1.6; }
|
|
.about-content h2 { color: var(--accent); margin-bottom: 8px; }
|
|
.about-content a { color: var(--accent); }
|
|
.about-content ul { margin: 16px 0 16px 20px; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<!-- Login -->
|
|
<div id="login-screen">
|
|
<h1>Textpipe</h1>
|
|
<p class="subtitle">SMS Gateway</p>
|
|
<div class="login-form">
|
|
<input type="password" id="api-key" placeholder="Enter API Key">
|
|
<button onclick="login()">Login</button>
|
|
<p class="error-msg" id="login-error"></p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Main App -->
|
|
<div id="main-app">
|
|
<header>
|
|
<div class="header-left">
|
|
<h1>Textpipe</h1>
|
|
<span class="sim-info" id="sim-info">Loading...</span>
|
|
</div>
|
|
<div class="header-right">
|
|
<button class="icon-btn" onclick="showAbout()" title="About">i</button>
|
|
<button class="icon-btn" onclick="logout()" title="Logout">x</button>
|
|
</div>
|
|
</header>
|
|
|
|
<div id="conversations"></div>
|
|
|
|
<button class="fab" onclick="showCompose()">+</button>
|
|
</div>
|
|
|
|
<!-- Thread View -->
|
|
<div id="thread-view">
|
|
<div class="thread-header">
|
|
<button class="back-btn" onclick="closeThread()">←</button>
|
|
<span id="thread-address"></span>
|
|
</div>
|
|
<div class="thread-messages" id="thread-messages"></div>
|
|
<div class="thread-compose">
|
|
<input type="text" id="thread-input" placeholder="Message" onkeypress="if(event.key==='Enter')sendInThread()">
|
|
<button class="send-btn" onclick="sendInThread()">→</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Compose Modal -->
|
|
<div class="modal-overlay" id="compose-modal" onclick="if(event.target===this)closeCompose()">
|
|
<div class="modal">
|
|
<h2>New Message</h2>
|
|
<input type="text" id="compose-to" placeholder="Phone numbers, separated by commas" autocomplete="off">
|
|
<small class="input-hint"> Enter multiple phone numbers separated by commas </small>
|
|
<textarea id="compose-msg" placeholder="Message"></textarea>
|
|
<p class="error-msg" id="compose-error"></p>
|
|
<div class="modal-actions">
|
|
<button class="cancel-btn" onclick="closeCompose()">Cancel</button>
|
|
<button onclick="sendCompose()">Send</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- About Modal -->
|
|
<div class="modal-overlay" id="about-modal" onclick="if(event.target===this)closeAbout()">
|
|
<div class="modal about-content">
|
|
<h2>Textpipe</h2>
|
|
<p>SMS API Gateway for Android</p>
|
|
<p style="margin-top:16px"><a href="https://git.shihaam.dev/shihaam/textpipe" target="_blank">git.shihaam.dev/shihaam/textpipe</a></p>
|
|
<div class="modal-actions" style="margin-top:24px">
|
|
<button onclick="closeAbout()">Close</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
let apiKey = localStorage.getItem('apiKey') || '';
|
|
let allMessages = [];
|
|
let currentAddress = '';
|
|
|
|
if (apiKey) checkAuth();
|
|
|
|
async function login() {
|
|
apiKey = document.getElementById('api-key').value.trim();
|
|
if (!apiKey) { document.getElementById('login-error').textContent = 'Enter API key'; return; }
|
|
|
|
try {
|
|
const r = await fetch('/api/status', { headers: { 'X-API-Key': apiKey } });
|
|
if (r.ok) {
|
|
localStorage.setItem('apiKey', apiKey);
|
|
showApp();
|
|
} else {
|
|
document.getElementById('login-error').textContent = 'Invalid API key';
|
|
}
|
|
} catch (e) { document.getElementById('login-error').textContent = 'Connection error'; }
|
|
}
|
|
|
|
async function checkAuth() {
|
|
try {
|
|
const r = await fetch('/api/status', { headers: { 'X-API-Key': apiKey } });
|
|
if (r.ok) showApp();
|
|
else logout();
|
|
} catch (e) { logout(); }
|
|
}
|
|
|
|
function showApp() {
|
|
document.getElementById('login-screen').style.display = 'none';
|
|
document.getElementById('main-app').style.display = 'flex';
|
|
loadStatus();
|
|
loadMessages();
|
|
}
|
|
|
|
function logout() {
|
|
apiKey = '';
|
|
localStorage.removeItem('apiKey');
|
|
document.getElementById('login-screen').style.display = 'flex';
|
|
document.getElementById('main-app').style.display = 'none';
|
|
document.getElementById('thread-view').style.display = 'none';
|
|
document.getElementById('api-key').value = '';
|
|
}
|
|
|
|
async function loadStatus() {
|
|
try {
|
|
const r = await fetch('/api/status', { headers: { 'X-API-Key': apiKey } });
|
|
const sim = await r.json();
|
|
document.getElementById('sim-info').textContent = sim.slot !== undefined
|
|
? `SIM ${sim.slot + 1} - ${sim.number || sim.carrier || 'Unknown'}`
|
|
: 'No SIM';
|
|
} catch (e) {}
|
|
}
|
|
|
|
async function loadMessages() {
|
|
try {
|
|
const r = await fetch('/api/sms/messages', { headers: { 'X-API-Key': apiKey } });
|
|
const d = await r.json();
|
|
allMessages = d.messages || [];
|
|
renderConversations();
|
|
} catch (e) { console.error('Failed to load messages:', e); }
|
|
}
|
|
|
|
function renderConversations() {
|
|
const grouped = {};
|
|
allMessages.forEach(m => {
|
|
if (!grouped[m.address]) grouped[m.address] = { msgs: [], contactName: m.contactName };
|
|
grouped[m.address].msgs.push(m);
|
|
// Use first non-null contact name
|
|
if (m.contactName && !grouped[m.address].contactName) {
|
|
grouped[m.address].contactName = m.contactName;
|
|
}
|
|
});
|
|
|
|
const convs = Object.entries(grouped).map(([addr, data]) => {
|
|
data.msgs.sort((a, b) => b.timestamp - a.timestamp);
|
|
return { address: addr, contactName: data.contactName, latest: data.msgs[0] };
|
|
}).sort((a, b) => b.latest.timestamp - a.latest.timestamp);
|
|
|
|
const el = document.getElementById('conversations');
|
|
if (!convs.length) {
|
|
el.innerHTML = '<div class="empty-state">No messages yet<br>Tap + to send one</div>';
|
|
return;
|
|
}
|
|
|
|
el.innerHTML = convs.map(c => {
|
|
const displayName = c.contactName || c.address;
|
|
const avatarText = c.contactName ? c.contactName.slice(0, 2).toUpperCase() : c.address.slice(-2);
|
|
return `
|
|
<div class="conversation-item" onclick="openThread('${c.address}')">
|
|
<div class="avatar">${avatarText}</div>
|
|
<div class="conv-info">
|
|
<div class="conv-address">${displayName}</div>
|
|
${c.contactName ? `<div class="conv-number">${c.address}</div>` : ''}
|
|
<div class="conv-preview">${c.latest.text}</div>
|
|
</div>
|
|
<span class="conv-time">${formatTime(c.latest.timestamp)}</span>
|
|
</div>
|
|
`}).join('');
|
|
}
|
|
|
|
function openThread(addr) {
|
|
currentAddress = addr;
|
|
document.getElementById('main-app').style.display = 'none';
|
|
document.getElementById('thread-view').style.display = 'flex';
|
|
|
|
// Find contact name for this address
|
|
const contactMsg = allMessages.find(m => m.address === addr && m.contactName);
|
|
const displayName = contactMsg?.contactName || addr;
|
|
document.getElementById('thread-address').innerHTML = contactMsg?.contactName
|
|
? `${displayName}<br><span style="font-size:0.8rem;color:var(--text-secondary)">${addr}</span>`
|
|
: displayName;
|
|
|
|
const msgs = allMessages.filter(m => m.address === addr).sort((a,b) => a.timestamp - b.timestamp);
|
|
const el = document.getElementById('thread-messages');
|
|
el.innerHTML = msgs.map(m => `
|
|
<div class="message-bubble ${m.type === 'received' ? 'msg-in' : 'msg-out'}">
|
|
${m.text}
|
|
<div class="msg-time">${formatTime(m.timestamp)}</div>
|
|
</div>
|
|
`).join('');
|
|
el.scrollTop = el.scrollHeight;
|
|
}
|
|
|
|
function closeThread() {
|
|
document.getElementById('thread-view').style.display = 'none';
|
|
document.getElementById('main-app').style.display = 'flex';
|
|
currentAddress = '';
|
|
}
|
|
|
|
async function sendInThread() {
|
|
const input = document.getElementById('thread-input');
|
|
const text = input.value.trim();
|
|
if (!text) return;
|
|
|
|
try {
|
|
await fetch('/api/sms/send', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', 'X-API-Key': apiKey },
|
|
body: JSON.stringify({ to: currentAddress, text })
|
|
});
|
|
input.value = '';
|
|
await loadMessages();
|
|
openThread(currentAddress);
|
|
} catch (e) {}
|
|
}
|
|
|
|
function showCompose() { document.getElementById('compose-modal').style.display = 'flex'; }
|
|
function closeCompose() {
|
|
document.getElementById('compose-modal').style.display = 'none';
|
|
document.getElementById('compose-to').value = '';
|
|
document.getElementById('compose-msg').value = '';
|
|
document.getElementById('compose-error').textContent = '';
|
|
}
|
|
|
|
async function sendCompose() {
|
|
const toInput = document.getElementById('compose-to').value.trim();
|
|
const text = document.getElementById('compose-msg').value.trim();
|
|
const error = document.getElementById('compose-error');
|
|
|
|
error.textContent = '';
|
|
|
|
if (!toInput) {
|
|
error.textContent = 'Please enter at least one phone number';
|
|
return;
|
|
}
|
|
|
|
if (!text) {
|
|
error.textContent = 'Please enter a message';
|
|
return;
|
|
}
|
|
|
|
// Split numbers by comma
|
|
const recipients = toInput
|
|
.split(',')
|
|
.map(number => number.trim())
|
|
.filter(number => number.length > 0);
|
|
|
|
// Remove duplicate numbers
|
|
const uniqueRecipients = [...new Set(recipients)];
|
|
|
|
if (uniqueRecipients.length === 0) {
|
|
error.textContent = 'Please enter at least one phone number';
|
|
return;
|
|
}
|
|
|
|
// Basic phone number validation
|
|
const invalidNumber = uniqueRecipients.find(number => {
|
|
return !/^\+?[0-9\s\-()]+$/.test(number);
|
|
});
|
|
|
|
if (invalidNumber) {
|
|
error.textContent = `Invalid phone number: ${invalidNumber}`;
|
|
return;
|
|
}
|
|
|
|
const sendButton = document.querySelector(
|
|
'#compose-modal .modal-actions button:last-child'
|
|
);
|
|
|
|
sendButton.disabled = true;
|
|
sendButton.textContent = 'Sending...';
|
|
|
|
try {
|
|
const response = await fetch('/api/sms/send', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-API-Key': apiKey
|
|
},
|
|
body: JSON.stringify({
|
|
to: uniqueRecipients,
|
|
text: text
|
|
})
|
|
});
|
|
|
|
const data = await response.json();
|
|
|
|
if (!response.ok) {
|
|
throw new Error(data.error || 'Failed to send SMS');
|
|
}
|
|
|
|
closeCompose();
|
|
|
|
await loadMessages();
|
|
|
|
// Open the conversation when sending to one number
|
|
if (uniqueRecipients.length === 1) {
|
|
openThread(uniqueRecipients[0]);
|
|
}
|
|
|
|
} catch (err) {
|
|
error.textContent = err.message || 'Failed to send SMS';
|
|
} finally {
|
|
sendButton.disabled = false;
|
|
sendButton.textContent = 'Send';
|
|
}
|
|
}
|
|
function showAbout() { document.getElementById('about-modal').style.display = 'flex'; }
|
|
function closeAbout() { document.getElementById('about-modal').style.display = 'none'; }
|
|
|
|
function formatTime(ts) {
|
|
const d = new Date(ts), now = new Date();
|
|
const diff = Math.floor((now - d) / 86400000);
|
|
if (diff === 0) return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
|
if (diff === 1) return 'Yesterday';
|
|
if (diff < 7) return d.toLocaleDateString([], { weekday: 'short' });
|
|
return d.toLocaleDateString([], { month: 'short', day: 'numeric' });
|
|
}
|
|
|
|
setInterval(() => { if (apiKey) { loadMessages(); } }, 15000);
|
|
</script>
|
|
</body>
|
|
</html
|