basic frontend added

This commit is contained in:
2026-02-06 00:49:38 +05:00
parent c35b466de6
commit fbbe35c9c0
19 changed files with 2374 additions and 7 deletions

4
.gitignore vendored
View File

@@ -13,3 +13,7 @@
.externalNativeBuild .externalNativeBuild
.cxx .cxx
local.properties local.properties
# WebUI
webui/node_modules
webui/dist

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View 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>

View File

@@ -1,5 +1,6 @@
package sh.sar.textpipe.server package sh.sar.textpipe.server
import android.content.Context
import android.util.Log import android.util.Log
import io.ktor.http.* import io.ktor.http.*
import io.ktor.serialization.kotlinx.json.* import io.ktor.serialization.kotlinx.json.*
@@ -13,18 +14,18 @@ import io.ktor.server.response.*
import io.ktor.server.routing.* import io.ktor.server.routing.*
import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import sh.sar.textpipe.data.model.ErrorResponse import sh.sar.textpipe.data.model.ErrorResponse
import sh.sar.textpipe.data.repository.SmsRepository import sh.sar.textpipe.data.repository.SmsRepository
import sh.sar.textpipe.server.auth.configureAuth import sh.sar.textpipe.server.auth.configureAuth
import sh.sar.textpipe.server.routes.smsRoutes import sh.sar.textpipe.server.routes.smsRoutes
import sh.sar.textpipe.server.routes.statusRoutes import sh.sar.textpipe.server.routes.statusRoutes
import sh.sar.textpipe.server.routes.webUIRoutes
import sh.sar.textpipe.sim.SimManager import sh.sar.textpipe.sim.SimManager
import java.net.BindException
import java.net.ServerSocket import java.net.ServerSocket
class TextpipeServer( class TextpipeServer(
private val context: Context,
private val smsRepository: SmsRepository, private val smsRepository: SmsRepository,
private val simManager: SimManager private val simManager: SimManager
) { ) {
@@ -143,6 +144,10 @@ class TextpipeServer(
private fun Application.configureRouting() { private fun Application.configureRouting() {
routing { routing {
// Web UI routes (served from assets)
webUIRoutes(context)
// API routes
statusRoutes(simManager, { startTime }, { currentPort }) statusRoutes(simManager, { startTime }, { currentPort })
smsRoutes(smsRepository) smsRoutes(smsRepository)
} }

View File

@@ -6,10 +6,6 @@ import sh.sar.textpipe.data.model.StatusResponse
import sh.sar.textpipe.sim.SimManager import sh.sar.textpipe.sim.SimManager
fun Route.statusRoutes(simManager: SimManager, getStartTime: () -> Long, getPort: () -> Int) { fun Route.statusRoutes(simManager: SimManager, getStartTime: () -> Long, getPort: () -> Int) {
get("/") {
call.respondText("Textpipe SMS Gateway API")
}
get("/api/status") { get("/api/status") {
val uptime = System.currentTimeMillis() - getStartTime() val uptime = System.currentTimeMillis() - getStartTime()
val response = StatusResponse( val response = StatusResponse(

View File

@@ -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
}
}

View File

@@ -101,7 +101,7 @@ class TextpipeService : Service() {
smsSender.register() smsSender.register()
smsRepository = SmsRepository(dao, smsSender, simManager) smsRepository = SmsRepository(dao, smsSender, simManager)
textpipeServer = TextpipeServer(smsRepository, simManager) textpipeServer = TextpipeServer(this, smsRepository, simManager)
} }
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {

13
webui/index.html Normal file
View 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

File diff suppressed because it is too large Load Diff

1
webui/package.json Normal file
View 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
View 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
View 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()
}
}

View 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>

View 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">&times;</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>

View 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
View 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
View 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
View 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
}
})