added ownership transfer

This commit is contained in:
2026-01-17 08:41:51 +01:00
parent d325511d0e
commit a4c6e0661b
7 changed files with 280 additions and 178 deletions

View File

@@ -3,7 +3,7 @@
@close="showInviteModal = false" @room-changed="handleRoomChanged" /> @close="showInviteModal = false" @room-changed="handleRoomChanged" />
<RoomDetailsModal v-if="showDetailsModal && isSocketConnected" :roomUuid="props.uuid" <RoomDetailsModal v-if="showDetailsModal && isSocketConnected" :roomUuid="props.uuid"
:roomName="currentRoom?.name || 'Unknown room'" :isGlobal="currentRoom?.global || false" :roomName="currentRoom?.name || 'Unknown room'" :isGlobal="currentRoom?.global || false" :ownerUuid="currentRoom?.owner_uuid || ''"
@close="showDetailsModal = false" @room-changed="handleRoomChanged" /> @close="showDetailsModal = false" @room-changed="handleRoomChanged" />
<div v-if="uuid === 'none'" class="no-room"> <div v-if="uuid === 'none'" class="no-room">

View File

@@ -8,10 +8,12 @@
<input v-model="name" :placeholder="$t('chat-create-name-placeholder')" autofocus /> <input v-model="name" :placeholder="$t('chat-create-name-placeholder')" autofocus />
</div> </div>
<!--
<label class="checkbox"> <label class="checkbox">
<input type="checkbox" v-model="global" /> <input type="checkbox" v-model="global" />
<span>{{ $t('chat-create-global') }}</span> <span>{{ $t('chat-create-global') }}</span>
</label> </label>
-->
<div class="actions"> <div class="actions">
<button type="button" @click="emit('close')" class="secondary"> <button type="button" @click="emit('close')" class="secondary">

View File

