add contact name spport and upadtes to webui
This commit is contained in:
35
README.md
35
README.md
@@ -10,7 +10,7 @@ An Android app that turns your phone into an SMS API gateway with a web interfac
|
||||
- **Local Database** - All messages stored locally with delivery tracking
|
||||
- **Background Service** - Runs as a foreground service with persistent notification
|
||||
- **Auto-start** - Optionally start on device boot
|
||||
- **Root Port Redirect** - Use ports 80/443 with root access via iptables
|
||||
- **Root Support** - Bind directly to ports 80/443 with root access
|
||||
|
||||
## Requirements
|
||||
|
||||
@@ -25,32 +25,22 @@ An Android app that turns your phone into an SMS API gateway with a web interfac
|
||||
|
||||
- Android Studio or command line with Android SDK
|
||||
- JDK 11+
|
||||
- Node.js 18+ (for web UI)
|
||||
|
||||
### Build Steps
|
||||
|
||||
1. **Clone the repository**
|
||||
```bash
|
||||
git clone <repo-url>
|
||||
git clone https://git.shihaam.dev/shihaam/textpipe
|
||||
cd textpipe
|
||||
```
|
||||
|
||||
2. **Build the Web UI**
|
||||
```bash
|
||||
cd webui
|
||||
npm install
|
||||
npm run build
|
||||
cd ..
|
||||
```
|
||||
This builds the Vue frontend to `app/src/main/assets/webui/`
|
||||
|
||||
3. **Build the Android App**
|
||||
2. **Build the Android App**
|
||||
```bash
|
||||
./gradlew assembleDebug
|
||||
```
|
||||
The APK will be at `app/build/outputs/apk/debug/app-debug.apk`
|
||||
|
||||
4. **Install on device**
|
||||
3. **Install on device**
|
||||
```bash
|
||||
adb install app/build/outputs/apk/debug/app-debug.apk
|
||||
```
|
||||
@@ -190,7 +180,7 @@ curl -H "X-API-Key: your-api-key-here" \
|
||||
- Default port: 8080
|
||||
- Can be changed in the app UI
|
||||
- For ports below 1024 (like 80 or 443), root access is required
|
||||
- With root, the app uses iptables to redirect traffic from the privileged port to the actual server port
|
||||
- With root, the app uses sysctl to allow binding to privileged ports directly
|
||||
|
||||
### Auto-start
|
||||
|
||||
@@ -228,16 +218,9 @@ textpipe/
|
||||
│ │ ├── sim/ # SIM management
|
||||
│ │ ├── root/ # Root utilities
|
||||
│ │ └── ui/ # Compose UI
|
||||
│ ├── assets/webui/ # Built web UI
|
||||
│ ├── assets/webui/ # Web UI (vanilla HTML/CSS/JS)
|
||||
│ │ └── index.html
|
||||
│ └── AndroidManifest.xml
|
||||
├── webui/ # Vue.js web interface
|
||||
│ ├── src/
|
||||
│ │ ├── App.vue
|
||||
│ │ ├── api.js
|
||||
│ │ ├── style.css
|
||||
│ │ └── components/
|
||||
│ ├── package.json
|
||||
│ └── vite.config.js
|
||||
├── gradle/
|
||||
│ └── libs.versions.toml # Dependency versions
|
||||
└── build.gradle.kts
|
||||
@@ -254,9 +237,7 @@ textpipe/
|
||||
- kotlinx.serialization (JSON)
|
||||
|
||||
### Web UI
|
||||
- Vue 3
|
||||
- Vite
|
||||
- Vanilla CSS
|
||||
- Vanilla HTML/CSS/JS (no build step required)
|
||||
|
||||
## Message Status Flow
|
||||
|
||||
|
||||
@@ -11,6 +11,9 @@
|
||||
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
|
||||
<uses-permission android:name="android.permission.READ_PHONE_NUMBERS" />
|
||||
|
||||
<!-- Contacts -->
|
||||
<uses-permission android:name="android.permission.READ_CONTACTS" />
|
||||
|
||||
<!-- Network -->
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,14 +1,535 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Textpipe SMS Gateway</title>
|
||||
<script type="module" crossorigin src="./assets/index-BR9M1eFw.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-jwVA1VU4.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
</body>
|
||||
<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;
|
||||
}
|
||||
|
||||
.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="tel" id="compose-to" placeholder="Phone number">
|
||||
<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 d = await r.json();
|
||||
const sim = d.sims && d.sims[0];
|
||||
document.getElementById('sim-info').textContent = sim
|
||||
? `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 to = document.getElementById('compose-to').value.trim();
|
||||
const text = document.getElementById('compose-msg').value.trim();
|
||||
if (!to || !text) { document.getElementById('compose-error').textContent = 'Fill all fields'; return; }
|
||||
|
||||
try {
|
||||
const r = await fetch('/api/sms/send', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-API-Key': apiKey },
|
||||
body: JSON.stringify({ to, text })
|
||||
});
|
||||
if (r.ok) {
|
||||
closeCompose();
|
||||
await loadMessages();
|
||||
openThread(to);
|
||||
} else {
|
||||
const e = await r.json();
|
||||
document.getElementById('compose-error').textContent = e.error || 'Failed';
|
||||
}
|
||||
} catch (e) { document.getElementById('compose-error').textContent = 'Error'; }
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
@@ -23,6 +23,7 @@ class MainActivity : ComponentActivity() {
|
||||
add(Manifest.permission.RECEIVE_SMS)
|
||||
add(Manifest.permission.READ_SMS)
|
||||
add(Manifest.permission.READ_PHONE_STATE)
|
||||
add(Manifest.permission.READ_CONTACTS)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
add(Manifest.permission.READ_PHONE_NUMBERS)
|
||||
}
|
||||
|
||||
48
app/src/main/java/sh/sar/textpipe/contacts/ContactsHelper.kt
Normal file
48
app/src/main/java/sh/sar/textpipe/contacts/ContactsHelper.kt
Normal file
@@ -0,0 +1,48 @@
|
||||
package sh.sar.textpipe.contacts
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.provider.ContactsContract
|
||||
|
||||
class ContactsHelper(private val context: Context) {
|
||||
|
||||
private val cache = mutableMapOf<String, String?>()
|
||||
|
||||
fun getContactName(phoneNumber: String): String? {
|
||||
// Check cache first
|
||||
cache[phoneNumber]?.let { return it }
|
||||
|
||||
val name = lookupContactName(phoneNumber)
|
||||
cache[phoneNumber] = name
|
||||
return name
|
||||
}
|
||||
|
||||
private fun lookupContactName(phoneNumber: String): String? {
|
||||
return try {
|
||||
val uri = Uri.withAppendedPath(
|
||||
ContactsContract.PhoneLookup.CONTENT_FILTER_URI,
|
||||
Uri.encode(phoneNumber)
|
||||
)
|
||||
|
||||
context.contentResolver.query(
|
||||
uri,
|
||||
arrayOf(ContactsContract.PhoneLookup.DISPLAY_NAME),
|
||||
null,
|
||||
null,
|
||||
null
|
||||
)?.use { cursor ->
|
||||
if (cursor.moveToFirst()) {
|
||||
cursor.getString(0)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun clearCache() {
|
||||
cache.clear()
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@ data class SmsMessageResponse(
|
||||
val id: Long,
|
||||
val type: String,
|
||||
val address: String,
|
||||
val contactName: String? = null,
|
||||
val text: String,
|
||||
val simSlot: Int,
|
||||
val status: String,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package sh.sar.textpipe.data.repository
|
||||
|
||||
import sh.sar.textpipe.contacts.ContactsHelper
|
||||
import sh.sar.textpipe.data.db.SmsMessageDao
|
||||
import sh.sar.textpipe.data.db.SmsMessageEntity
|
||||
import sh.sar.textpipe.data.model.SendSmsResponse
|
||||
@@ -10,7 +11,8 @@ import sh.sar.textpipe.sim.SimManager
|
||||
class SmsRepository(
|
||||
private val dao: SmsMessageDao,
|
||||
private val smsSender: SmsSender,
|
||||
private val simManager: SimManager
|
||||
private val simManager: SimManager,
|
||||
private val contactsHelper: ContactsHelper
|
||||
) {
|
||||
suspend fun sendSms(to: String, text: String, apiKey: String): SendSmsResponse? {
|
||||
val subscriptionId = simManager.getSubscriptionIdForApiKey(apiKey) ?: return null
|
||||
@@ -62,6 +64,7 @@ class SmsRepository(
|
||||
id = id,
|
||||
type = type,
|
||||
address = address,
|
||||
contactName = contactsHelper.getContactName(address),
|
||||
text = text,
|
||||
simSlot = simSlot,
|
||||
status = status,
|
||||
|
||||
@@ -24,6 +24,7 @@ import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
import sh.sar.textpipe.MainActivity
|
||||
import sh.sar.textpipe.TextpipeApplication
|
||||
import sh.sar.textpipe.contacts.ContactsHelper
|
||||
import sh.sar.textpipe.data.repository.SmsRepository
|
||||
import sh.sar.textpipe.root.RootManager
|
||||
import sh.sar.textpipe.server.ServerStartResult
|
||||
@@ -125,7 +126,8 @@ class TextpipeService : Service() {
|
||||
smsSender = SmsSender(this, dao)
|
||||
smsSender.register()
|
||||
|
||||
smsRepository = SmsRepository(dao, smsSender, simManager)
|
||||
val contactsHelper = ContactsHelper(this)
|
||||
smsRepository = SmsRepository(dao, smsSender, simManager, contactsHelper)
|
||||
textpipeServer = TextpipeServer(this, smsRepository, simManager)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Textpipe SMS Gateway</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
1207
webui/package-lock.json
generated
1207
webui/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1 +0,0 @@
|
||||
{"name":"textpipe-webui","version":"1.0.0","private":true,"type":"module","scripts":{"dev":"vite","build":"vite build","preview":"vite preview"},"dependencies":{"vue":"^3.4.0"},"devDependencies":{"@vitejs/plugin-vue":"^5.0.0","vite":"^5.0.0"}}
|
||||
@@ -1,35 +0,0 @@
|
||||
<template>
|
||||
<div id="app">
|
||||
<LoginScreen v-if="!isLoggedIn" @login="handleLogin" />
|
||||
<MainApp v-else :apiKey="apiKey" @logout="handleLogout" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import LoginScreen from './components/LoginScreen.vue'
|
||||
import MainApp from './components/MainApp.vue'
|
||||
|
||||
const isLoggedIn = ref(false)
|
||||
const apiKey = ref('')
|
||||
|
||||
onMounted(() => {
|
||||
const savedKey = localStorage.getItem('textpipe_api_key')
|
||||
if (savedKey) {
|
||||
apiKey.value = savedKey
|
||||
isLoggedIn.value = true
|
||||
}
|
||||
})
|
||||
|
||||
const handleLogin = (key) => {
|
||||
apiKey.value = key
|
||||
localStorage.setItem('textpipe_api_key', key)
|
||||
isLoggedIn.value = true
|
||||
}
|
||||
|
||||
const handleLogout = () => {
|
||||
apiKey.value = ''
|
||||
localStorage.removeItem('textpipe_api_key')
|
||||
isLoggedIn.value = false
|
||||
}
|
||||
</script>
|
||||
@@ -1,42 +0,0 @@
|
||||
const API_BASE = window.location.origin
|
||||
|
||||
export const api = {
|
||||
async getStatus() {
|
||||
const res = await fetch(`${API_BASE}/api/status`)
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
return res.json()
|
||||
},
|
||||
|
||||
async getMessages(apiKey) {
|
||||
const res = await fetch(`${API_BASE}/api/sms/messages`, {
|
||||
headers: {
|
||||
'X-API-Key': apiKey
|
||||
}
|
||||
})
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
return res.json()
|
||||
},
|
||||
|
||||
async getMessageStatus(apiKey, id) {
|
||||
const res = await fetch(`${API_BASE}/api/sms/status/${id}`, {
|
||||
headers: {
|
||||
'X-API-Key': apiKey
|
||||
}
|
||||
})
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
return res.json()
|
||||
},
|
||||
|
||||
async sendMessage(apiKey, to, text) {
|
||||
const res = await fetch(`${API_BASE}/api/sms/send`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-API-Key': apiKey
|
||||
},
|
||||
body: JSON.stringify({ to, text })
|
||||
})
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
return res.json()
|
||||
}
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
<template>
|
||||
<div class="login-container">
|
||||
<div class="login-box">
|
||||
<div class="login-logo">📱</div>
|
||||
<h1 class="login-title">Textpipe</h1>
|
||||
<p class="login-subtitle">SMS Gateway</p>
|
||||
|
||||
<form @submit.prevent="handleSubmit">
|
||||
<div class="input-group">
|
||||
<input
|
||||
v-model="apiKeyInput"
|
||||
type="text"
|
||||
placeholder="Enter your API Key"
|
||||
autocomplete="off"
|
||||
:disabled="loading"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary" :disabled="loading || !apiKeyInput">
|
||||
{{ loading ? 'Verifying...' : 'Login' }}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p v-if="error" class="error-message">{{ error }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { api } from '../api'
|
||||
|
||||
const emit = defineEmits(['login'])
|
||||
|
||||
const apiKeyInput = ref('')
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!apiKeyInput.value) return
|
||||
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
// Verify the API key by fetching messages
|
||||
await api.getMessages(apiKeyInput.value)
|
||||
emit('login', apiKeyInput.value)
|
||||
} catch (e) {
|
||||
if (e.message.includes('401') || e.message.includes('Unauthorized')) {
|
||||
error.value = 'Invalid API key'
|
||||
} else {
|
||||
error.value = 'Connection failed. Check server address.'
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,213 +0,0 @@
|
||||
<template>
|
||||
<div class="app-container">
|
||||
<!-- Header -->
|
||||
<header class="header">
|
||||
<div class="header-left">
|
||||
<h1 class="header-title">Textpipe</h1>
|
||||
<div class="header-status">
|
||||
<span class="status-dot"></span>
|
||||
<span>Connected</span>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn-logout" @click="$emit('logout')">Logout</button>
|
||||
</header>
|
||||
|
||||
<!-- Main Content -->
|
||||
<div style="flex: 1; display: flex; flex-direction: column; overflow: hidden;">
|
||||
<!-- Conversation View -->
|
||||
<template v-if="selectedConversation">
|
||||
<MessageThread
|
||||
:conversation="selectedConversation"
|
||||
:messages="selectedMessages"
|
||||
:apiKey="apiKey"
|
||||
@back="selectedConversation = null"
|
||||
@messageSent="loadMessages"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- Conversation List -->
|
||||
<template v-else>
|
||||
<div class="conversation-list">
|
||||
<div v-if="loading" class="loading">
|
||||
<div class="spinner"></div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="conversations.length === 0" class="empty-state">
|
||||
<div class="empty-state-icon">💬</div>
|
||||
<h2 class="empty-state-title">No messages yet</h2>
|
||||
<p class="empty-state-text">Start a new conversation</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
v-for="conv in conversations"
|
||||
:key="conv.address"
|
||||
class="conversation-item"
|
||||
@click="selectConversation(conv)"
|
||||
>
|
||||
<div class="conversation-avatar">
|
||||
{{ getInitial(conv.address) }}
|
||||
</div>
|
||||
<div class="conversation-info">
|
||||
<div class="conversation-name">{{ conv.address }}</div>
|
||||
<div class="conversation-preview">{{ conv.lastMessage }}</div>
|
||||
</div>
|
||||
<div class="conversation-meta">
|
||||
<div class="conversation-time">{{ formatTime(conv.timestamp) }}</div>
|
||||
<span v-if="conv.unread" class="conversation-badge">{{ conv.unread }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- FAB -->
|
||||
<button class="fab" @click="showNewMessage = true">+</button>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- New Message Modal -->
|
||||
<div v-if="showNewMessage" class="modal-overlay" @click.self="showNewMessage = false">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<h2 class="modal-title">New Message</h2>
|
||||
<button class="btn-close" @click="showNewMessage = false">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="form-group">
|
||||
<label>To</label>
|
||||
<input
|
||||
v-model="newMessageTo"
|
||||
type="tel"
|
||||
placeholder="Phone number"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Message</label>
|
||||
<textarea
|
||||
v-model="newMessageText"
|
||||
placeholder="Type your message..."
|
||||
rows="4"
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
:disabled="!newMessageTo || !newMessageText || sending"
|
||||
@click="sendNewMessage"
|
||||
>
|
||||
{{ sending ? 'Sending...' : 'Send' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { api } from '../api'
|
||||
import MessageThread from './MessageThread.vue'
|
||||
|
||||
const props = defineProps(['apiKey'])
|
||||
const emit = defineEmits(['logout'])
|
||||
|
||||
const messages = ref([])
|
||||
const loading = ref(true)
|
||||
const selectedConversation = ref(null)
|
||||
const showNewMessage = ref(false)
|
||||
const newMessageTo = ref('')
|
||||
const newMessageText = ref('')
|
||||
const sending = ref(false)
|
||||
|
||||
let pollInterval = null
|
||||
|
||||
const conversations = computed(() => {
|
||||
const grouped = {}
|
||||
|
||||
messages.value.forEach(msg => {
|
||||
const address = msg.address
|
||||
if (!grouped[address]) {
|
||||
grouped[address] = {
|
||||
address,
|
||||
messages: [],
|
||||
lastMessage: '',
|
||||
timestamp: 0,
|
||||
unread: 0
|
||||
}
|
||||
}
|
||||
grouped[address].messages.push(msg)
|
||||
if (msg.timestamp > grouped[address].timestamp) {
|
||||
grouped[address].timestamp = msg.timestamp
|
||||
grouped[address].lastMessage = msg.text.substring(0, 50) + (msg.text.length > 50 ? '...' : '')
|
||||
}
|
||||
})
|
||||
|
||||
return Object.values(grouped).sort((a, b) => b.timestamp - a.timestamp)
|
||||
})
|
||||
|
||||
const selectedMessages = computed(() => {
|
||||
if (!selectedConversation.value) return []
|
||||
return messages.value
|
||||
.filter(m => m.address === selectedConversation.value.address)
|
||||
.sort((a, b) => a.timestamp - b.timestamp)
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
loadMessages()
|
||||
pollInterval = setInterval(loadMessages, 5000)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (pollInterval) clearInterval(pollInterval)
|
||||
})
|
||||
|
||||
const loadMessages = async () => {
|
||||
try {
|
||||
const data = await api.getMessages(props.apiKey)
|
||||
messages.value = data.messages || []
|
||||
} catch (e) {
|
||||
console.error('Failed to load messages:', e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const selectConversation = (conv) => {
|
||||
selectedConversation.value = conv
|
||||
}
|
||||
|
||||
const getInitial = (address) => {
|
||||
return address.charAt(0).toUpperCase()
|
||||
}
|
||||
|
||||
const formatTime = (timestamp) => {
|
||||
const date = new Date(timestamp)
|
||||
const now = new Date()
|
||||
const diff = now - date
|
||||
|
||||
if (diff < 86400000) {
|
||||
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
||||
} else if (diff < 604800000) {
|
||||
return date.toLocaleDateString([], { weekday: 'short' })
|
||||
} else {
|
||||
return date.toLocaleDateString([], { month: 'short', day: 'numeric' })
|
||||
}
|
||||
}
|
||||
|
||||
const sendNewMessage = async () => {
|
||||
if (!newMessageTo.value || !newMessageText.value) return
|
||||
|
||||
sending.value = true
|
||||
try {
|
||||
await api.sendMessage(props.apiKey, newMessageTo.value, newMessageText.value)
|
||||
showNewMessage.value = false
|
||||
newMessageTo.value = ''
|
||||
newMessageText.value = ''
|
||||
await loadMessages()
|
||||
} catch (e) {
|
||||
alert('Failed to send message: ' + e.message)
|
||||
} finally {
|
||||
sending.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,102 +0,0 @@
|
||||
<template>
|
||||
<div class="message-thread">
|
||||
<!-- Thread Header -->
|
||||
<div class="thread-header">
|
||||
<button class="btn-back" @click="$emit('back')">←</button>
|
||||
<div class="thread-info">
|
||||
<div class="thread-name">{{ conversation.address }}</div>
|
||||
<div class="thread-number">{{ messages.length }} messages</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Messages -->
|
||||
<div class="messages-container" ref="messagesContainer">
|
||||
<div
|
||||
v-for="msg in messages"
|
||||
:key="msg.id"
|
||||
:class="['message', msg.type === 'sent' ? 'message-sent' : 'message-received']"
|
||||
>
|
||||
<div class="message-text">{{ msg.text }}</div>
|
||||
<div class="message-time">{{ formatTime(msg.timestamp) }}</div>
|
||||
<div v-if="msg.type === 'sent'" :class="['message-status', msg.status]">
|
||||
{{ getStatusText(msg.status) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Compose -->
|
||||
<div class="compose-container">
|
||||
<textarea
|
||||
v-model="messageText"
|
||||
class="compose-input"
|
||||
placeholder="Type a message..."
|
||||
rows="1"
|
||||
@keydown.enter.exact.prevent="sendMessage"
|
||||
></textarea>
|
||||
<button
|
||||
class="btn-send"
|
||||
:disabled="!messageText.trim() || sending"
|
||||
@click="sendMessage"
|
||||
>
|
||||
➤
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, nextTick, watch, onMounted } from 'vue'
|
||||
import { api } from '../api'
|
||||
|
||||
const props = defineProps(['conversation', 'messages', 'apiKey'])
|
||||
const emit = defineEmits(['back', 'messageSent'])
|
||||
|
||||
const messageText = ref('')
|
||||
const sending = ref(false)
|
||||
const messagesContainer = ref(null)
|
||||
|
||||
onMounted(() => {
|
||||
scrollToBottom()
|
||||
})
|
||||
|
||||
watch(() => props.messages.length, () => {
|
||||
nextTick(scrollToBottom)
|
||||
})
|
||||
|
||||
const scrollToBottom = () => {
|
||||
if (messagesContainer.value) {
|
||||
messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight
|
||||
}
|
||||
}
|
||||
|
||||
const formatTime = (timestamp) => {
|
||||
const date = new Date(timestamp)
|
||||
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
|
||||
const getStatusText = (status) => {
|
||||
switch (status) {
|
||||
case 'pending': return '○ Sending...'
|
||||
case 'sent': return '✓ Sent'
|
||||
case 'delivered': return '✓✓ Delivered'
|
||||
case 'failed': return '✕ Failed'
|
||||
default: return status
|
||||
}
|
||||
}
|
||||
|
||||
const sendMessage = async () => {
|
||||
const text = messageText.value.trim()
|
||||
if (!text || sending.value) return
|
||||
|
||||
sending.value = true
|
||||
try {
|
||||
await api.sendMessage(props.apiKey, props.conversation.address, text)
|
||||
messageText.value = ''
|
||||
emit('messageSent')
|
||||
} catch (e) {
|
||||
alert('Failed to send: ' + e.message)
|
||||
} finally {
|
||||
sending.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,5 +0,0 @@
|
||||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
import './style.css'
|
||||
|
||||
createApp(App).mount('#app')
|
||||
@@ -1,590 +0,0 @@
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
:root {
|
||||
--primary: #FFA31A;
|
||||
--primary-dark: #CC8215;
|
||||
--bg-dark: #1B1B1B;
|
||||
--surface: #292929;
|
||||
--surface-light: #363636;
|
||||
--text-white: #FFFFFF;
|
||||
--text-gray: #B3B3B3;
|
||||
--text-dark: #8C8C8C;
|
||||
--success: #4CAF50;
|
||||
--error: #F44336;
|
||||
--sent: #2196F3;
|
||||
--received: #4CAF50;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
||||
background-color: var(--bg-dark);
|
||||
color: var(--text-white);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
#app {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Login Screen */
|
||||
.login-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.login-box {
|
||||
background: var(--surface);
|
||||
border-radius: 16px;
|
||||
padding: 40px;
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.login-logo {
|
||||
font-size: 48px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.login-title {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.login-subtitle {
|
||||
color: var(--text-gray);
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.input-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.input-group input {
|
||||
width: 100%;
|
||||
padding: 16px;
|
||||
border: 2px solid var(--surface-light);
|
||||
border-radius: 12px;
|
||||
background: var(--bg-dark);
|
||||
color: var(--text-white);
|
||||
font-size: 16px;
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.input-group input:focus {
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.input-group input::placeholder {
|
||||
color: var(--text-dark);
|
||||
}
|
||||
|
||||
.btn {
|
||||
width: 100%;
|
||||
padding: 16px;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--primary);
|
||||
color: var(--bg-dark);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: var(--primary-dark);
|
||||
}
|
||||
|
||||
.btn-primary:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: var(--error);
|
||||
margin-top: 16px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* Main App Layout */
|
||||
.app-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.header {
|
||||
background: var(--surface);
|
||||
padding: 16px 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
border-bottom: 1px solid var(--surface-light);
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.header-title {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.header-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
color: var(--text-gray);
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--success);
|
||||
}
|
||||
|
||||
.btn-logout {
|
||||
background: transparent;
|
||||
border: 1px solid var(--surface-light);
|
||||
color: var(--text-gray);
|
||||
padding: 8px 16px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-logout:hover {
|
||||
border-color: var(--error);
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
/* Conversation List */
|
||||
.conversation-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.conversation-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 16px;
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.conversation-item:hover {
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.conversation-item.active {
|
||||
background: var(--surface-light);
|
||||
}
|
||||
|
||||
.conversation-avatar {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
background: var(--primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: var(--bg-dark);
|
||||
margin-right: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.conversation-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.conversation-name {
|
||||
font-weight: 500;
|
||||
margin-bottom: 4px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.conversation-preview {
|
||||
color: var(--text-gray);
|
||||
font-size: 14px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.conversation-meta {
|
||||
text-align: right;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.conversation-time {
|
||||
font-size: 12px;
|
||||
color: var(--text-dark);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.conversation-badge {
|
||||
background: var(--primary);
|
||||
color: var(--bg-dark);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
/* Message Thread */
|
||||
.message-thread {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.thread-header {
|
||||
background: var(--surface);
|
||||
padding: 16px 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
border-bottom: 1px solid var(--surface-light);
|
||||
}
|
||||
|
||||
.btn-back {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-white);
|
||||
font-size: 24px;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.thread-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.thread-name {
|
||||
font-weight: 600;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.thread-number {
|
||||
color: var(--text-gray);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* Messages */
|
||||
.messages-container {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.message {
|
||||
max-width: 75%;
|
||||
padding: 12px 16px;
|
||||
border-radius: 18px;
|
||||
font-size: 15px;
|
||||
line-height: 1.4;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.message-sent {
|
||||
background: var(--sent);
|
||||
color: white;
|
||||
align-self: flex-end;
|
||||
border-bottom-right-radius: 4px;
|
||||
}
|
||||
|
||||
.message-received {
|
||||
background: var(--surface-light);
|
||||
color: var(--text-white);
|
||||
align-self: flex-start;
|
||||
border-bottom-left-radius: 4px;
|
||||
}
|
||||
|
||||
.message-time {
|
||||
font-size: 11px;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
margin-top: 4px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.message-status {
|
||||
font-size: 11px;
|
||||
margin-top: 2px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.message-status.pending {
|
||||
color: var(--text-dark);
|
||||
}
|
||||
|
||||
.message-status.sent {
|
||||
color: var(--text-gray);
|
||||
}
|
||||
|
||||
.message-status.delivered {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.message-status.failed {
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
/* Compose */
|
||||
.compose-container {
|
||||
background: var(--surface);
|
||||
padding: 16px 20px;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 12px;
|
||||
border-top: 1px solid var(--surface-light);
|
||||
}
|
||||
|
||||
.compose-input {
|
||||
flex: 1;
|
||||
background: var(--bg-dark);
|
||||
border: none;
|
||||
border-radius: 24px;
|
||||
padding: 12px 20px;
|
||||
color: var(--text-white);
|
||||
font-size: 16px;
|
||||
resize: none;
|
||||
outline: none;
|
||||
max-height: 120px;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.compose-input::placeholder {
|
||||
color: var(--text-dark);
|
||||
}
|
||||
|
||||
.btn-send {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
background: var(--primary);
|
||||
border: none;
|
||||
color: var(--bg-dark);
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background 0.2s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.btn-send:hover {
|
||||
background: var(--primary-dark);
|
||||
}
|
||||
|
||||
.btn-send:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* New Message FAB */
|
||||
.fab {
|
||||
position: fixed;
|
||||
bottom: 24px;
|
||||
right: 24px;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 50%;
|
||||
background: var(--primary);
|
||||
border: none;
|
||||
color: var(--bg-dark);
|
||||
font-size: 28px;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
transition: transform 0.2s, background 0.2s;
|
||||
}
|
||||
|
||||
.fab:hover {
|
||||
background: var(--primary-dark);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
/* New Message Modal */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: var(--surface);
|
||||
border-radius: 16px;
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
max-height: 90vh;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
padding: 20px;
|
||||
border-bottom: 1px solid var(--surface-light);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.btn-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-gray);
|
||||
font-size: 24px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 20px;
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
padding: 20px;
|
||||
border-top: 1px solid var(--surface-light);
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
color: var(--text-gray);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
.form-group textarea {
|
||||
width: 100%;
|
||||
padding: 14px 16px;
|
||||
border: 2px solid var(--surface-light);
|
||||
border-radius: 12px;
|
||||
background: var(--bg-dark);
|
||||
color: var(--text-white);
|
||||
font-size: 16px;
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.form-group input:focus,
|
||||
.form-group textarea:focus {
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.form-group textarea {
|
||||
resize: vertical;
|
||||
min-height: 100px;
|
||||
}
|
||||
|
||||
/* Empty State */
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
padding: 40px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty-state-icon {
|
||||
font-size: 64px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.empty-state-title {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.empty-state-text {
|
||||
color: var(--text-gray);
|
||||
}
|
||||
|
||||
/* Loading */
|
||||
.loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 3px solid var(--surface-light);
|
||||
border-top-color: var(--primary);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 600px) {
|
||||
.login-box {
|
||||
padding: 30px 20px;
|
||||
}
|
||||
|
||||
.message {
|
||||
max-width: 85%;
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
base: './',
|
||||
build: {
|
||||
outDir: '../app/src/main/assets/webui',
|
||||
emptyOutDir: true
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user