basic frontend added
This commit is contained in:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -13,3 +13,7 @@
|
||||
.externalNativeBuild
|
||||
.cxx
|
||||
local.properties
|
||||
|
||||
# WebUI
|
||||
webui/node_modules
|
||||
webui/dist
|
||||
|
||||
17
app/src/main/assets/webui/assets/index-BR9M1eFw.js
Normal file
17
app/src/main/assets/webui/assets/index-BR9M1eFw.js
Normal file
File diff suppressed because one or more lines are too long
1
app/src/main/assets/webui/assets/index-jwVA1VU4.css
Normal file
1
app/src/main/assets/webui/assets/index-jwVA1VU4.css
Normal file
File diff suppressed because one or more lines are too long
14
app/src/main/assets/webui/index.html
Normal file
14
app/src/main/assets/webui/index.html
Normal file
@@ -0,0 +1,14 @@
|
||||
<!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>
|
||||
<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>
|
||||
</html>
|
||||
@@ -1,5 +1,6 @@
|
||||
package sh.sar.textpipe.server
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import io.ktor.http.*
|
||||
import io.ktor.serialization.kotlinx.json.*
|
||||
@@ -13,18 +14,18 @@ import io.ktor.server.response.*
|
||||
import io.ktor.server.routing.*
|
||||
import kotlinx.coroutines.CoroutineExceptionHandler
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import kotlinx.serialization.json.Json
|
||||
import sh.sar.textpipe.data.model.ErrorResponse
|
||||
import sh.sar.textpipe.data.repository.SmsRepository
|
||||
import sh.sar.textpipe.server.auth.configureAuth
|
||||
import sh.sar.textpipe.server.routes.smsRoutes
|
||||
import sh.sar.textpipe.server.routes.statusRoutes
|
||||
import sh.sar.textpipe.server.routes.webUIRoutes
|
||||
import sh.sar.textpipe.sim.SimManager
|
||||
import java.net.BindException
|
||||
import java.net.ServerSocket
|
||||
|
||||
class TextpipeServer(
|
||||
private val context: Context,
|
||||
private val smsRepository: SmsRepository,
|
||||
private val simManager: SimManager
|
||||
) {
|
||||
@@ -143,6 +144,10 @@ class TextpipeServer(
|
||||
|
||||
private fun Application.configureRouting() {
|
||||
routing {
|
||||
// Web UI routes (served from assets)
|
||||
webUIRoutes(context)
|
||||
|
||||
// API routes
|
||||
statusRoutes(simManager, { startTime }, { currentPort })
|
||||
smsRoutes(smsRepository)
|
||||
}
|
||||
|
||||
@@ -6,10 +6,6 @@ import sh.sar.textpipe.data.model.StatusResponse
|
||||
import sh.sar.textpipe.sim.SimManager
|
||||
|
||||
fun Route.statusRoutes(simManager: SimManager, getStartTime: () -> Long, getPort: () -> Int) {
|
||||
get("/") {
|
||||
call.respondText("Textpipe SMS Gateway API")
|
||||
}
|
||||
|
||||
get("/api/status") {
|
||||
val uptime = System.currentTimeMillis() - getStartTime()
|
||||
val response = StatusResponse(
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
package sh.sar.textpipe.server.routes
|
||||
|
||||
import android.content.Context
|
||||
import io.ktor.http.*
|
||||
import io.ktor.server.response.*
|
||||
import io.ktor.server.routing.*
|
||||
import java.io.IOException
|
||||
|
||||
fun Route.webUIRoutes(context: Context) {
|
||||
get("/") {
|
||||
serveAsset(context, call, "index.html", ContentType.Text.Html)
|
||||
}
|
||||
|
||||
get("/assets/{file}") {
|
||||
val fileName = call.parameters["file"] ?: return@get
|
||||
val contentType = getContentType(fileName)
|
||||
serveAsset(context, call, "assets/$fileName", contentType)
|
||||
}
|
||||
|
||||
get("/favicon.svg") {
|
||||
serveAsset(context, call, "favicon.svg", ContentType.Image.SVG)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun serveAsset(
|
||||
context: Context,
|
||||
call: io.ktor.server.application.ApplicationCall,
|
||||
path: String,
|
||||
contentType: ContentType
|
||||
) {
|
||||
try {
|
||||
val inputStream = context.assets.open("webui/$path")
|
||||
val bytes = inputStream.readBytes()
|
||||
inputStream.close()
|
||||
call.respondBytes(bytes, contentType)
|
||||
} catch (e: IOException) {
|
||||
call.respond(HttpStatusCode.NotFound, "File not found: $path")
|
||||
}
|
||||
}
|
||||
|
||||
private fun getContentType(fileName: String): ContentType {
|
||||
return when {
|
||||
fileName.endsWith(".js") -> ContentType.Application.JavaScript
|
||||
fileName.endsWith(".css") -> ContentType.Text.CSS
|
||||
fileName.endsWith(".html") -> ContentType.Text.Html
|
||||
fileName.endsWith(".svg") -> ContentType.Image.SVG
|
||||
fileName.endsWith(".png") -> ContentType.Image.PNG
|
||||
fileName.endsWith(".jpg") || fileName.endsWith(".jpeg") -> ContentType.Image.JPEG
|
||||
fileName.endsWith(".json") -> ContentType.Application.Json
|
||||
else -> ContentType.Application.OctetStream
|
||||
}
|
||||
}
|
||||
@@ -101,7 +101,7 @@ class TextpipeService : Service() {
|
||||
smsSender.register()
|
||||
|
||||
smsRepository = SmsRepository(dao, smsSender, simManager)
|
||||
textpipeServer = TextpipeServer(smsRepository, simManager)
|
||||
textpipeServer = TextpipeServer(this, smsRepository, simManager)
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
|
||||
13
webui/index.html
Normal file
13
webui/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!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
Normal file
1207
webui/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
1
webui/package.json
Normal file
1
webui/package.json
Normal file
@@ -0,0 +1 @@
|
||||
{"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"}}
|
||||
35
webui/src/App.vue
Normal file
35
webui/src/App.vue
Normal file
@@ -0,0 +1,35 @@
|
||||
<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>
|
||||
42
webui/src/api.js
Normal file
42
webui/src/api.js
Normal file
@@ -0,0 +1,42 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
59
webui/src/components/LoginScreen.vue
Normal file
59
webui/src/components/LoginScreen.vue
Normal file
@@ -0,0 +1,59 @@
|
||||
<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>
|
||||
213
webui/src/components/MainApp.vue
Normal file
213
webui/src/components/MainApp.vue
Normal file
@@ -0,0 +1,213 @@
|
||||
<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>
|
||||
102
webui/src/components/MessageThread.vue
Normal file
102
webui/src/components/MessageThread.vue
Normal file
@@ -0,0 +1,102 @@
|
||||
<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>
|
||||
5
webui/src/main.js
Normal file
5
webui/src/main.js
Normal file
@@ -0,0 +1,5 @@
|
||||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
import './style.css'
|
||||
|
||||
createApp(App).mount('#app')
|
||||
590
webui/src/style.css
Normal file
590
webui/src/style.css
Normal file
@@ -0,0 +1,590 @@
|
||||
* {
|
||||
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%;
|
||||
}
|
||||
}
|
||||
11
webui/vite.config.js
Normal file
11
webui/vite.config.js
Normal file
@@ -0,0 +1,11 @@
|
||||
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