@@ -1,51 +1,60 @@
<template> <template>
<div class="backdrop" @click.self="emit('close')"> <div class="backdrop" @click.self="emit('close')">
<div class="modal"> <div class="modal">
<h2>{{ roomName }}</h2> <h2>{{ roomName }}</h2>
<h3 v-if="!isGlobal">Members:</h3> <h3 v-if="!isGlobal">Members:</h3>
<ul v-if="!isGlobal" class="member-list"> <ul v-if="!isGlobal" class="member-list">
<li v-for="user in users" :key="user.uuid || user.username" class="member-item"> <li v-for="user in users" :key="user.uuid || user.username" class="member-item" :class="{
<img :src="getAvatarUrl(user.uuid)" @error="handleAvatarError" class="avatar" alt="avatar" /> 'is-owner': user.uuid === ownerUuid,
<span class="member-name">{{ user.username }}</span> 'selectable': isTransferringOwnership && user.uuid !== ownerUuid,
</li> 'dimmed': isTransferringOwnership && user.uuid === currentUserUuid
</ul> }" @click="handleUserClick(user)">
<img :src="getAvatarUrl(user.uuid)" @error="handleAvatarError" class="avatar" alt="avatar" />
<span class="member-name">{{ user.username }}</span>
</li>
</ul>
<p v-else class="global-tag"> <p v-else class="global-tag">
<i class="fa-solid fa-earth"></i> <i class="fa-solid fa-earth"></i>
{{ $t('chat-room-global') }} {{ $t('chat-room-global') }}
</p> </p>
<div class="buttons"> <div class="buttons">
<button v-if="!isGlobal && !isOwner" class="btn" @click="requestLeave" :disabled="isLoading"> <button v-if="!isGlobal && !isOwner && !isTransferringOwnership" class="btn" @click="requestLeave"
{{ $t('chat-room-actions-leave') }} :disabled="isLoading">
</button> {{ $t('chat-room-actions-leave') }}
</button>
<button v-if="isOwner" class="btn delete-btn" @click="requestDelete" :disabled="isLoading"> <button v-if="isOwner && !isTransferringOwnership" class="btn delete-btn" @click="requestDelete"
{{ $t('chat-room-actions-delete') }} :disabled="isLoading || isTransferringOwnership">
</button> {{ $t('chat-room-actions-delete') }}
</button>
<!-- <button class="btn ownership-btn">{{ $t('chat-room-actions-ownership') }}</button> --> <button v-if="!isGlobal && isOwner" class="btn ownership-btn" :class="{ 'active': isTransferringOwnership }"
</div> @click="toggleTransferMode" :disabled="isLoading">
{{ isTransferringOwnership ? $t('shared-cancel') : $t('chat-room-actions-ownership') }}
</button>
</div>
<div class="actions"> <div class="actions">
<button @click="emit('close')" class="secondary"> <button @click="emit('close')" class="secondary">
{{ $t('shared-close') }} {{ $t('shared-close') }}
</button> </button>
</div> </div>
</div>
</div> </div>
</div>
<ConfirmModal v-if="modalState.visible" :title="modalState.title" :message="modalState.message" <ConfirmModal v-if="modalState.visible" :title="modalState.title" :message="modalState.message"
:confirm-label="modalState.confirmLabel" :confirm-button-class="modalState.isDanger ? 'btn-danger' : ''" :confirm-label="modalState.confirmLabel" :confirm-button-class="modalState.isDanger ? 'btn-danger' : ''"
@yes="handleConfirmAction" @no="closeConfirmModal" /> @yes="handleConfirmAction" @no="closeConfirmModal" />
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref, reactive } from 'vue'; import { computed, onMounted, ref, reactive } from 'vue';
import { UserProfile } from '../types'; import { UserProfile } from '../types';
import { deleteRoom, leaveRoom, listMembers } from '../api/rooms'; import { deleteRoom, leaveRoom, listMembers, transferOwnership } from '../api/rooms';
import { getAuthData, getAvatarUrl } from '../store.ts'; import { getAuthData, getAvatarUrl } from '../store.ts';
import defaultAvatar from '../assets/default-avatar.png'; import defaultAvatar from '../assets/default-avatar.png';
import ConfirmModal from './ConfirmModal.vue'; import ConfirmModal from './ConfirmModal.vue';
@@ -53,228 +62,296 @@ import { useFluent } from 'fluent-vue';
const { $t } = useFluent() const { $t } = useFluent()
const props = defineProps<{ roomUuid: string, roomName: string, isGlobal: boolean }>() const props = defineProps<{ roomUuid: string, roomName: string, isGlobal: boolean, ownerUuid: string }>()
const emit = defineEmits(['close', 'room-changed']); const emit = defineEmits(['close', 'room-changed']);
const users = ref<UserProfile[]>([]); const users = ref<UserProfile[]>([]);
const isLoading = ref(false); const isLoading = ref(false);
const currentUserUuid = ref<string>(''); const currentUserUuid = ref<string>('');
type ActionType = 'leave' | 'delete' | null; const isTransferringOwnership = ref(false);
const targetTransferUser = ref<UserProfile | null>(null);
type ActionType = 'leave' | 'delete' | 'transfer' | null;
const modalState = reactive({ const modalState = reactive({
visible: false, visible: false,
type: null as ActionType, type: null as ActionType,
title: '', title: '',
message: '', message: '',
confirmLabel: '', confirmLabel: '',
isDanger: false isDanger: false
}); });
const requestLeave = () => { const requestLeave = () => {
modalState.type = 'leave'; modalState.type = 'leave';
modalState.title = $t('chat-room-actions-leave'); modalState.title = $t('chat-room-actions-leave');
modalState.message = $t('chat-room-actions-leave-confirm'); modalState.message = $t('chat-room-actions-leave-confirm');
modalState.confirmLabel = $t('shared-leave'); modalState.confirmLabel = $t('shared-leave');
modalState.isDanger = false; modalState.isDanger = false;
modalState.visible = true; modalState.visible = true;
}; };
const requestDelete = () => { const requestDelete = () => {
modalState.type = 'delete'; modalState.type = 'delete';
modalState.title = $t('chat-room-actions-delete'); modalState.title = $t('chat-room-actions-delete');
modalState.message = $t('chat-room-actions-delete-confirm'); modalState.message = $t('chat-room-actions-delete-confirm');
modalState.confirmLabel = $t('shared-delete'); modalState.confirmLabel = $t('shared-delete');
modalState.isDanger = true; modalState.isDanger = true;
modalState.visible = true; modalState.visible = true;
};
const toggleTransferMode = () => {
isTransferringOwnership.value = !isTransferringOwnership.value;
};
const handleUserClick = (user: UserProfile) => {
if (!isTransferringOwnership.value || user.uuid === currentUserUuid.value) {
return;
}
targetTransferUser.value = user;
modalState.type = 'transfer';
modalState.title = $t('chat-room-actions-ownership');
modalState.message = $t('chat-room-actions-ownership-confirm', { user: user.username });
modalState.confirmLabel = $t('shared-confirm');
modalState.isDanger = true;
modalState.visible = true;
}; };
const closeConfirmModal = () => { const closeConfirmModal = () => {
modalState.visible = false; modalState.visible = false;
modalState.type = null; modalState.type = null;
targetTransferUser.value = null;
}; };
const handleConfirmAction = async () => { const handleConfirmAction = async () => {
const actionType = modalState.type; const actionType = modalState.type;
const userToTransfer = targetTransferUser.value;
closeConfirmModal(); closeConfirmModal();
isTransferringOwnership.value = false;
isLoading.value = true; isLoading.value = true;
try { try {
if (actionType === 'leave') { if (actionType === 'leave') {
await leaveRoom(props.roomUuid); await leaveRoom(props.roomUuid);
emit('room-changed'); emit('room-changed');
emit('close'); emit('close');
} else if (actionType === 'delete') { } else if (actionType === 'delete') {
await deleteRoom(props.roomUuid); await deleteRoom(props.roomUuid);
emit('room-changed'); emit('room-changed');
emit('close'); emit('close');
} } else if (actionType === 'transfer' && userToTransfer) {
} catch (error) { console.log("ownership")
console.error(`Failed to ${actionType} room:`, error); await transferOwnership(props.roomUuid, userToTransfer.uuid);
} finally { emit('room-changed');
isLoading.value = false; emit('close');
} }
} catch (error) {
console.error(`Failed to ${actionType} room:`, error);
} finally {
isLoading.value = false;
}
}; };
const isOwner = computed(() => { const isOwner = computed(() => {
return true; // Logic placeholder return currentUserUuid.value === props.ownerUuid;
}); });
onMounted(async () => { onMounted(async () => {
const auth = await getAuthData(); const auth = await getAuthData();
currentUserUuid.value = auth.user?.uuid || 'undefined'; currentUserUuid.value = auth.user?.uuid || 'undefined';
if (!props.isGlobal) { if (!props.isGlobal) {
users.value = await listMembers(props.roomUuid); users.value = await listMembers(props.roomUuid);
} }
}); });
const handleAvatarError = (event: Event) => { const handleAvatarError = (event: Event) => {
const img = event.target as HTMLImageElement; const img = event.target as HTMLImageElement;
img.src = defaultAvatar; img.src = defaultAvatar;
}; };
</script> </script>
<style scoped> <style scoped>
.backdrop { .backdrop {
position: fixed; position: fixed;
inset: 0; inset: 0;
background: rgba(0, 0, 0, 0.6); background: rgba(0, 0, 0, 0.6);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
z-index: 500; z-index: 500;
} }
.modal { .modal {
background: var(--panel); background: var(--panel);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: var(--radius); border-radius: var(--radius);
padding: 1.5rem; padding: 1.5rem;
width: 100%; width: 100%;
max-width: 400px; max-width: 400px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 1.2rem; gap: 1.2rem;
} }
.actions { .actions {
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
gap: 0.5rem; gap: 0.5rem;
} }
.secondary { .secondary {
background: transparent; background: transparent;
color: var(--text); color: var(--text);
border: 1px solid var(--border); border: 1px solid var(--border);
padding: 8px 16px; padding: 8px 16px;
border-radius: var(--radius); border-radius: var(--radius);
cursor: pointer; cursor: pointer;
} }
.member-list { .member-list {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(80px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(80px, 1fr));
gap: 1rem; gap: 1rem;
list-style: none; list-style: none;
padding: 0; padding: 0;
margin: 0; margin: 0;
overflow-y: auto; overflow-y: auto;
} }
.member-item { .member-item {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
text-align: center; text-align: center;
gap: 0.5rem; gap: 0.5rem;
padding: 5px;
border-radius: var(--radius);
transition: all 0.2s ease;
} }
.avatar { .avatar {
width: 48px; width: 48px;
height: 48px; height: 48px;
border-radius: 50%;
object-fit: cover;
border: 2px solid transparent;
} }
.member-name { .member-name {
font-size: 0.85rem; font-size: 0.85rem;
width: 100%; width: 100%;
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
}
.member-item.is-owner .avatar {
border: 2px solid var(--accent);
}
.member-item.is-owner .member-name {
color: var(--accent);
font-weight: 600;
}
.member-item.dimmed {
opacity: 0.4;
cursor: not-allowed;
filter: grayscale(1);
}
.member-item.selectable {
cursor: pointer;
background-color: rgba(var(--accent-rgb, 100, 100, 255), 0.1);
border: 1px solid var(--border);
}
.member-item.selectable:hover {
background-color: var(--accent);
color: var(--bg);
}
.member-item.selectable:hover .member-name {
color: var(--bg);
} }
.global-tag { .global-tag {
width: 100%; width: 100%;
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
flex-direction: row; flex-direction: row;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 0.6rem; gap: 0.6rem;
} }
.buttons { .buttons {
width: 100%; width: 100%;
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
flex-direction: row; flex-direction: row;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 0.6rem; gap: 0.6rem;
margin-top: 22px; margin-top: 22px;
} }
.btn { .btn {
cursor: pointer; cursor: pointer;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 10px; gap: 10px;
padding: 8px 16px; padding: 8px 16px;
font-size: 0.9rem; font-size: 0.9rem;
color: var(--text); color: var(--text);
background-color: transparent; background-color: transparent;
border-radius: var(--radius); border-radius: var(--radius);
width: fit-content; width: fit-content;
border: 1px solid var(--accent); border: 1px solid var(--accent);
transition: all 0.2s ease; transition: all 0.2s ease;
} }
.btn:hover { .btn:hover {
color: var(--accent) color: var(--accent)
} }
.btn:hover i { .btn:hover i {
color: var(--accent) color: var(--accent)
} }
.btn i { .btn i {
font-size: 1.25rem; font-size: 1.25rem;
} }
.delete-btn { .delete-btn {
border: 1px solid var(--error); border: 1px solid var(--error);
} }
.delete-btn:hover { .delete-btn:hover {
color: var(--error) color: var(--error)
} }
.delete-btn:hover i { .delete-btn:hover i {
color: var(--error) color: var(--error)
} }
.ownership-btn { .ownership-btn {
border: 1px solid var(--accent-second); border: 1px solid var(--accent-second);
} }
.ownership-btn:hover { .ownership-btn:hover,
color: var(--accent-second) .ownership-btn.active {
background-color: var(--accent-second);
color: white;
} }
.ownership-btn:hover i { .ownership-btn:hover i {
color: var(--accent-second) color: white;
} }
</style> </style>

