4 Commits

33 changed files with 1075 additions and 172 deletions

View File

@@ -1,8 +1,8 @@
{
"name": "frangipane-client",
"private": true,
"version": "1.0.0",
"backendVersion": "1.0.3",
"version": "1.0.1",
"backendVersion": "1.0.4",
"type": "module",
"scripts": {
"dev": "vite",
@@ -17,6 +17,7 @@
"@tauri-apps/plugin-fs": "~2",
"@tauri-apps/plugin-http": "~2",
"@tauri-apps/plugin-opener": "^2",
"@tauri-apps/plugin-os": "~2",
"@tauri-apps/plugin-store": "~2",
"@tauri-apps/plugin-upload": "~2",
"@tauri-apps/plugin-websocket": "~2",

83
src-tauri/Cargo.lock generated
View File

@@ -1028,6 +1028,7 @@ dependencies = [
"tauri-plugin-fs",
"tauri-plugin-http",
"tauri-plugin-opener",
"tauri-plugin-os",
"tauri-plugin-store",
"tauri-plugin-upload",
"tauri-plugin-websocket",
@@ -1263,6 +1264,16 @@ dependencies = [
"version_check",
]
[[package]]
name = "gethostname"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8"
dependencies = [
"rustix",
"windows-link 0.2.1",
]
[[package]]
name = "getrandom"
version = "0.1.16"
@@ -2315,6 +2326,16 @@ dependencies = [
"objc2-foundation",
]
[[package]]
name = "objc2-core-location"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ca347214e24bc973fc025fd0d36ebb179ff30536ed1f80252706db19ee452009"
dependencies = [
"objc2",
"objc2-foundation",
]
[[package]]
name = "objc2-core-text"
version = "0.3.2"
@@ -2419,8 +2440,27 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22"
dependencies = [
"bitflags 2.10.0",
"block2",
"objc2",
"objc2-cloud-kit",
"objc2-core-data",
"objc2-core-foundation",
"objc2-core-graphics",
"objc2-core-image",
"objc2-core-location",
"objc2-core-text",
"objc2-foundation",
"objc2-quartz-core",
"objc2-user-notifications",
]
[[package]]
name = "objc2-user-notifications"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e"
dependencies = [
"objc2",
"objc2-foundation",
]
@@ -2474,6 +2514,22 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "os_info"
version = "3.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e4022a17595a00d6a369236fdae483f0de7f0a339960a53118b818238e132224"
dependencies = [
"android_system_properties",
"log",
"nix",
"objc2",
"objc2-foundation",
"objc2-ui-kit",
"serde",
"windows-sys 0.61.2",
]
[[package]]
name = "pango"
version = "0.18.3"
@@ -3782,6 +3838,15 @@ dependencies = [
"syn 2.0.111",
]
[[package]]
name = "sys-locale"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4"
dependencies = [
"libc",
]
[[package]]
name = "system-configuration"
version = "0.6.1"
@@ -4091,6 +4156,24 @@ dependencies = [
"zbus",
]
[[package]]
name = "tauri-plugin-os"
version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8f08346c8deb39e96f86973da0e2d76cbb933d7ac9b750f6dc4daf955a6f997"
dependencies = [
"gethostname",
"log",
"os_info",
"serde",
"serde_json",
"serialize-to-javascript",
"sys-locale",
"tauri",
"tauri-plugin",
"thiserror 2.0.17",
]
[[package]]
name = "tauri-plugin-store"
version = "2.4.1"

View File

@@ -28,4 +28,5 @@ tauri-plugin-websocket = "2"
tauri-plugin-upload = "2"
tauri-plugin-dialog = "2"
tauri-plugin-fs = "2"
tauri-plugin-os = "2"

View File

@@ -7,6 +7,12 @@
],
"permissions": [
"core:default",
"core:window:allow-maximize",
"core:window:allow-minimize",
"core:window:allow-close",
"core:window:allow-toggle-maximize",
"core:window:allow-start-dragging",
"core:window:allow-set-fullscreen",
"opener:default",
"store:default",
{
@@ -47,7 +53,7 @@
"allow": [
"*"
]
}
},
"os:default"
]
}

View File

@@ -7,6 +7,7 @@ fn greet(name: &str) -> String {
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_os::init())
.plugin(tauri_plugin_fs::init())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_upload::init())

View File

