created frontend with login, room listing and creation, and message page with all features

This commit is contained in:
2025-12-15 14:50:50 +01:00
parent f10c761f1b
commit d6a26c0d09
18 changed files with 653 additions and 165 deletions

View File

@@ -1,160 +1,10 @@
<script setup lang="ts">
import { ref } from "vue";
import { invoke } from "@tauri-apps/api/core";
import { useAuthStore } from "./stores/auth";
const greetMsg = ref("");
const name = ref("");
async function greet() {
// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
greetMsg.value = await invoke("greet", { name: name.value });
}
const auth = useAuthStore();
</script>
<template>
<main class="container">
<h1>Welcome to Tauri + Vue</h1>
<div class="row">
<a href="https://vite.dev" target="_blank">
<img src="/vite.svg" class="logo vite" alt="Vite logo" />
</a>
<a href="https://tauri.app" target="_blank">
<img src="/tauri.svg" class="logo tauri" alt="Tauri logo" />
</a>
<a href="https://vuejs.org/" target="_blank">
<img src="./assets/vue.svg" class="logo vue" alt="Vue logo" />
</a>
</div>
<p>Click on the Tauri, Vite, and Vue logos to learn more.</p>
<form class="row" @submit.prevent="greet">
<input id="greet-input" v-model="name" placeholder="Enter a name..." />
<button type="submit">Greet</button>
</form>
<p>{{ greetMsg }}</p>
</main>
<!-- You can later replace this with a real layout -->
<router-view />
</template>
<style scoped>
.logo.vite:hover {
filter: drop-shadow(0 0 2em #747bff);
}
.logo.vue:hover {
filter: drop-shadow(0 0 2em #249b73);
}
</style>
<style>
:root {
font-family: Inter, Avenir, Helvetica, Arial, sans-serif;
font-size: 16px;
line-height: 24px;
font-weight: 400;
color: #0f0f0f;
background-color: #f6f6f6;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
-webkit-text-size-adjust: 100%;
}
.container {
margin: 0;
padding-top: 10vh;
display: flex;
flex-direction: column;
justify-content: center;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: 0.75s;
}
.logo.tauri:hover {
filter: drop-shadow(0 0 2em #24c8db);
}
.row {
display: flex;
justify-content: center;
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
}
h1 {
text-align: center;
}
input,
button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
color: #0f0f0f;
background-color: #ffffff;
transition: border-color 0.25s;
box-shadow: 0 2px 2px rgba(0, 0, 0, 0.2);
}
button {
cursor: pointer;
}
button:hover {
border-color: #396cd8;
}
button:active {
border-color: #396cd8;
background-color: #e8e8e8;
}
input,
button {
outline: none;
}
#greet-input {
margin-right: 5px;
}
@media (prefers-color-scheme: dark) {
:root {
color: #f6f6f6;
background-color: #2f2f2f;
}
a:hover {
color: #24c8db;
}
input,
button {
color: #ffffff;
background-color: #0f0f0f98;
}
button:active {
background-color: #0f0f0f69;
}
}
</style>

27
src/api/client.ts Normal file
View File

@@ -0,0 +1,27 @@
import { useAuthStore } from '../stores/auth'
const BASE_URL = 'http://localhost:8080'
export async function apiFetch<T>(
path: string,
options: RequestInit = {}
): Promise<T> {
const auth = useAuthStore()
const res = await fetch(`${BASE_URL}${path}`, {
...options,
headers: {
'Content-Type': 'application/json',
...(auth.token ? { Authorization: `Bearer ${auth.token}` } : {}),
...options.headers,
},
})
if (!res.ok) {
const text = await res.text()
throw new Error(text || res.statusText)
}
return res.json()
}

17
src/api/messages.ts Normal file
View File

@@ -0,0 +1,17 @@
import { apiFetch } from './client'
import type { Message } from '../types/api'
export function fetchMessages(roomUuid: string) {
return apiFetch<Message[]>(`/messages/${roomUuid}`)
}
export function sendMessage(roomUuid: string, content: string) {
return apiFetch<Message>(`/messages/${roomUuid}`, {
method: 'POST',
body: JSON.stringify({
message_type: 'text',
content,
}),
})
}

14
src/api/rooms.ts Normal file
View File

@@ -0,0 +1,14 @@
import { apiFetch } from './client'
import type { Room } from '../types/api'
export function fetchRooms(userUuid: string) {
return apiFetch<Room[]>(`/rooms/${userUuid}`)
}
export function createRoom(name: string) {
return apiFetch<Room>('/rooms', {
method: 'POST',
body: JSON.stringify({ name }),
})
}

73
src/base.css Normal file
View File

@@ -0,0 +1,73 @@
/* ---- CSS reset (minimal, modern) ---- */
*,
*::before,
*::after {
box-sizing: border-box;
}
* {
margin: 0;
padding: 0;
}
html,
body,
#app {
height: 100%;
}
/* ---- Theme variables ---- */
:root {
--bg: #0f1115;
--panel: #171923;
--text: #e6e6eb;
--muted: #9aa0a6;
--accent: #f27aa2;
--accent-hover: #ff91b4;
--border: #2a2f3a;
--radius: 8px;
}
/* ---- Base ---- */
body {
font-family: system-ui, -apple-system, BlinkMacSystemFont,
"Segoe UI", sans-serif;
background-color: var(--bg);
color: var(--text);
line-height: 1.5;
}
/* ---- Inputs & buttons ---- */
input, textarea {
background: var(--panel);
border: 1px solid var(--border);
color: var(--text);
padding: 0.6rem 0.7rem;
border-radius: var(--radius);
font-size: 0.95rem;
}
input::placeholder, textarea::placeholder {
color: var(--muted);
}
input:focus, textarea:focus {
outline: none;
border-color: var(--accent);
}
button {
font-size: 1rem;
background: var(--accent);
color: #0b0d12;
border: none;
border-radius: var(--radius);
padding: 0.6rem 1rem;
font-weight: 600;
cursor: pointer;
}
button:hover {
background: var(--accent-hover);
}

