basic frontend added
This commit is contained in:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -13,3 +13,7 @@
|
|||||||
.externalNativeBuild
|
.externalNativeBuild
|
||||||
.cxx
|
.cxx
|
||||||
local.properties
|
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
|
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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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()
|
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
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