@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "frangipane",
"version": "1.0.0",
"version": "1.0.1",
"identifier": "com.strawberries.frangipane",
"build": {
"beforeDevCommand": "yarn dev",
@@ -15,7 +15,7 @@
"title": "frangipane",
"width": 800,
"height": 600,
"decorations": true,
"decorations": false,
"resizable": true,
"fullscreen": false,
"center": true,

View File

@@ -1,5 +1,24 @@
<template>
<div id="page">
<div id="page" :class="{ 'is-mobile': currentPlatform === 'android' || currentPlatform === 'ios' }">
<div v-if="currentPlatform != 'android' && currentPlatform != 'ios'" data-tauri-drag-region class="titlebar">
<div class="titlebar-button" @click="minimize">
<svg width="12" height="12" viewBox="0 0 12 12">
<rect fill="currentColor" width="10" height="1" x="1" y="6" />
</svg>
</div>
<div class="titlebar-button" @click="toggleMaximize">
<svg width="12" height="12" viewBox="0 0 12 12">
<rect fill="none" stroke="currentColor" stroke-width="1" width="9" height="9" x="1.5" y="1.5" />
</svg>
</div>
<div class="titlebar-button" id="close-btn" @click="close">
<svg width="12" height="12" viewBox="0 0 12 12">
<path fill="currentColor"
d="M11 1.57L10.43 1 6 5.43 1.57 1 1 1.57 5.43 6 1 10.43 1.57 11 6 6.57 10.43 11 11 10.43 6.57 6z" />
</svg>
</div>
</div>
<main id="content">
<router-view />
</main>
@@ -19,6 +38,12 @@ import Navbar from './components/Navbar.vue'
import VersionWarningModal from './components/VersionWarningModal.vue'
import { apiFetch } from './api/client'
import { VersionResponse } from './types'
import { getCurrentWindow } from '@tauri-apps/api/window'
import { platform } from '@tauri-apps/plugin-os';
const currentPlatform = ref('')
const appWindow = getCurrentWindow()
const showVersionWarningModal = ref(false)
const backendVersion = ref('')
@@ -26,13 +51,24 @@ const backendVersion = ref('')
const appVersion = __APP_VERSION__
const expectedBackendVersion = __BACKEND_VERSION__
const isFullScreen = ref(false)
onMounted(async () => {
backendVersion.value = (await getBackendVersion()).version
if (backendVersion.value !== expectedBackendVersion) {
showVersionWarningModal.value = true
}
currentPlatform.value = platform()
})
const minimize = () => appWindow.minimize()
const toggleMaximize = () => {
appWindow.setFullscreen(!isFullScreen.value)
isFullScreen.value = !isFullScreen.value
}
const close = () => appWindow.close()
async function getBackendVersion() {
return await apiFetch<VersionResponse>('/version')
}
@@ -50,27 +86,65 @@ async function getBackendVersion() {
#content {
width: 100%;
max-width: 1100px;
padding: 2rem;
/* padding: 2rem; */
padding: calc(20px + 1.8rem) 1.8rem calc(1.8rem - 10px) 1.8rem;
flex: 1;
overflow-y: auto;
}
.titlebar {
height: 30px;
background: var(--panel);
user-select: none;
display: flex;
justify-content: flex-end;
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 9999;
}
.titlebar-button {
display: inline-flex;
justify-content: center;
align-items: center;
width: 45px;
height: 30px;
transition: background-color 0.2s;
color: var(--text-color);
cursor: pointer;
}
.titlebar-button:hover {
background: var(--panel-accent)
}
#close-btn:hover {
background: #e81123;
color: white;
}
footer {
width: 100%;
display: flex;
justify-content: center;
padding-bottom: 24px;
padding-bottom: calc(1.8rem - 10px);
background: var(--bg);
}
@media (max-width: 720px) {
#content {
padding: 12px;
padding-top: calc(30px + 10px);
}
.is-mobile #content {
padding-top: 30px;
}
footer {
.is-mobile footer {
padding-bottom: 56px;
}
}

View File

@@ -29,3 +29,14 @@ export function declineFriendRequest(senderUuid: string) {
body: JSON.stringify({ sender_uuid: senderUuid }),
})
}
export function checkIsFriend(targetUuid: string) {
return apiFetch<boolean>(`/friends/check/${targetUuid}`)
}
export function removeFriend(friendUuid: string) {
return apiFetch<void>('/friends/remove', {
method: 'POST',
body: JSON.stringify({ friend_uuid: friendUuid }),
})
}

View File