View File

@@ -64,6 +64,10 @@ let unlistenDrag: UnlistenFn;
let unlistenHover: UnlistenFn; let unlistenHover: UnlistenFn;
let unlistenLeave: UnlistenFn; let unlistenLeave: UnlistenFn;
function uiLog(message: string) {
errorMessage.value += (errorMessage.value ? '\n' : '') + message;
}
onMounted(async () => { onMounted(async () => {
unlistenHover = await listen('tauri://drag-enter', () => isDragging.value = true); unlistenHover = await listen('tauri://drag-enter', () => isDragging.value = true);
unlistenLeave = await listen('tauri://drag-leave', () => isDragging.value = false); unlistenLeave = await listen('tauri://drag-leave', () => isDragging.value = false);
@@ -83,21 +87,36 @@ onUnmounted(() => {
if (previewUrl.value) URL.revokeObjectURL(previewUrl.value); if (previewUrl.value) URL.revokeObjectURL(previewUrl.value);
}); });
async function pickFile() { async function pickFile() {
errorMessage.value = '';
// uiLog('[pickFile] called');
try { try {
const selected = await open({ const selected = await open({
multiple: false, multiple: false,
filters: [{ name: 'Image', extensions: ['png', 'jpeg', 'jpg', 'webp'] }] filters: [{ name: 'Image', extensions: ['png', 'jpeg', 'jpg', 'webp'] }]
}); });
if (selected && typeof selected === 'string') { // uiLog('[pickFile] dialog returned: ' + JSON.stringify(selected));
await setFile(selected);
if (!selected) {
uiLog('No file selected (null)');
return;
} }
} catch (err) {
console.error(err); if (typeof selected !== 'string') {
uiLog('Unexpected return type');
return;
}
await setFile(selected);
} catch (err: any) {
uiLog('Error: ' + (err?.message ?? String(err)));
} }
} }
// Async function to read file and create blob URL // Async function to read file and create blob URL
async function setFile(path: string) { async function setFile(path: string) {
try { try {

View File

@@ -43,6 +43,7 @@ chat-room-actions-leave-confirm = Are you sure you want to leave this room?
chat-room-actions-delete = Delete Room chat-room-actions-delete = Delete Room
chat-room-actions-delete-confirm = Are you sure you want to delete this room? This cannot be undone. chat-room-actions-delete-confirm = Are you sure you want to delete this room? This cannot be undone.
chat-room-actions-ownership = Transfer Ownership chat-room-actions-ownership = Transfer Ownership
chat-room-actions-ownership-confirm = Do you really wish to transfer this room's ownership to {$user}? You might never get it back.
chat-create-title = Create room chat-create-title = Create room
chat-create-name = Room name chat-create-name = Room name
chat-create-name-placeholder = room name chat-create-name-placeholder = room name
@@ -110,3 +111,4 @@ shared-save = Save
shared-updating = Updating shared-updating = Updating
shared-delete = Delete shared-delete = Delete
shared-leave = Leave shared-leave = Leave
shared-confirm = Confirm

View File

@@ -43,6 +43,7 @@ chat-room-actions-leave-confirm = Voulez-vous vraiment quitter ce salon?
chat-room-actions-delete = Supprimer le Salon chat-room-actions-delete = Supprimer le Salon
chat-room-actions-delete-confirm = Voulez-vous vraiment supprimer ce salon? C'est irréversible. chat-room-actions-delete-confirm = Voulez-vous vraiment supprimer ce salon? C'est irréversible.
chat-room-actions-ownership = Transférer la Propriété chat-room-actions-ownership = Transférer la Propriété
chat-room-actions-ownership-confirm = Voulez-vous vraiment transférer la propriété du salon à {$user}? Vous pourriez ne jamais la récupérer.
chat-create-title = Créer un salon chat-create-title = Créer un salon
chat-create-name = Nom du salon chat-create-name = Nom du salon
chat-create-name-placeholder = nom du salon chat-create-name-placeholder = nom du salon
@@ -108,3 +109,4 @@ shared-save = Enregistrer
shared-updating = Mise à jour... shared-updating = Mise à jour...
shared-delete = Supprimer shared-delete = Supprimer
shared-leave = Quitter shared-leave = Quitter
shared-confirm = Confirmer

View File

@@ -8,22 +8,22 @@ import './base.css'
import { getLocalePreference } from './store.ts' import { getLocalePreference } from './store.ts'
async function init() { async function init() {
await validateToken() await validateToken()
const app = createApp(App) const app = createApp(App)
app.use(router) app.use(router)
app.use(fluent) app.use(fluent)
const savedLocale = await getLocalePreference(); const savedLocale = await getLocalePreference();
const osLocale = navigator.language; const osLocale = navigator.language;
if (savedLocale) { if (savedLocale) {
setLanguage(savedLocale); setLanguage(savedLocale);
} else { } else {
setLanguage(osLocale); setLanguage(osLocale);
} }
app.mount('#app') app.mount('#app')
} }
init() init()