This commit is contained in:
2026-08-30 16:08:57 +02:00
parent f2197a6b28
commit 78cb70d8e8
28 changed files with 740 additions and 42 deletions
+51 -5
View File
@@ -26,12 +26,14 @@ watch(isServerContext, (isActive) => {
})
const showDialog = ref(false)
const dialogTab = ref('create')
const formData = ref({
name: '',
password: '',
is_default: false
})
const isSubmitting = ref(false)
const joinData = ref({serverId: '', password: ''})
const resetForm = () => {
formData.value = {
@@ -39,6 +41,8 @@ const resetForm = () => {
password: '',
is_default: false
}
joinData.value = {serverId: '', password: ''}
dialogTab.value = 'create'
}
const handleSubmit = async () => {
@@ -64,6 +68,21 @@ const handleSubmit = async () => {
}
}
const handleJoin = async () => {
if (!joinData.value.serverId.trim()) return
isSubmitting.value = true
try {
const server = await serverStore.joinServer(joinData.value.serverId.trim(), joinData.value.password || null)
showDialog.value = false
resetForm()
router.push(`/server/${server.id}`)
} catch (error) {
console.error('Failed to join server:', error)
} finally {
isSubmitting.value = false
}
}
const handleCancel = () => {
showDialog.value = false;
resetForm();
@@ -97,6 +116,12 @@ function onServerContextMenu(event: MouseEvent, server: Server) {
selectedServerName.value = server.name
showServerSettings.value = true
},
}, {
label: 'Copier le lien d’invitation',
icon: 'mdi-link-variant',
action: async () => {
await navigator.clipboard.writeText(`${window.location.origin}/${server.id}`)
},
}])
}
@@ -191,9 +216,13 @@ function onServerContextMenu(event: MouseEvent, server: Server) {
<ContextMenu/>
<v-dialog v-model="showDialog" width="400">
<v-card>
<v-card-title>Create Server</v-card-title>
<v-card-title>Add a server</v-card-title>
<v-tabs v-model="dialogTab" grow>
<v-tab value="create">Create</v-tab>
<v-tab value="join">Join</v-tab>
</v-tabs>
<v-card-text>
<div class="mt-4 space-y-4">
<div v-if="dialogTab === 'create'" class="mt-4 space-y-4">
<v-text-field
v-model="formData.name"
density="compact"
@@ -211,6 +240,23 @@ function onServerContextMenu(event: MouseEvent, server: Server) {
@keyup.enter="handleSubmit"
></v-text-field>
</div>
<div v-else class="mt-4 space-y-4">
<v-text-field
v-model="joinData.serverId"
density="compact"
label="Server ID"
outlined
@keyup.enter="handleJoin"
></v-text-field>
<v-text-field
v-model="joinData.password"
density="compact"
label="Password (optional)"
outlined
type="password"
@keyup.enter="handleJoin"
></v-text-field>
</div>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
@@ -222,13 +268,13 @@ function onServerContextMenu(event: MouseEvent, server: Server) {
Cancel
</v-btn>
<v-btn
:disabled="!formData.name.trim()"
:disabled="dialogTab === 'create' ? !formData.name.trim() : !joinData.serverId.trim()"
:loading="isSubmitting"
color="primary"
variant="tonal"
@click="handleSubmit"
@click="dialogTab === 'create' ? handleSubmit() : handleJoin()"
>
Create
{{ dialogTab === 'create' ? 'Create' : 'Join' }}
</v-btn>
</v-card-actions>
</v-card>
+6 -3
View File
@@ -1,12 +1,13 @@
<script lang="ts" setup>
import {ref, watch} from 'vue'
import {useRouter} from 'vue-router'
import {useRoute, useRouter} from 'vue-router'
import {useApi} from "@/composables/useApi.ts";
const api = useApi()
const router = useRouter()
const route = useRoute()
const username = ref('')
const password = ref('')
@@ -48,6 +49,8 @@ async function handleRegister() {
if (hasInitToken.value && initToken.value.trim() !== '') {
body.superuser_token = initToken.value.trim()
}
const serverId = typeof route.query.serverId === 'string' ? route.query.serverId : null
if (serverId) body.server_id = serverId
try {
const response = await api.post('/join', body)
@@ -57,7 +60,7 @@ async function handleRegister() {
throw new Error(errData.message || 'Échec de l\'inscription')
}
await router.push('/login')
await router.push(serverId ? {name: 'login', query: {redirect: `/server/${serverId}`}} : '/login')
} catch (err) {
error.value = err instanceof Error ? err.message : 'Une erreur est survenue'
} finally {
@@ -145,4 +148,4 @@ async function handleRegister() {
</v-col>
</v-row>
</v-container>
</template>
</template>
+5 -2
View File
@@ -22,7 +22,10 @@ async function handleLogin() {
try {
await sessionStore.login(username.value, password.value)
await router.push('/')
const redirect = typeof router.currentRoute.value.query.redirect === 'string'
? router.currentRoute.value.query.redirect
: '/'
await router.push(redirect)
} catch (err) {
error.value = err instanceof Error ? err.message : 'Impossible de contacter le serveur'
} finally {
@@ -83,4 +86,4 @@ async function handleLogin() {
</v-col>
</v-row>
</v-container>
</template>
</template>
@@ -385,6 +385,11 @@ watch(messages, async () => {
if (messageStore.consumeScrollToBottomRequest()) {
await scrollToBottom();
}
// Si la fenêtre était déjà en bas, l'arrivée d'un nouveau message ne
// déclenche pas le watcher `isAtBottom`. Revalider explicitement la lecture.
if (messageStore.isAtBottom) {
await markCurrentChannelRead(channelId.value);
}
}, {deep: true, flush: 'post'});
watch(isAtBottom, async (atBottom) => {
+120 -8
View File
@@ -8,7 +8,7 @@ import CreateChannelDialog from '@/components/channel/CreateChannelDialog.vue'
import CreateCategoryDialog from '@/components/category/CreateCategoryDialog.vue'
import {type MenuItem, useContextMenu} from '@/composables/useContextMenu'
import {useUserStore} from "@/stores/user.ts";
import {useServerStore} from "@/stores/server.ts";
import {type OrderedResourceType, useServerStore} from "@/stores/server.ts";
import ChannelPermissionsDialog from '@/components/permissions/ChannelPermissionsDialog.vue'
import {useAuthStore} from '@/stores/auth'
import ServerSettingsDialog from '@/components/server/ServerSettingsDialog.vue'
@@ -73,6 +73,13 @@ const showChannelDialog = ref(false)
const showCategoryDialog = ref(false)
const selectedCategoryId = ref<string | null>(null)
const openedCategories = ref<string[]>([])
const draggingItem = ref<{ resource_id: string; resource_type: OrderedResourceType } | null>(null)
const dropTarget = ref<{
resource_id: string
resource_type: OrderedResourceType
parent_category_id: string | null
position: 'before' | 'after'
} | null>(null)
function syncOpenedCategories() {
openedCategories.value = currentTree.value
@@ -80,6 +87,74 @@ function syncOpenedCategories() {
.map((item) => item.Category[0].id)
}
function orderReference(resource_id: string, resource_type: OrderedResourceType) {
return {resource_id, resource_type}
}
function startDragging(
event: DragEvent,
resource_id: string,
resource_type: OrderedResourceType,
) {
draggingItem.value = orderReference(resource_id, resource_type)
dropTarget.value = null
event.dataTransfer?.setData('text/plain', resource_id)
if (event.dataTransfer) event.dataTransfer.effectAllowed = 'move'
}
function dragOver(
event: DragEvent,
resource_id: string,
resource_type: OrderedResourceType,
parent_category_id: string | null,
) {
if (!draggingItem.value || draggingItem.value.resource_id === resource_id) return
event.preventDefault()
const element = event.currentTarget as HTMLElement | null
const rect = element?.getBoundingClientRect()
const position = rect && event.clientY > rect.top + rect.height / 2 ? 'after' : 'before'
dropTarget.value = {
resource_id,
resource_type,
parent_category_id,
position,
}
if (event.dataTransfer) event.dataTransfer.dropEffect = 'move'
}
async function dropItem(event: DragEvent) {
event.preventDefault()
const source = draggingItem.value
const target = dropTarget.value
draggingItem.value = null
dropTarget.value = null
if (!source || !target) return
try {
await serverStore.reorderItem({
server_id: props.serverId,
resource_id: source.resource_id,
resource_type: source.resource_type,
parent_category_id: target.parent_category_id,
reference: orderReference(target.resource_id, target.resource_type),
position: target.position,
})
await refreshServerTree()
} catch (error) {
console.error('Failed to reorder server item:', error)
await refreshServerTree()
}
}
function stopDragging() {
draggingItem.value = null
dropTarget.value = null
}
function isDropTarget(resource_id: string, position: 'before' | 'after') {
return dropTarget.value?.resource_id === resource_id && dropTarget.value.position === position
}
async function refreshServerTree() {
await serverStore.fetchServerTree(props.serverId)
syncOpenedCategories()
@@ -179,18 +254,36 @@ function onChannelContextMenu(event: MouseEvent, channel: any) {
<v-list-item
:title="item.Category[0].name"
v-bind="groupProps"
draggable="true"
:class="{
'server-item-drop-before': isDropTarget(item.Category[0].id, 'before'),
'server-item-drop-after': isDropTarget(item.Category[0].id, 'after'),
}"
@dragstart="startDragging($event, item.Category[0].id, 'category')"
@dragover="dragOver($event, item.Category[0].id, 'category', null)"
@drop="dropItem($event)"
@dragend="stopDragging"
@contextmenu="onCategoryContextMenu($event, item.Category[0])"
/>
</template>
<v-list-item
v-for="channel in item.Category[1]"
:key="channel.id"
<v-list-item
v-for="channel in item.Category[1]"
:key="channel.id"
:title="channel.name"
:to="`/server/${serverId}/channel/${channel.id}`"
:class="{ 'font-weight-bold': (channel.unread_count ?? 0) > 0 }"
link
@contextmenu="onChannelContextMenu($event, channel)"
:class="{
'font-weight-bold': (channel.unread_count ?? 0) > 0,
'server-item-drop-before': isDropTarget(channel.id, 'before'),
'server-item-drop-after': isDropTarget(channel.id, 'after'),
}"
link
draggable="true"
@dragstart="startDragging($event, channel.id, 'channel')"
@dragover="dragOver($event, channel.id, 'channel', item.Category[0].id)"
@drop="dropItem($event)"
@dragend="stopDragging"
@contextmenu="onChannelContextMenu($event, channel)"
>
<template #append>
<v-chip
@@ -212,8 +305,17 @@ function onChannelContextMenu(event: MouseEvent, channel: any) {
:key="item.Channel.id"
:title="item.Channel.name"
:to="`/server/${serverId}/channel/${item.Channel.id}`"
:class="{ 'font-weight-bold': (item.Channel.unread_count ?? 0) > 0 }"
:class="{
'font-weight-bold': (item.Channel.unread_count ?? 0) > 0,
'server-item-drop-before': isDropTarget(item.Channel.id, 'before'),
'server-item-drop-after': isDropTarget(item.Channel.id, 'after'),
}"
link
draggable="true"
@dragstart="startDragging($event, item.Channel.id, 'channel')"
@dragover="dragOver($event, item.Channel.id, 'channel', null)"
@drop="dropItem($event)"
@dragend="stopDragging"
@contextmenu="onChannelContextMenu($event, item.Channel)"
>
<template #append>
@@ -262,3 +364,13 @@ function onChannelContextMenu(event: MouseEvent, channel: any) {
<router-view/>
</v-main>
</template>
<style scoped>
.server-item-drop-before {
border-top: 2px solid rgb(var(--v-theme-primary));
}
.server-item-drop-after {
border-bottom: 2px solid rgb(var(--v-theme-primary));
}
</style>
+33
View File
@@ -0,0 +1,33 @@
<script lang="ts" setup>
import { onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useServerStore } from '@/stores/server'
const route = useRoute()
const router = useRouter()
const serverStore = useServerStore()
const error = ref('')
onMounted(async () => {
try {
const server = await serverStore.joinServer(String(route.params.serverId))
await router.replace(`/server/${server.id}`)
} catch (err) {
error.value = err instanceof Error ? err.message : 'Impossible de rejoindre le serveur.'
}
})
</script>
<template>
<v-container class="fill-height" fluid>
<v-row align="center" justify="center">
<v-col cols="12" md="5" sm="8">
<v-card class="pa-6 text-center">
<v-progress-circular v-if="!error" color="primary" indeterminate class="mb-4" />
<v-alert v-else type="error" class="mb-4">{{ error }}</v-alert>
<v-btn v-if="error" to="/" color="primary">Retour à l’accueil</v-btn>
</v-card>
</v-col>
</v-row>
</v-container>
</template>
+10 -1
View File
@@ -41,6 +41,12 @@ const router = createRouter({
path: '/',
component: AppLayout,
children: [
{
path: ':serverId([0-9a-fA-F-]{36})',
name: 'server-invite',
component: () => import('@/pages/server/invite.vue'),
props: true,
},
{
path: '',
name: 'home',
@@ -101,7 +107,10 @@ router.beforeEach(async (to) => {
if (authRequired && !authStore.isAuthenticated) {
// Non connecté -> Login
return '/auth/login'
if (to.name === 'server-invite') {
return {name: 'join', query: {serverId: String(to.params.serverId)}}
}
return {name: 'login', query: {redirect: to.fullPath}}
} else if (to.name === 'login' && authStore.isAuthenticated) {
// Déjà connecté -> Accueil
return '/'
+29
View File
@@ -13,6 +13,20 @@ export interface Server {
unread_count?: number
}
export type OrderedResourceType = 'channel' | 'category'
export interface ServerItemOrderReference {
resource_id: string
resource_type: OrderedResourceType
}
export interface ReorderServerItemPayload extends ServerItemOrderReference {
server_id: string
parent_category_id: string | null
reference: ServerItemOrderReference | null
position: 'before' | 'after'
}
export const useServerStore = defineStore("server", {
state: () => ({
servers: [] as Server[],
@@ -62,6 +76,14 @@ export const useServerStore = defineStore("server", {
this.loading = false;
}
},
async joinServer(serverId: string, password?: string | null) {
const response = await useApi().post(`/servers/${serverId}/join`, {password: password || null});
const error = !response.ok ? await response.json().catch(() => null) : null;
if (!response.ok) throw new Error(error?.error || 'Failed to join server');
const server: Server = await response.json();
if (!this.servers.some(item => item.id === server.id)) this.servers.push(server);
return server;
},
async updateServer(serverId: string, payload: { name: string; is_default?: boolean }) {
const api = useApi();
const response = await api.put(`/servers/${serverId}`, {
@@ -111,6 +133,13 @@ export const useServerStore = defineStore("server", {
return tree.items;
},
async reorderItem(payload: ReorderServerItemPayload) {
const response = await useApi().put('/server-item-orders/reorder', payload)
if (!response.ok) {
const error = await response.json().catch(() => null)
throw new Error(error?.error || 'Failed to reorder server item')
}
},
applyChannelReadState(serverId: string, channelId: string, unreadCount: number) {
let previousUnreadCount = 0;