@@ -16,6 +16,7 @@ body,
overflow-y: hidden;
}
/* This is overwritten by the theme configuration */
:root {
--bg: #0f1116;
--panel: #171922;
@@ -23,6 +24,7 @@ body,
--text: #e6e6eb;
--muted: #9aa0aa;
--accent: #f27aa3;
--accent-rgb: 242, 122, 163;
--accent-hover: #ff91b3;
--accent-second: #96CDFB;
--border: #2a2f3b;
@@ -30,6 +32,10 @@ body,
--radius: 8px;
}
* {
transition: background-color 0.2s ease, border-color 0.2s ease;
}
body {
font-family: system-ui, -apple-system, BlinkMacSystemFont,
"Segoe UI", sans-serif;
@@ -59,7 +65,8 @@ input:focus, textarea:focus {
button, .button {
font-size: 1rem;
background: var(--accent);
color: #0b0d12;
color: var(--bg);
/* color: var(--btn-text); */
border: none;
border-radius: var(--radius);
padding: 0.6rem 1rem;
@@ -74,6 +81,19 @@ button:hover, .button:hover {
background: var(--accent-hover);
}
.secondary {
background: transparent;
color: var(--text);
border: 1px solid var(--border);
padding: 8px 16px;
border-radius: var(--radius);
cursor: pointer;
}
.secondary:hover {
background: var(--panel-hover);
}
input[type="checkbox"] {
appearance: none;
-webkit-appearance: none;

View File

@@ -347,7 +347,7 @@ async function onSend(content: string) {
border-radius: var(--radius);
border: none;
background: transparent;
color: white;
color: var(--text);
cursor: pointer;
font-size: 1.6rem;
@@ -386,7 +386,7 @@ async function onSend(content: string) {
}
.retry-btn:hover {
background: rgba(255, 255, 255, 0.05);
background: var(--panel-hover);
}
.no-room {

View File

@@ -76,15 +76,6 @@ const emit = defineEmits(['yes', 'no']);
color: var(--text);
}
.secondary {
cursor: pointer;
padding: 8px 16px;
border-radius: var(--radius);
background: transparent;
color: var(--text);
border: 1px solid var(--border);
}
.btn-danger {
border-color: var(--error);
color: var(--error);

View File

@@ -92,14 +92,4 @@ async function submit() {
justify-content: flex-end;
gap: 0.5rem;
}
.secondary {
background: transparent;
color: var(--text);
border: 1px solid var(--border);
}
.secondary:hover {
background: rgba(255, 255, 255, 0.05);
}
</style>

View File

@@ -120,14 +120,4 @@ async function submit() {
justify-content: flex-end;
gap: 0.5rem;
}
.secondary {
background: transparent;
color: var(--text);
border: 1px solid var(--border);
}
.secondary:hover {
background: rgba(255, 255, 255, 0.05);
}
</style>

View File

@@ -1,10 +1,17 @@
<template>
<textarea ref="textareaRef" v-model="content" @input="resize" @keydown="handleKeydown" rows="1"
<div class="container-div">
<textarea ref="textareaRef" v-model="content" @input="handleInput" @keydown="handleKeydown" rows="1"
:placeholder="$t('chat-input-placeholder')"></textarea>
<button class="send-btn" @click="submit" :disabled="!content.trim()">
<i class="fas fa-paper-plane"></i>
</button>
</div>
</template>
<script setup lang="ts">
import { ref, nextTick } from 'vue'
import { ref, nextTick, onMounted } from 'vue'
import { saveMessageDraft, getMessageDraft } from '../store'
const content = ref('')
const textareaRef = ref<HTMLTextAreaElement | null>(null)
@@ -17,10 +24,24 @@ defineExpose({
}
})
onMounted(async () => {
const saved = await getMessageDraft()
if (saved) {
content.value = saved
resize()
}
})
function handleInput() {
resize()
saveMessageDraft(content.value)
}
function submit() {
if (!content.value.trim()) return
emit('send', content.value)
content.value = ''
saveMessageDraft('')
resize()
}
@@ -43,6 +64,9 @@ function handleKeydown(e: KeyboardEvent) {
textarea.selectionStart = textarea.selectionEnd = start + 1
})
resize()
saveMessageDraft(content.value)
e.preventDefault()
} else if (!e.shiftKey && !e.altKey && e.key === 'Enter') {
submit()
@@ -52,6 +76,13 @@ function handleKeydown(e: KeyboardEvent) {
</script>
<style scoped>
.container-div {
position: relative;
width: 100%;
display: flex;
align-items: flex-end;
}
textarea {
width: 100%;
resize: none;
@@ -64,5 +95,26 @@ textarea {
border: none;
color: inherit;
outline: none;
flex: 1;
}
.send-btn {
margin-right: 0.8rem;
background: transparent;
border: none;
cursor: pointer;
color: var(--text);
padding: 0;
transition: color 0.2s;
font-size: 1.2rem;
}
.send-btn:hover:not(:disabled) {
color: var(--accent);
}
.send-btn:disabled {
cursor: not-allowed;
opacity: 0.5;
}
</style>

View File

@@ -1,38 +1,56 @@
<template>
<ul>
<ul :class="{ 'is-compact': isCompact }">
<li v-for="(m, i) in messages" :key="i" class="message" :class="{ 'is-me': m.sender_uuid === currentUserUuid }">
<div class="sender-info">
<img :src="getAvatarUrl(m.sender_uuid)" @error="handleAvatarError" class="avatar" />
<div class="sender">{{ m.sender }}</div>
<img :src="getAvatarUrl(m.sender_uuid)" @error="handleAvatarError" class="avatar clickable"
@click.stop="openUserProfile(m)" />
<div class="sender clickable" @click.stop="openUserProfile(m)">
{{ m.sender }}
</div>
<span class="timestamp">{{ m.sent_at }}</span>
</div>
<div class="message-content">{{ m.content }}</div>
</li>
</ul>
<UserProfileModal v-if="selectedUser" :username="selectedUser.name" :user-uuid="selectedUser.uuid"
@close="selectedUser = null" />
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import type { Message } from '../types'
import { getAvatarUrl } from '../store.ts'
import { getAvatarUrl, getCompactLayoutPreference } from '../store.ts'
import defaultAvatar from '../assets/default-avatar.png'
import { getAuthData } from '../store.ts';
import UserProfileModal from './UserProfileModal.vue';
defineProps<{ messages: Message[] }>()
const currentUserUuid = ref<string | null>(null)
const isCompact = ref(false)
const selectedUser = ref<{ name: string, uuid: string } | null>(null)
onMounted(async () => {
const auth = await getAuthData()
if (auth.user) {
currentUserUuid.value = auth.user.uuid
}
isCompact.value = await getCompactLayoutPreference()
})
const handleAvatarError = (event: Event) => {
const img = event.target as HTMLImageElement;
img.src = defaultAvatar;
};
const openUserProfile = (message: Message) => {
selectedUser.value = {
name: message.sender,
uuid: message.sender_uuid
}
}
</script>
<style scoped>
@@ -50,14 +68,12 @@ ul {
flex-direction: column;
align-items: flex-start;
justify-content: center;
/* gap: 0.5rem; */
background: var(--panel-accent);
padding: 0;
max-width: 80%;
width: fit-content;
align-self: flex-start;
/* border: 1px solid var(--border); */
border-radius: var(--radius);
background: var(--panel-accent);
}
.sender-info {
@@ -66,14 +82,41 @@ ul {
justify-content: flex-start;
align-items: center;
gap: 0.7rem;
/* border: 1px solid var(--border); */
border-bottom: 1px solid var(--border);
/* border-radius: var(--radius) var(--radius) 0 0; */
/* background-color: rgba(255, 255, 255, 0.02); */
width: 100%;
padding: 5px 18px;
}
.avatar {
width: 32px;
height: 32px;
border-radius: 50%;
object-fit: cover;
}
.clickable {
cursor: pointer;
transition: opacity 0.2s;
}
.clickable:hover {
opacity: 0.8;
}
.sender.clickable:hover {
text-decoration: underline;
}
.message-content {
padding: 10px;
padding-left: 1rem;
padding-right: 1rem;
white-space: pre-wrap;
word-wrap: break-word;
max-width: 100%;
display: block;
}
.message.is-me {
align-self: flex-end;
align-items: flex-end;
@@ -87,13 +130,31 @@ ul {
.message.is-me .message-content {
text-align: right;
padding-right: 1rem;
padding-left: 10px;
}
ul.is-compact .message {
background: transparent;
max-width: 90%;
}
ul.is-compact .sender-info {
border-bottom: none;
padding: 5px 18px 0 18px;
}
ul.is-compact .message-content {
padding: 5px 10px 10px 50px;
}
ul.is-compact .message.is-me .message-content {
padding-right: 50px;
padding-left: 0;
text-align: right;
}
.sender {
font-weight: bold;
font-size: 1.1rem;
/* flex: 1; */
}
.timestamp {
@@ -101,13 +162,4 @@ ul {
opacity: 0.7;
font-size: 0.7rem;
}
.message-content {
padding: 10px;
padding-left: 1rem;
white-space: pre-wrap;
word-wrap: break-word;
max-width: 100%;
display: block;
}
</style>

View File

@@ -76,7 +76,7 @@ onMounted(() => {
top: 4px;
right: 4px;
background-color: var(--accent);
color: black;
color: var(--bg);
font-size: 0.65rem;
font-weight: bold;
min-width: 18px;
@@ -92,7 +92,7 @@ onMounted(() => {
@media (hover: hover) {
.nav-item:hover {
background: rgba(255, 255, 255, 0.04);
background: var(--panel-hover);
}
.nav-item:not(.router-link-active):hover i {

View File

@@ -205,15 +205,6 @@ const handleAvatarError = (event: Event) => {
gap: 0.5rem;
}
.secondary {
background: transparent;
color: var(--text);
border: 1px solid var(--border);
padding: 8px 16px;
border-radius: var(--radius);
cursor: pointer;
}
.member-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(80px, 1fr));

View File

@@ -138,7 +138,7 @@ onMounted(async () => {
}
.retry-btn:hover {
background: rgba(255, 255, 255, 0.05);
background: var(--panel-hover);
}
.room-content-wrapper {
@@ -150,7 +150,7 @@ onMounted(async () => {
.unread-badge {
background-color: var(--accent);
color: black;
color: var(--bg);
font-size: 0.75rem;
font-weight: bold;
min-width: 20px;
@@ -193,7 +193,7 @@ onMounted(async () => {
}
.room-item:hover {
background: rgba(255, 255, 255, 0.05);
background: var(--panel-hover);
color: var(--text);
}
@@ -236,6 +236,6 @@ onMounted(async () => {
}
.create-btn:hover {
background: rgba(255, 255, 255, 0.05);
background: var(--panel-hover);
}
</style>

View File

@@ -112,7 +112,6 @@ async function submit() {
display: flex;
flex-direction: column;
gap: 1.2rem;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.3);
}
.subtitle {
@@ -154,10 +153,4 @@ button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.secondary {
background: transparent;
color: var(--text);
border: 1px solid var(--border);
}
</style>

View File

@@ -202,7 +202,7 @@ async function handleUpload() {
text-align: center;
cursor: pointer;
transition: all 0.2s ease;
background: rgba(255, 255, 255, 0.02);
background: var(--panel-accent);
min-height: 180px;
display: flex;
align-items: center;
@@ -221,7 +221,7 @@ async function handleUpload() {
.drop-zone:hover {
border-color: var(--accent);
background: rgba(255, 255, 255, 0.05);
background: var(--panel-hover);
}
.drop-content i {
@@ -299,10 +299,4 @@ async function handleUpload() {
justify-content: flex-end;
gap: 0.5rem;
}
.secondary {
background: transparent;
color: var(--text);
border: 1px solid var(--border);
}
</style>

View File

@@ -0,0 +1,256 @@
<template>
<div class="backdrop" @click.self="emit('close')">
<div class="modal">
<!-- <h2>User Profile</h2> -->
<div class="profile-content">
<img :src="getAvatarUrl(userUuid)" @error="handleAvatarError" class="avatar-large" />
<div class="info-group">
<label>{{ $t('profile-username') }}</label>
<div class="info-value">{{ username }}</div>
</div>
<div class="info-group">
<label>{{ $t('profile-userid') }}</label>
<div class="info-value uuid">{{ userUuid }}</div>
</div>
</div>
<div class="actions">
<!-- Friend Button -->
<div v-if="!isMe && !isLoading" class="friend-actions">
<button v-if="isFriend" class="btn-danger" @click="requestRemoveFriend" :disabled="isActionLoading">
{{ $t('profile-remove-friend') }}
</button>
<button v-else class="btn-primary" @click="handleAddFriend" :disabled="isActionLoading || requestSent">
{{ requestSent ? $t('profile-request-sent') : $t('profile-add-friend') }}
</button>
</div>
<button type="button" @click="emit('close')" class="secondary">
{{ $t('shared-close') }}
</button>
</div>
</div>
</div>
<ConfirmModal v-if="modalState.visible" :title="modalState.title" :message="modalState.message"
:confirm-label="modalState.confirmLabel" :confirm-button-class="modalState.isDanger ? 'btn-danger' : ''"
@yes="handleConfirmRemove" @no="closeConfirmModal" />
</template>
<script setup lang="ts">
import { ref, onMounted, computed, reactive } from 'vue'
import { getAvatarUrl, getAuthData } from '../store.ts'
import { checkIsFriend, removeFriend, sendFriendRequest } from '../api/friends'
import defaultAvatar from '../assets/default-avatar.png'
import ConfirmModal from './ConfirmModal.vue'
import { useFluent } from 'fluent-vue'
const { $t } = useFluent()
const props = defineProps<{
username: string
userUuid: string
}>()
const emit = defineEmits<{
(e: 'close'): void
}>()
const currentUserUuid = ref<string | null>(null)
const isFriend = ref(false)
const isLoading = ref(true)
const isActionLoading = ref(false)
const requestSent = ref(false)
// Confirm Modal State
const modalState = reactive({
visible: false,
title: '',
message: '',
confirmLabel: '',
isDanger: false
});
const isMe = computed(() => currentUserUuid.value === props.userUuid)
onMounted(async () => {
try {
const auth = await getAuthData()
if (auth.user) {
currentUserUuid.value = auth.user.uuid
}
// Only check if not self
if (currentUserUuid.value && currentUserUuid.value !== props.userUuid) {
isFriend.value = await checkIsFriend(props.userUuid)
}
} catch (error) {
console.error("Failed to check friend status", error)
} finally {
isLoading.value = false
}
})
const handleAddFriend = async () => {
isActionLoading.value = true
try {
await sendFriendRequest(props.username)
requestSent.value = true
} catch (error) {
console.error("Failed to send friend request", error)
} finally {
isActionLoading.value = false
}
}
const requestRemoveFriend = () => {
modalState.title = $t('profile-remove-friend');
modalState.message = $t('profile-remove-friend-confirm', { user: props.username });
modalState.confirmLabel = $t('shared-delete'); // or 'shared-confirm'
modalState.isDanger = true;
modalState.visible = true;
};
const closeConfirmModal = () => {
modalState.visible = false;
};
const handleConfirmRemove = async () => {
closeConfirmModal();
isActionLoading.value = true;
try {
await removeFriend(props.userUuid)
isFriend.value = false
} catch (error) {
console.error("Failed to remove friend", error)
} finally {
isActionLoading.value = false
}
};
const handleAvatarError = (event: Event) => {
const img = event.target as HTMLImageElement;
img.src = defaultAvatar;
};
</script>
<style scoped>
.backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal {
background: var(--panel);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1.5rem;
width: 100%;
max-width: 420px;
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.modal h2 {
font-size: 1.25rem;
font-weight: 600;
margin: 0;
}
.profile-content {
display: flex;
flex-direction: column;
align-items: center;
gap: 1.5rem;
}
.avatar-large {
width: 96px;
height: 96px;
border-radius: 50%;
object-fit: cover;
border: 2px solid var(--border);
}
.info-group {
width: 100%;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.info-value {
font-size: 1.1rem;
font-weight: 500;
background: var(--panel-accent);
padding: 0.75rem;
border-radius: var(--radius);
word-break: break-all;
}
.info-value.uuid {
font-family: monospace;
font-size: 0.9rem;
opacity: 0.8;
}
.actions {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
}
.friend-actions {
display: flex;
gap: 0.5rem;
}
.btn-primary {
background-color: var(--accent);
color: var(--bg);
border: 1px solid var(--accent);
padding: 8px 16px;
border-radius: var(--radius);
cursor: pointer;
font-weight: 600;
}
.btn-primary:hover:not(:disabled) {
background-color: var(--accent-hover);
}
.btn-danger {
background-color: transparent;
border: 1px solid var(--error);
color: var(--error);
padding: 8px 16px;
border-radius: var(--radius);
cursor: pointer;
}
.btn-danger:hover:not(:disabled) {
background-color: var(--error);
color: white;
}
button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.secondary {
margin-left: auto;
}
</style>

View File

@@ -49,10 +49,4 @@ const emit = defineEmits(['close']);
justify-content: flex-end;
gap: 0.5rem;
}
.secondary {
background: transparent;
color: var(--text);
border: 1px solid var(--border);
}
</style>

View File

@@ -52,6 +52,15 @@ chat-create-submit = Create
chat-connecting = Connecting to room...
chat-connecting-failed = Could not connect. Check internet connection.
## User profile
profile-title = User profile
profile-add-friend = Add Friend
profile-remove-friend = Remove Friend
profile-remove-friend-confirm = Are you sure you want to remove this friend?
profile-request-sent = Request sent
profile-username = Username
profile-userid = User ID
## Friends page
friends-title = Your friends
friends-add-title = Add Friend
@@ -79,6 +88,8 @@ settings-loading = Loading settings...
settings-title = Settings
settings-account = Account
settings-language = Language
settings-appearance = Appearance
settings-compact-layout = Use compact layout
settings-label-username = Username:
settings-label-email = Email:
settings-update-btn = Update
@@ -100,7 +111,7 @@ settings-error-upload-avatar-failed-upload = Failed to upload image
## Warning
warning-wrongversion-title = Wrong app version
warning-wrongversion-message = The backend expects version {$expectedBackendVersion} while your version of the app ({$appVersion}) supports backend version {$backendVersion}. Please update to avoid potential issues.
warning-wrongversion-message = The backend expects version {$backendVersion} while your version of the app ({$appVersion}) supports backend version {$expectedBackendVersion}. Please update to avoid potential issues.
warning-wrongversion-dismiss = I know what I'm doing
## Shared

View File

@@ -52,6 +52,15 @@ chat-create-submit = Créer
chat-connecting = Connexion au salon...
chat-connecting-failed = Impossible d'établir la connexion. Vérifiez votre internet.
## User profile
profile-title = Profil d'utilisateur
profile-add-friend = Ajouter en ami
profile-remove-friend = Retirer l'ami
profile-remove-friend-confirm = Etes-vous sûr de vouloir retirer cet ami ?
profile-request-sent = Requête envoyée
profile-username = Nom d'utilisateur
profile-userid = ID d'utilisateur
## Friends page
friends-title = Vos amis
friends-add-title = Ajouter un ami
@@ -79,6 +88,8 @@ settings-loading = Chargement des paramètres...
settings-title = Paramètres
settings-account = Compte
settings-language = Langue
settings-appearance = Apparence
settings-compact-layout = Utiliser la disposition compacte
settings-label-username = Nom d'utilisateur :
settings-label-email = Email :
settings-update-btn = Modifier
@@ -98,7 +109,7 @@ settings-error-upload-avatar-failed-upload = Erreur d'envoi de l'image
## Warning
warning-wrongversion-title = Mauvaise version de l'application
warning-wrongversion-message = Le backend attend la version {$expectedBackendVersion} alors que votre version de l'application ({$appVersion}) prend en charge la version {$backendVersion} du backend. Veuillez mettre à jour pour éviter d'éventuels problèmes.
warning-wrongversion-message = Le backend attend la version {$backendVersion} alors que votre version de l'application ({$appVersion}) prend en charge la version {$expectedBackendVersion} du backend. Veuillez mettre à jour pour éviter d'éventuels problèmes.
warning-wrongversion-dismiss = Je sais ce que je fais
## Shared

View File

@@ -1,7 +1,7 @@
import { createApp } from 'vue'
import router from './router.ts'
import App from './App.vue'
import { validateToken } from './store.ts'
import { validateToken, initTheme } from './store.ts'
import { fluent, setLanguage } from './i18n'
import './base.css'
@@ -17,6 +17,8 @@ async function init() {
const savedLocale = await getLocalePreference();
const osLocale = navigator.language;
await initTheme();
if (savedLocale) {
setLanguage(savedLocale);
} else {
@@ -28,9 +30,9 @@ async function init() {
init()
export const API = 'http://127.0.0.1:8080'
// export const API = 'http://127.0.0.1:8080'
// export const API = 'http://192.168.1.183:8080'
// export const API = 'https://alatreon.org/frangipane'
export const API_WS = 'ws://127.0.0.1:8080/ws'
export const API = 'https://alatreon.org/frangipane'
// export const API_WS = 'ws://127.0.0.1:8080/ws'
// export const API_WS = 'ws://192.168.1.183:8080/ws'
// export const API_WS = 'wss://alatreon.org/frangipane/ws'
export const API_WS = 'wss://alatreon.org/frangipane/ws'

View File

@@ -152,7 +152,7 @@ const handleNotification = (roomUuid: string) => {
.sidebar-overlay {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.3);
/* background: rgba(0, 0, 0, 0.3); */
z-index: 15;
}

View File

@@ -15,28 +15,46 @@
<div class="friends-list">
<h2>{{ $t('friends-list-header') }}</h2>
<ul>
<li v-for="friend in friends" :key="friend.uuid">
{{ friend.username }}
</li>
</ul>
<div class="friends">
<button class="friend" v-for="friend in friends" :key="friend.uuid" @click="openProfile(friend)">
<img :src="getAvatarUrl(friend.uuid)" @error="handleAvatarError" class="avatar" />
<p>{{ friend.username }}</p>
</button>
</div>
</div>
<UserProfileModal v-if="selectedFriend" :username="selectedFriend.username" :user-uuid="selectedFriend.uuid"
@close="selectedFriend = null" />
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { fetchFriends, sendFriendRequest } from '../api/friends'
import type { Friend } from '../types'
import { getAvatarUrl } from '../store'
import defaultAvatar from '../assets/default-avatar.png'
import UserProfileModal from '../components/UserProfileModal.vue'
const friends = ref<Friend[]>([])
const username = ref('')
const errorMessage = ref('')
const selectedFriend = ref<Friend | null>(null)
onMounted(async () => {
friends.value = await fetchFriends()
})
const openProfile = (friend: Friend) => {
selectedFriend.value = friend
}
const handleAvatarError = (event: Event) => {
const img = event.target as HTMLImageElement;
img.src = defaultAvatar;
};
async function send() {
if (!username.value) {
// errorMessage.value = 'Username is required.'
@@ -106,21 +124,37 @@ async function send() {
padding: 1rem;
}
.friends-list ul {
list-style: none;
padding: 0;
}
.friends-list li {
.friend {
width: 100%;
box-sizing: border-box;
background: var(--panel-light);
color: var(--text);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 0.75rem 1rem;
/* padding: 0.75rem 1rem; */
margin-bottom: 0.5rem;
display: flex;
flex-direction: row;
align-items: center;
gap: 1.2rem;
margin: 0 0 0.5rem 0;
}
.friend:hover p {
text-decoration: underline;
}
/* .friend:hover { */
/* background-color: var(--panel-hover); */
/* } */
.friends-list h2 {
font-size: 1.25rem;
margin-bottom: 1rem;
}
.avatar {
height: 42px;
width: 42px;
}
</style>

View File

@@ -182,7 +182,7 @@ async function declineRoom(senderUuid: string, roomUuid: string) {
}
.decline-btn:hover {
background-color: rgba(255, 255, 255, 0.05);
background-color: var(--panel-hover);
}
@media (max-width: 720px) {

View File

@@ -38,6 +38,21 @@
</div>
</div>
<h2>{{ $t('settings-appearance') || 'Appearance' }}</h2>
<div class="theme-grid">
<button v-for="theme in availableThemes" :key="theme.id" class="theme-btn"
:class="{ active: currentTheme === theme.id }" @click="changeTheme(theme.id)">
{{ theme.name }}
</button>
</div>
<div class="setting-checkbox">
<label class="checkbox-label">
<input type="checkbox" v-model="useCompactLayout" @change="toggleCompactLayout" />
<span>{{ $t('settings-compact-layout') || 'Use Compact Layout' }} (WIP)</span>
</label>
</div>
<button class="logout-btn" @click="logout">
<i class="fa-solid fa-right-from-bracket"></i>
<span>{{ $t('settings-logout-btn') }}</span>
@@ -49,7 +64,9 @@
import { ref, onMounted, computed } from 'vue'
import { useRouter } from 'vue-router'
import { logout as authLogout } from '../store.ts'
import { saveThemePreference, getThemePreference } from "../store.ts"
import { getAuthData } from "../store.ts"
import { saveCompactLayoutPreference, getCompactLayoutPreference } from "../store.ts"
import type { User } from "../types"
import UpdateAccountModal from '../components/UpdateAccountModal.vue'
import { useFluent } from 'fluent-vue'
@@ -58,22 +75,28 @@ import { getSupportedLanguagesMetadata, setLanguage } from '../i18n'
import UploadAvatarModal from '../components/UploadAvatarModal.vue'
import defaultAvatar from '../assets/default-avatar.png'
import { getAvatarUrl } from '../store.ts'
import { getAvailableThemes } from '../themeLoader';
const { $t } = useFluent()
const showAvatarModal = ref(false)
const router = useRouter()
const availableThemes = getAvailableThemes();
const user = ref<User | null>(null)
const showUpdateModal = ref(false)
const currentLang = ref('')
const languages = computed(() => getSupportedLanguagesMetadata())
const currentTheme = ref('default');
const useCompactLayout = ref(false)
const handleAvatarError = (event: Event) => {
const img = event.target as HTMLImageElement;
img.src = defaultAvatar;
};
const showAvatarModal = ref(false)
const router = useRouter()
const user = ref<User | null>(null)
const showUpdateModal = ref(false)
const { $t } = useFluent()
const currentLang = ref('')
const languages = computed(() => getSupportedLanguagesMetadata())
async function fetchUserData() {
try {
const auth = await getAuthData()
@@ -85,8 +108,9 @@ async function fetchUserData() {
onMounted(async () => {
const pref = await getLocalePreference()
// Synchronize the UI state with the actual active language
currentLang.value = pref || (navigator.language.split('-')[0])
currentTheme.value = await getThemePreference()
useCompactLayout.value = await getCompactLayoutPreference()
fetchUserData()
})
@@ -97,6 +121,15 @@ async function changeLanguage(code: string) {
await saveLocalePreference(actual)
}
async function changeTheme(theme: string) {
currentTheme.value = theme
await saveThemePreference(theme)
}
async function toggleCompactLayout() {
await saveCompactLayoutPreference(useCompactLayout.value)
}
function logout() {
authLogout()
router.push('/login')
@@ -171,12 +204,67 @@ h2 {
margin: 0;
}
.lang-btn:hover:not(.active) {
background: rgba(var(--accent-rgb), 0.1);
border-color: var(--accent);
}
.lang-btn.active {
background: var(--accent);
color: #000;
border-color: var(--accent);
}
.theme-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 0.5rem;
margin-top: 0.5rem;
}
.theme-btn {
background: var(--panel);
border: 1px solid var(--border);
color: var(--text);
margin: 0;
padding: 12px;
font-size: 0.9rem;
}
.theme-btn.active {
background: var(--accent);
color: var(--bg);
border-color: var(--accent);
}
.theme-btn:hover:not(.active) {
background: rgba(var(--accent-rgb), 0.1);
border-color: var(--accent);
}
.setting-checkbox {
padding: 10px;
margin-top: 15px;
background: var(--panel);
border: 1px solid var(--border);
border-radius: var(--radius);
margin-bottom: 1rem;
}
.checkbox-label {
display: flex;
align-items: center;
gap: 10px;
cursor: pointer;
user-select: none;
}
.checkbox-label input {
width: 18px;
height: 18px;
accent-color: var(--accent);
}
.logout-btn {
cursor: pointer;
display: flex;
@@ -193,11 +281,11 @@ h2 {
}
.logout-btn:hover {
color: rgba(255, 80, 80, 0.8);
color: var(--error);
}
.logout-btn:hover i {
color: rgba(255, 80, 80, 0.8);
color: var(--error);
}
.logout-btn i {

View File

@@ -8,6 +8,7 @@ import { load, Store } from '@tauri-apps/plugin-store'
import { UpdateUserResponse } from './types'
import { reactive } from 'vue'
import { API } from './main'
import { applyTheme } from './themeLoader.ts';
let store: Store | null = null
export const initAuth = getAuthData
@@ -184,3 +185,54 @@ export function getAvatarUrl(uuid: string | undefined | null) {
export function refreshAvatar(uuid: string) {
avatarTimestamps[uuid] = Date.now()
}
// ==== Color themes ====
export async function saveThemePreference(themeId: string) {
const s = await getStore();
await s.set('theme', themeId);
await s.save();
applyTheme(themeId);
}
export async function initTheme() {
const s = await getStore();
const themeId = (await s.get<string>('theme')) || 'default';
applyTheme(themeId);
}
export async function getThemePreference(): Promise<string> {
const s = await getStore()
return (await s.get<string>('theme')) ?? 'default'
}
export async function applyStoredTheme() {
const theme = await getThemePreference();
document.documentElement.setAttribute('data-theme', theme);
}
// ==== Layout ====
export async function saveCompactLayoutPreference(enabled: boolean) {
const s = await getStore();
await s.set('compact_layout', enabled);
await s.save();
}
export async function getCompactLayoutPreference(): Promise<boolean> {
const s = await getStore();
return (await s.get<boolean>('compact_layout')) ?? false;
}
// ==== Message draft ====
export async function saveMessageDraft(text: string) {
const s = await getStore()
await s.set('message_draft', text)
await s.save()
}
export async function getMessageDraft(): Promise<string> {
const s = await getStore()
return (await s.get<string>('message_draft')) ?? ''
}

26
src/themeLoader.ts Normal file
View File

@@ -0,0 +1,26 @@
import themes from './themes.json';
export type ThemeKey = keyof typeof themes;
export function applyTheme(themeKey: string) {
const theme = (themes as any)[themeKey] || themes.default;
const root = document.documentElement;
// Convert json keys to css Variables
Object.entries(theme.colors).forEach(([key, value]) => {
root.style.setProperty(`--${key}`, value as string);
});
// if (themeKey.includes('light')) {
// root.style.setProperty('--btn-text', theme.colors.bg);
// } else {
// root.style.setProperty('--btn-text', '#0b0d12');
// }
}
export function getAvailableThemes() {
return Object.entries(themes).map(([id, data]) => ({
id,
name: data.name
}));
}

172
src/themes.json Normal file
View File

@@ -0,0 +1,172 @@
{
"default": {
"name": "Frangipane Dark",
"colors": {
"bg": "#0f1116",
"panel": "#171922",
"panel-accent": "#12141B",
"panel-hover": "#22242D",
"text": "#e6e6eb",
"muted": "#9aa0aa",
"accent": "#f27aa3",
"accent-rgb": "242, 122, 163",
"accent-hover": "#ff91b3",
"accent-second": "#96CDFB",
"border": "#2a2f3b",
"error": "#ff5050"
}
},
"catppuccin-latte": {
"name": "Catppuccin Latte",
"colors": {
"bg": "#eff1f5",
"panel": "#e6e9ef",
"panel-hover": "#bcc0cc",
"panel-accent": "#ccd0da",
"text": "#4c4f69",
"muted": "#7c7f93",
"accent": "#ea76cb",
"accent-rgb": "234, 118, 203",
"accent-hover": "#d20f39",
"accent-second": "#1e66f5",
"border": "#dce0e8",
"error": "#d20f39"
}
},
"catppuccin-frappe": {
"name": "Catppuccin Frappé",
"colors": {
"bg": "#303446",
"panel": "#292c3c",
"panel-accent": "#232634",
"panel-hover": "#414559",
"text": "#c6d0f5",
"muted": "#838ba7",
"accent": "#8caaee",
"accent-rgb": "140, 170, 238",
"accent-hover": "#85c1dc",
"accent-second": "#f2d5cf",
"border": "#414559",
"error": "#e78284"
}
},
"catppuccin-macchiato": {
"name": "Catppuccin Macchiato",
"colors": {
"bg": "#24273a",
"panel": "#1e2030",
"panel-accent": "#181926",
"panel-hover": "#363a4f",
"text": "#cad3f5",
"muted": "#8087a2",
"accent": "#c6a0f6",
"accent-rgb": "198, 160, 246",
"accent-hover": "#f5bde6",
"accent-second": "#8aadf4",
"border": "#494d64",
"error": "#ed8796"
}
},
"catppuccin-mocha": {
"name": "Catppuccin Mocha",
"colors": {
"bg": "#1e1e2e",
"panel": "#181825",
"panel-accent": "#11111b",
"panel-hover": "#313244",
"text": "#cdd6f4",
"muted": "#7f849c",
"accent": "#a6e3a1",
"accent-rgb": "166, 227, 161",
"accent-hover": "#94e2d5",
"accent-second": "#f5c2e7",
"border": "#313244",
"error": "#f38ba8"
}
},
"nord": {
"name": "Nordic",
"colors": {
"bg": "#2e3440",
"panel": "#3b4252",
"panel-accent": "#242933",
"panel-hover": "#434c5e",
"text": "#eceff4",
"muted": "#d8dee9",
"accent": "#88c0d0",
"accent-rgb": "136, 192, 208",
"accent-hover": "#8fbcbb",
"accent-second": "#81a1c1",
"border": "#4c566a",
"error": "#bf616a"
}
},
"tokyo-night": {
"name": "Tokyo Night",
"colors": {
"bg": "#1a1b26",
"panel": "#16161e",
"panel-accent": "#1f2335",
"panel-hover": "#292e42",
"text": "#a9b1d6",
"muted": "#565f89",
"accent": "#7aa2f7",
"accent-rgb": "122, 162, 247",
"accent-hover": "#7dcfff",
"accent-second": "#bb9af7",
"border": "#24283b",
"error": "#f7768e"
}
},
"gruvbox-dark": {
"name": "Gruvbox Dark",
"colors": {
"bg": "#282828",
"panel": "#3c3836",
"panel-accent": "#282828",
"panel-hover": "#504945",
"text": "#ebdbb2",
"muted": "#a89984",
"accent": "#fabd2f",
"accent-rgb": "250, 189, 47",
"accent-hover": "#fe8019",
"accent-second": "#b8bb26",
"border": "#504945",
"error": "#fb4934"
}
},
"gruvbox-light": {
"name": "Gruvbox Light",
"colors": {
"bg": "#fbf1c7",
"panel": "#f2e5bc",
"panel-accent": "#d5c4a1",
"panel-hover": "#ebdbb2",
"text": "#3c3836",
"muted": "#7c6f64",
"accent": "#d65d0e",
"accent-rgb": "214, 93, 14",
"accent-hover": "#9d0006",
"accent-second": "#458588",
"border": "#bdae93",
"error": "#cc241d"
}
},
"solarized-dark": {
"name": "Solarized Dark",
"colors": {
"bg": "#002b36",
"panel": "#073642",
"panel-accent": "#00212b",
"panel-hover": "#586e75",
"text": "#839496",
"muted": "#657b83",
"accent": "#268bd2",
"accent-rgb": "38, 139, 210",
"accent-hover": "#2aa198",
"accent-second": "#859900",
"border": "#073642",
"error": "#dc322f"
}
}
}

View File

@@ -430,6 +430,13 @@
dependencies:
"@tauri-apps/api" "^2.8.0"
"@tauri-apps/plugin-os@~2":
version "2.3.2"
resolved "https://registry.yarnpkg.com/@tauri-apps/plugin-os/-/plugin-os-2.3.2.tgz#de916a82d8d955bba59a2ffdba7f7aaa29397246"
integrity sha512-n+nXWeuSeF9wcEsSPmRnBEGrRgOy6jjkSU+UVCOV8YUGKb2erhDOxis7IqRXiRVHhY8XMKks00BJ0OAdkpf6+A==
dependencies:
"@tauri-apps/api" "^2.8.0"
"@tauri-apps/plugin-store@~2":
version "2.4.1"
resolved "https://registry.yarnpkg.com/@tauri-apps/plugin-store/-/plugin-store-2.4.1.tgz#5e2d3362e41861d2fa79a3f1a78c091e12963236"