View File

@@ -0,0 +1,21 @@
<script setup lang="ts">
import { ref } from 'vue'
import { createRoom } from '../api/rooms'
import type { Room } from '../types/api'
const name = ref('')
const emit = defineEmits<{ (e: 'created', room: Room): void }>()
async function submit() {
const room = await createRoom(name.value)
emit('created', room)
name.value = ''
}
</script>
<template>
<div>
<input v-model="name" placeholder="room name" />
<button @click="submit">Create</button>
</div>
</template>

View File

@@ -0,0 +1,58 @@
<script setup lang="ts">
import { ref, nextTick } from 'vue'
const content = ref('')
const emit = defineEmits<{ (e: 'send', content: string): void }>()
function submit() {
if (!content.value) return
emit('send', content.value)
content.value = ''
resize()
}
function resize() {
nextTick(() => {
const textarea = document.querySelector('textarea')
if (textarea) {
textarea.style.height = 'auto'
textarea.style.height = textarea.scrollHeight + 'px'
}
})
}
function handleKeydown(e: KeyboardEvent) {
if ((e.shiftKey || e.altKey) && e.key === 'Enter') {
// Insert a line break at cursor
const textarea = e.target as HTMLTextAreaElement
const start = textarea.selectionStart
const end = textarea.selectionEnd
content.value = content.value.substring(0, start) + '\n' + content.value.substring(end)
nextTick(() => {
textarea.selectionStart = textarea.selectionEnd = start + 1
})
resize()
e.preventDefault()
} else if (!e.shiftKey && !e.altKey && e.key === 'Enter') {
submit()
e.preventDefault()
}
}
</script>
<template>
<textarea v-model="content" @input="resize" @keydown="handleKeydown" placeholder="type a message" rows="1"></textarea>
</template>
<style scoped>
textarea {
width: 100%;
resize: none;
overflow: hidden;
padding: 0.5rem;
font-size: 1rem;
line-height: 1.4;
box-sizing: border-box;
}
</style>

View File

@@ -0,0 +1,31 @@
<script setup lang="ts">
import type { Message } from '../types/api'
defineProps<{ messages: Message[] }>()
</script>
<template>
<ul>
<li v-for="(m, i) in messages" :key="i">
<strong>{{ m.sender }}:</strong> {{ m.content }}
</li>
</ul>
</template>
<style scoped>
ul {
padding: 0;
margin: 0;
list-style: none;
word-wrap: break-word;
}
li {
white-space: pre-wrap;
}
.message-content {
display: inline-block;
max-width: 100%;
word-break: break-word;
}
</style>

View File

@@ -1,4 +1,11 @@
import { createApp } from "vue";
import App from "./App.vue";
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import router from './router'
import App from './App.vue'
createApp(App).mount("#app");
import './base.css'
createApp(App)
.use(createPinia())
.use(router)
.mount('#app')

78
src/pages/ChatPage.vue Normal file
View File

@@ -0,0 +1,78 @@
<template>
<div class="chat-page">
<div class="messages-container" ref="messageListRef">
<MessageList :messages="messages" />
</div>
<div class="input-container">
<MessageInput @send="onSend" />
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref, nextTick } from "vue";
import { fetchMessages, sendMessage } from "../api/messages";
import type { Message } from "../types/api";
import MessageList from "../components/MessageList.vue";
import MessageInput from "../components/MessageInput.vue";
const props = defineProps<{ uuid: string }>();
const messages = ref<Message[]>([]);
async function load() {
messages.value = await fetchMessages(props.uuid);
}
async function onSend(content: string) {
const msg = await sendMessage(props.uuid, content);
messages.value.push(msg);
await nextTick();
scrollToBottom();
}
onMounted(async () => {
await load();
await nextTick();
scrollToBottom();
});
const messageListRef = ref<HTMLElement | null>(null);
function scrollToBottom() {
if (messageListRef.value) {
messageListRef.value.scrollTop = messageListRef.value.scrollHeight;
}
}
</script>
<style scoped>
.chat-page {
display: flex;
flex-direction: column;
height: 100%;
max-width: 720px;
margin: 0 auto;
padding: 15px;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--panel);
overflow: hidden;
}
.messages-container {
flex: 1;
padding: 1rem;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 0.5rem;
word-wrap: break-word;
}
.input-container {
padding: 0.5rem 1rem;
border-top: 1px solid var(--border);
background: var(--panel);
}
</style>

58
src/pages/LoginPage.vue Normal file
View File

@@ -0,0 +1,58 @@
<script setup lang="ts">
import { ref } from "vue";
import { useAuthStore } from "../stores/auth";
import { useRouter } from "vue-router";
const email = ref("");
const username = ref("");
const password = ref("");
const auth = useAuthStore();
const router = useRouter();
async function submit() {
await auth.login(email.value, username.value, password.value);
router.push("/");
}
</script>
<template>
<div class="login-page">
<form class="login-card" @submit.prevent="submit">
<h1>Login</h1>
<input v-model="email" placeholder="email" />
<input v-model="password" type="password" placeholder="password" />
<button type="submit">Login</button>
</form>
</div>
</template>
<style scoped>
.login-page {
height: 100%;
display: grid;
place-items: center;
}
.login-card {
width: 100%;
max-width: 360px;
background: var(--panel);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 2rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
.login-card h1 {
font-size: 1.4rem;
font-weight: 600;
margin-bottom: 0.5rem;
text-align: center;
}
</style>

83
src/pages/RoomsPage.vue Normal file
View File

@@ -0,0 +1,83 @@
<template>
<div class="rooms-page">
<header class="rooms-header">
<h1>Your rooms</h1>
<CreateRoomForm @created="rooms.push($event)" />
</header>
<ul class="rooms-list">
<li v-for="room in rooms" :key="room.uuid" class="room-item">
<router-link class="room-link" :to="`/rooms/${room.uuid}`">
{{ room.name }}
</router-link>
</li>
</ul>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useAuthStore } from '../stores/auth'
import { fetchRooms } from '../api/rooms'
import type { Room } from '../types/api'
const auth = useAuthStore()
const rooms = ref<Room[]>([])
onMounted(async () => {
rooms.value = await fetchRooms(auth.uuid!)
})
</script>
<script lang="ts">
import CreateRoomForm from '../components/CreateRoomForm.vue'
export default { components: { CreateRoomForm } }
</script>
<style scoped>
.rooms-page {
max-width: 720px;
margin: 0 auto;
padding: 2rem 1.5rem;
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.rooms-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.rooms-header h1 {
font-size: 1.5rem;
font-weight: 600;
}
.rooms-list {
list-style: none;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.room-item {
background: var(--panel);
border: 1px solid var(--border);
border-radius: var(--radius);
}
.room-link {
display: block;
padding: 0.75rem 1rem;
color: var(--text);
text-decoration: none;
}
.room-link:hover {
background: rgba(255, 255, 255, 0.03);
}
</style>

25
src/router/index.ts Normal file
View File

@@ -0,0 +1,25 @@
import { createRouter, createWebHistory } from 'vue-router'
import { useAuthStore } from '../stores/auth'
import LoginPage from '../pages/LoginPage.vue'
import RoomsPage from '../pages/RoomsPage.vue'
import ChatPage from '../pages/ChatPage.vue'
const router = createRouter({
history: createWebHistory(),
routes: [
{ path: '/login', component: LoginPage },
{ path: '/', component: RoomsPage },
{ path: '/rooms/:uuid', component: ChatPage, props: true },
],
})
router.beforeEach((to) => {
const auth = useAuthStore()
if (!auth.isAuthenticated && to.path !== '/login') {
return '/login'
}
})
export default router

32
src/stores/auth.ts Normal file
View File

@@ -0,0 +1,32 @@
import { defineStore } from 'pinia'
import type { LoginResponse } from '../types/api'
import { apiFetch } from '../api/client'
export const useAuthStore = defineStore('auth', {
state: () => ({
token: null as string | null,
uuid: null as string | null,
}),
getters: {
isAuthenticated: (s) => !!s.token,
},
actions: {
async login(email: string, username: string, password: string) {
const res = await apiFetch<LoginResponse>('/login', {
method: 'POST',
body: JSON.stringify({ email, username, password }),
})
this.token = res.token
this.uuid = res.uuid
},
logout() {
this.token = null
this.uuid = null
},
},
})

17
src/types/api.ts Normal file
View File

@@ -0,0 +1,17 @@
export interface LoginResponse {
uuid: string
token: string
}
export interface Room {
uuid: string
owner: number
name: string
}
export interface Message {
sender: string
message_type: 'text'
content: string
}