init
This commit is contained in:
Generated
+6
-6
@@ -145,9 +145,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "argon2"
|
name = "argon2"
|
||||||
version = "0.6.0-rc.8"
|
version = "0.6.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7af50940b73bf4e16c15c448a2b121c63f2d68e3e54b6a8731673cb4aa0cdff5"
|
checksum = "134c52ddac6d63c576bef8168db10c83c49c26444ecbc68060fef078925a901c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64ct",
|
"base64ct",
|
||||||
"blake2",
|
"blake2",
|
||||||
@@ -656,9 +656,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "blake2"
|
name = "blake2"
|
||||||
version = "0.11.0-rc.6"
|
version = "0.11.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "061f1a09225e328e1ffbb378d2d49923c0ca5fee19fb5ac1cc9c1e9d52b93690"
|
checksum = "5b5d4d889834ee8ecfc0f8426ad30faf7cdcb10f741a8e6d7224d95325479f6f"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"digest 0.11.3",
|
"digest 0.11.3",
|
||||||
]
|
]
|
||||||
@@ -4241,9 +4241,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "uuid"
|
name = "uuid"
|
||||||
version = "1.24.1"
|
version = "1.26.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2cefc03fd367c0c6d4305de1b312cf00248c4114f4a0418ce6a6af769e3b0bd9"
|
checksum = "b5772d71c9be8a8a6ac2117d949c5b224c1b72241bb611d9a3012edcf8af7812"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"getrandom 0.4.3",
|
"getrandom 0.4.3",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
|
|||||||
+2
-2
@@ -22,7 +22,7 @@ parking_lot = "0.12.5"
|
|||||||
serde = "1.0.229"
|
serde = "1.0.229"
|
||||||
serde_json = "1.0.151"
|
serde_json = "1.0.151"
|
||||||
toml = "1.1.4"
|
toml = "1.1.4"
|
||||||
uuid = { version = "1.24.1", features = ["v4", "v7", "fast-rng", "serde"] }
|
uuid = { version = "1.26.0", features = ["v4", "v7", "fast-rng", "serde"] }
|
||||||
tracing = "0.1.44"
|
tracing = "0.1.44"
|
||||||
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt", "time"] }
|
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt", "time"] }
|
||||||
thiserror = "2"
|
thiserror = "2"
|
||||||
@@ -30,7 +30,7 @@ utoipa = { version = "5", features = ["uuid", "chrono"] }
|
|||||||
utoipa-swagger-ui = { version = "9", features = ["axum"] }
|
utoipa-swagger-ui = { version = "9", features = ["axum"] }
|
||||||
log = "0.4"
|
log = "0.4"
|
||||||
bitflags = "2.13.1"
|
bitflags = "2.13.1"
|
||||||
argon2 = { version = "0.6.0-rc.8", features = ["password-hash"] }
|
argon2 = { version = "0.6.0", features = ["password-hash"] }
|
||||||
jsonwebtoken = { version = "11.0.0", features = ["aws_lc_rs"] }
|
jsonwebtoken = { version = "11.0.0", features = ["aws_lc_rs"] }
|
||||||
tower = { version = "0.5", features = ["util"] }
|
tower = { version = "0.5", features = ["util"] }
|
||||||
tower-http = { version = "0.7.0", features = ["catch-panic", "cors", "trace"] }
|
tower-http = { version = "0.7.0", features = ["catch-panic", "cors", "trace"] }
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ harness = false
|
|||||||
tokio = { version = "1.53.1", default-features = false, features = ["rt", "sync"] }
|
tokio = { version = "1.53.1", default-features = false, features = ["rt", "sync"] }
|
||||||
parking_lot = "0.12.5"
|
parking_lot = "0.12.5"
|
||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
uuid = { version = "1.24.1", features = ["v4"] }
|
uuid = { version = "1.26.0", features = ["v4"] }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tokio = { version = "1.53.1", default-features = false, features = ["rt", "rt-multi-thread", "macros", "time", "sync"] }
|
tokio = { version = "1.53.1", default-features = false, features = ["rt", "rt-multi-thread", "macros", "time", "sync"] }
|
||||||
|
|||||||
@@ -26,12 +26,14 @@ watch(isServerContext, (isActive) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const showDialog = ref(false)
|
const showDialog = ref(false)
|
||||||
|
const dialogTab = ref('create')
|
||||||
const formData = ref({
|
const formData = ref({
|
||||||
name: '',
|
name: '',
|
||||||
password: '',
|
password: '',
|
||||||
is_default: false
|
is_default: false
|
||||||
})
|
})
|
||||||
const isSubmitting = ref(false)
|
const isSubmitting = ref(false)
|
||||||
|
const joinData = ref({serverId: '', password: ''})
|
||||||
|
|
||||||
const resetForm = () => {
|
const resetForm = () => {
|
||||||
formData.value = {
|
formData.value = {
|
||||||
@@ -39,6 +41,8 @@ const resetForm = () => {
|
|||||||
password: '',
|
password: '',
|
||||||
is_default: false
|
is_default: false
|
||||||
}
|
}
|
||||||
|
joinData.value = {serverId: '', password: ''}
|
||||||
|
dialogTab.value = 'create'
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
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 = () => {
|
const handleCancel = () => {
|
||||||
showDialog.value = false;
|
showDialog.value = false;
|
||||||
resetForm();
|
resetForm();
|
||||||
@@ -97,6 +116,12 @@ function onServerContextMenu(event: MouseEvent, server: Server) {
|
|||||||
selectedServerName.value = server.name
|
selectedServerName.value = server.name
|
||||||
showServerSettings.value = true
|
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/>
|
<ContextMenu/>
|
||||||
<v-dialog v-model="showDialog" width="400">
|
<v-dialog v-model="showDialog" width="400">
|
||||||
<v-card>
|
<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>
|
<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-text-field
|
||||||
v-model="formData.name"
|
v-model="formData.name"
|
||||||
density="compact"
|
density="compact"
|
||||||
@@ -211,6 +240,23 @@ function onServerContextMenu(event: MouseEvent, server: Server) {
|
|||||||
@keyup.enter="handleSubmit"
|
@keyup.enter="handleSubmit"
|
||||||
></v-text-field>
|
></v-text-field>
|
||||||
</div>
|
</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-text>
|
||||||
<v-card-actions>
|
<v-card-actions>
|
||||||
<v-spacer></v-spacer>
|
<v-spacer></v-spacer>
|
||||||
@@ -222,13 +268,13 @@ function onServerContextMenu(event: MouseEvent, server: Server) {
|
|||||||
Cancel
|
Cancel
|
||||||
</v-btn>
|
</v-btn>
|
||||||
<v-btn
|
<v-btn
|
||||||
:disabled="!formData.name.trim()"
|
:disabled="dialogTab === 'create' ? !formData.name.trim() : !joinData.serverId.trim()"
|
||||||
:loading="isSubmitting"
|
:loading="isSubmitting"
|
||||||
color="primary"
|
color="primary"
|
||||||
variant="tonal"
|
variant="tonal"
|
||||||
@click="handleSubmit"
|
@click="dialogTab === 'create' ? handleSubmit() : handleJoin()"
|
||||||
>
|
>
|
||||||
Create
|
{{ dialogTab === 'create' ? 'Create' : 'Join' }}
|
||||||
</v-btn>
|
</v-btn>
|
||||||
</v-card-actions>
|
</v-card-actions>
|
||||||
</v-card>
|
</v-card>
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import {ref, watch} from 'vue'
|
import {ref, watch} from 'vue'
|
||||||
import {useRouter} from 'vue-router'
|
import {useRoute, useRouter} from 'vue-router'
|
||||||
import {useApi} from "@/composables/useApi.ts";
|
import {useApi} from "@/composables/useApi.ts";
|
||||||
|
|
||||||
|
|
||||||
const api = useApi()
|
const api = useApi()
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
const username = ref('')
|
const username = ref('')
|
||||||
const password = ref('')
|
const password = ref('')
|
||||||
@@ -48,6 +49,8 @@ async function handleRegister() {
|
|||||||
if (hasInitToken.value && initToken.value.trim() !== '') {
|
if (hasInitToken.value && initToken.value.trim() !== '') {
|
||||||
body.superuser_token = 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 {
|
try {
|
||||||
const response = await api.post('/join', body)
|
const response = await api.post('/join', body)
|
||||||
@@ -57,7 +60,7 @@ async function handleRegister() {
|
|||||||
throw new Error(errData.message || 'Échec de l\'inscription')
|
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) {
|
} catch (err) {
|
||||||
error.value = err instanceof Error ? err.message : 'Une erreur est survenue'
|
error.value = err instanceof Error ? err.message : 'Une erreur est survenue'
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -22,7 +22,10 @@ async function handleLogin() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await sessionStore.login(username.value, password.value)
|
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) {
|
} catch (err) {
|
||||||
error.value = err instanceof Error ? err.message : 'Impossible de contacter le serveur'
|
error.value = err instanceof Error ? err.message : 'Impossible de contacter le serveur'
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -385,6 +385,11 @@ watch(messages, async () => {
|
|||||||
if (messageStore.consumeScrollToBottomRequest()) {
|
if (messageStore.consumeScrollToBottomRequest()) {
|
||||||
await scrollToBottom();
|
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'});
|
}, {deep: true, flush: 'post'});
|
||||||
|
|
||||||
watch(isAtBottom, async (atBottom) => {
|
watch(isAtBottom, async (atBottom) => {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import CreateChannelDialog from '@/components/channel/CreateChannelDialog.vue'
|
|||||||
import CreateCategoryDialog from '@/components/category/CreateCategoryDialog.vue'
|
import CreateCategoryDialog from '@/components/category/CreateCategoryDialog.vue'
|
||||||
import {type MenuItem, useContextMenu} from '@/composables/useContextMenu'
|
import {type MenuItem, useContextMenu} from '@/composables/useContextMenu'
|
||||||
import {useUserStore} from "@/stores/user.ts";
|
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 ChannelPermissionsDialog from '@/components/permissions/ChannelPermissionsDialog.vue'
|
||||||
import {useAuthStore} from '@/stores/auth'
|
import {useAuthStore} from '@/stores/auth'
|
||||||
import ServerSettingsDialog from '@/components/server/ServerSettingsDialog.vue'
|
import ServerSettingsDialog from '@/components/server/ServerSettingsDialog.vue'
|
||||||
@@ -73,6 +73,13 @@ const showChannelDialog = ref(false)
|
|||||||
const showCategoryDialog = ref(false)
|
const showCategoryDialog = ref(false)
|
||||||
const selectedCategoryId = ref<string | null>(null)
|
const selectedCategoryId = ref<string | null>(null)
|
||||||
const openedCategories = ref<string[]>([])
|
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() {
|
function syncOpenedCategories() {
|
||||||
openedCategories.value = currentTree.value
|
openedCategories.value = currentTree.value
|
||||||
@@ -80,6 +87,74 @@ function syncOpenedCategories() {
|
|||||||
.map((item) => item.Category[0].id)
|
.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() {
|
async function refreshServerTree() {
|
||||||
await serverStore.fetchServerTree(props.serverId)
|
await serverStore.fetchServerTree(props.serverId)
|
||||||
syncOpenedCategories()
|
syncOpenedCategories()
|
||||||
@@ -179,18 +254,36 @@ function onChannelContextMenu(event: MouseEvent, channel: any) {
|
|||||||
<v-list-item
|
<v-list-item
|
||||||
:title="item.Category[0].name"
|
:title="item.Category[0].name"
|
||||||
v-bind="groupProps"
|
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])"
|
@contextmenu="onCategoryContextMenu($event, item.Category[0])"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<v-list-item
|
<v-list-item
|
||||||
v-for="channel in item.Category[1]"
|
v-for="channel in item.Category[1]"
|
||||||
:key="channel.id"
|
:key="channel.id"
|
||||||
:title="channel.name"
|
:title="channel.name"
|
||||||
:to="`/server/${serverId}/channel/${channel.id}`"
|
:to="`/server/${serverId}/channel/${channel.id}`"
|
||||||
:class="{ 'font-weight-bold': (channel.unread_count ?? 0) > 0 }"
|
:class="{
|
||||||
link
|
'font-weight-bold': (channel.unread_count ?? 0) > 0,
|
||||||
@contextmenu="onChannelContextMenu($event, channel)"
|
'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>
|
<template #append>
|
||||||
<v-chip
|
<v-chip
|
||||||
@@ -212,8 +305,17 @@ function onChannelContextMenu(event: MouseEvent, channel: any) {
|
|||||||
:key="item.Channel.id"
|
:key="item.Channel.id"
|
||||||
:title="item.Channel.name"
|
:title="item.Channel.name"
|
||||||
:to="`/server/${serverId}/channel/${item.Channel.id}`"
|
: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
|
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)"
|
@contextmenu="onChannelContextMenu($event, item.Channel)"
|
||||||
>
|
>
|
||||||
<template #append>
|
<template #append>
|
||||||
@@ -262,3 +364,13 @@ function onChannelContextMenu(event: MouseEvent, channel: any) {
|
|||||||
<router-view/>
|
<router-view/>
|
||||||
</v-main>
|
</v-main>
|
||||||
</template>
|
</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>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -41,6 +41,12 @@ const router = createRouter({
|
|||||||
path: '/',
|
path: '/',
|
||||||
component: AppLayout,
|
component: AppLayout,
|
||||||
children: [
|
children: [
|
||||||
|
{
|
||||||
|
path: ':serverId([0-9a-fA-F-]{36})',
|
||||||
|
name: 'server-invite',
|
||||||
|
component: () => import('@/pages/server/invite.vue'),
|
||||||
|
props: true,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '',
|
path: '',
|
||||||
name: 'home',
|
name: 'home',
|
||||||
@@ -101,7 +107,10 @@ router.beforeEach(async (to) => {
|
|||||||
|
|
||||||
if (authRequired && !authStore.isAuthenticated) {
|
if (authRequired && !authStore.isAuthenticated) {
|
||||||
// Non connecté -> Login
|
// 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) {
|
} else if (to.name === 'login' && authStore.isAuthenticated) {
|
||||||
// Déjà connecté -> Accueil
|
// Déjà connecté -> Accueil
|
||||||
return '/'
|
return '/'
|
||||||
|
|||||||
@@ -13,6 +13,20 @@ export interface Server {
|
|||||||
unread_count?: number
|
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", {
|
export const useServerStore = defineStore("server", {
|
||||||
state: () => ({
|
state: () => ({
|
||||||
servers: [] as Server[],
|
servers: [] as Server[],
|
||||||
@@ -62,6 +76,14 @@ export const useServerStore = defineStore("server", {
|
|||||||
this.loading = false;
|
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 }) {
|
async updateServer(serverId: string, payload: { name: string; is_default?: boolean }) {
|
||||||
const api = useApi();
|
const api = useApi();
|
||||||
const response = await api.put(`/servers/${serverId}`, {
|
const response = await api.put(`/servers/${serverId}`, {
|
||||||
@@ -111,6 +133,13 @@ export const useServerStore = defineStore("server", {
|
|||||||
|
|
||||||
return tree.items;
|
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) {
|
applyChannelReadState(serverId: string, channelId: string, unreadCount: number) {
|
||||||
let previousUnreadCount = 0;
|
let previousUnreadCount = 0;
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use utoipa::ToSchema;
|
use utoipa::ToSchema;
|
||||||
use validator::Validate;
|
use validator::Validate;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
#[derive(Deserialize, Validate, ToSchema)]
|
#[derive(Deserialize, Validate, ToSchema)]
|
||||||
pub struct JoinRequest {
|
pub struct JoinRequest {
|
||||||
@@ -11,4 +12,5 @@ pub struct JoinRequest {
|
|||||||
#[validate(must_match(other = "password", message = "Passwords do not match"))]
|
#[validate(must_match(other = "password", message = "Passwords do not match"))]
|
||||||
pub password_valid: String,
|
pub password_valid: String,
|
||||||
pub superuser_token: Option<String>,
|
pub superuser_token: Option<String>,
|
||||||
|
pub server_id: Option<Uuid>,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,4 +9,5 @@ pub mod message;
|
|||||||
pub mod reaction;
|
pub mod reaction;
|
||||||
pub mod role;
|
pub mod role;
|
||||||
pub mod server;
|
pub mod server;
|
||||||
|
pub mod server_item_order;
|
||||||
pub mod user;
|
pub mod user;
|
||||||
|
|||||||
@@ -14,6 +14,11 @@ pub struct CreateServerRequest {
|
|||||||
pub is_default: bool,
|
pub is_default: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, ToSchema)]
|
||||||
|
pub struct JoinServerRequest {
|
||||||
|
pub password: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||||
pub struct UpdateServerRequest {
|
pub struct UpdateServerRequest {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
use crate::models::server_item_order::OrderedResourceType;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use utoipa::ToSchema;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||||
|
pub struct ServerItemOrderReference {
|
||||||
|
pub resource_id: Uuid,
|
||||||
|
pub resource_type: OrderedResourceType,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum ServerItemOrderPosition {
|
||||||
|
Before,
|
||||||
|
After,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||||
|
pub struct ReorderServerItemRequest {
|
||||||
|
pub server_id: Uuid,
|
||||||
|
pub resource_id: Uuid,
|
||||||
|
pub resource_type: OrderedResourceType,
|
||||||
|
/// Nouvelle catégorie parente du canal. Doit être nul pour une catégorie.
|
||||||
|
pub parent_category_id: Option<Uuid>,
|
||||||
|
/// Élément devant ou derrière lequel insérer la ressource.
|
||||||
|
pub reference: Option<ServerItemOrderReference>,
|
||||||
|
pub position: ServerItemOrderPosition,
|
||||||
|
}
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
use crate::models::{channel, channel_user_read_state, message};
|
use crate::models::{channel, channel_user_read_state, message};
|
||||||
use crate::repositories::{AnyResult, RepositoryContext};
|
use crate::repositories::{AnyResult, RepositoryContext};
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QuerySelect, Set};
|
use sea_orm::{
|
||||||
|
ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QuerySelect, Set,
|
||||||
|
RelationTrait, sea_query::OnConflict,
|
||||||
|
};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
@@ -12,6 +15,58 @@ pub struct ReadStateRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl ReadStateRepository {
|
impl ReadStateRepository {
|
||||||
|
/// Marque tous les canaux d'un serveur comme lus en une opération groupée.
|
||||||
|
pub async fn mark_server_read(&self, server_id: Uuid, user_id: Uuid) -> AnyResult<()> {
|
||||||
|
let last_messages = message::Entity::find()
|
||||||
|
.select_only()
|
||||||
|
.column(message::Column::ChannelId)
|
||||||
|
.column_as(message::Column::Id.max(), "last_read_message_id")
|
||||||
|
.join(
|
||||||
|
sea_orm::JoinType::InnerJoin,
|
||||||
|
message::Relation::Channel.def(),
|
||||||
|
)
|
||||||
|
.filter(channel::Column::ServerId.eq(server_id))
|
||||||
|
.group_by(message::Column::ChannelId)
|
||||||
|
.into_tuple::<(Uuid, Uuid)>()
|
||||||
|
.all(&self.context.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if last_messages.is_empty() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let now = Utc::now();
|
||||||
|
let states = last_messages
|
||||||
|
.into_iter()
|
||||||
|
.map(|(channel_id, last_read_message_id)| {
|
||||||
|
channel_user_read_state::ActiveModel {
|
||||||
|
id: Set(Uuid::now_v7()),
|
||||||
|
channel_id: Set(channel_id),
|
||||||
|
user_id: Set(user_id),
|
||||||
|
last_read_message_id: Set(Some(last_read_message_id)),
|
||||||
|
updated_at: Set(now),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
channel_user_read_state::Entity::insert_many(states)
|
||||||
|
.on_conflict(
|
||||||
|
OnConflict::columns([
|
||||||
|
channel_user_read_state::Column::ChannelId,
|
||||||
|
channel_user_read_state::Column::UserId,
|
||||||
|
])
|
||||||
|
.update_columns([
|
||||||
|
channel_user_read_state::Column::LastReadMessageId,
|
||||||
|
channel_user_read_state::Column::UpdatedAt,
|
||||||
|
])
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.exec(&self.context.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn get(
|
pub async fn get(
|
||||||
&self,
|
&self,
|
||||||
channel_id: Uuid,
|
channel_id: Uuid,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use super::{AnyResult, RepositoryContext};
|
use super::{AnyResult, RepositoryContext};
|
||||||
use crate::models::{role, server, server_role_permission, server_user, server_user_permission};
|
use crate::models::{role, server, server_role_permission, server_user, server_user_permission};
|
||||||
use sea_orm::prelude::*;
|
use sea_orm::prelude::*;
|
||||||
use sea_orm::{ActiveModelTrait, QuerySelect, Set};
|
use sea_orm::{ActiveModelTrait, JoinType, QuerySelect, RelationTrait, Set};
|
||||||
|
|
||||||
use sea_orm::sea_query::OnConflict;
|
use sea_orm::sea_query::OnConflict;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
@@ -17,6 +17,27 @@ impl ServerRepository {
|
|||||||
Ok(server::Entity::find().all(&self.context.db).await?)
|
Ok(server::Entity::find().all(&self.context.db).await?)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn get_all_for_user(&self, user_id: Uuid) -> AnyResult<Vec<server::Model>> {
|
||||||
|
Ok(server::Entity::find()
|
||||||
|
.join(JoinType::InnerJoin, server::Relation::ServerUser.def())
|
||||||
|
.filter(server_user::Column::UserId.eq(user_id))
|
||||||
|
.distinct()
|
||||||
|
.all(&self.context.db)
|
||||||
|
.await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_by_id_for_user(
|
||||||
|
&self,
|
||||||
|
id: Uuid,
|
||||||
|
user_id: Uuid,
|
||||||
|
) -> AnyResult<Option<server::Model>> {
|
||||||
|
Ok(server::Entity::find_by_id(id)
|
||||||
|
.join(JoinType::InnerJoin, server::Relation::ServerUser.def())
|
||||||
|
.filter(server_user::Column::UserId.eq(user_id))
|
||||||
|
.one(&self.context.db)
|
||||||
|
.await?)
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn get_by_id(&self, id: Uuid) -> AnyResult<Option<server::Model>> {
|
pub async fn get_by_id(&self, id: Uuid) -> AnyResult<Option<server::Model>> {
|
||||||
Ok(server::Entity::find_by_id(id).one(&self.context.db).await?)
|
Ok(server::Entity::find_by_id(id).one(&self.context.db).await?)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,23 @@ pub async fn join(
|
|||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
ValidatedJson(payload): ValidatedJson<JoinRequest>,
|
ValidatedJson(payload): ValidatedJson<JoinRequest>,
|
||||||
) -> Result<impl IntoResponse, HTTPError> {
|
) -> Result<impl IntoResponse, HTTPError> {
|
||||||
|
let target_server_id = if let Some(server_id) = payload.server_id {
|
||||||
|
let server = state
|
||||||
|
.repositories
|
||||||
|
.server
|
||||||
|
.get_by_id(server_id)
|
||||||
|
.await?
|
||||||
|
.ok_or(HTTPError::NotFound)?;
|
||||||
|
if server.password.is_some() {
|
||||||
|
return Err(HTTPError::BadRequest(
|
||||||
|
"This server requires a password; join it after creating your account".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
server_id
|
||||||
|
} else {
|
||||||
|
state.default_server.id
|
||||||
|
};
|
||||||
|
|
||||||
let user_exists = state
|
let user_exists = state
|
||||||
.repositories
|
.repositories
|
||||||
.user
|
.user
|
||||||
@@ -56,12 +73,18 @@ pub async fn join(
|
|||||||
state
|
state
|
||||||
.repositories
|
.repositories
|
||||||
.server
|
.server
|
||||||
.add_user(state.default_server.id, user.id),
|
.add_user(target_server_id, user.id),
|
||||||
state
|
state
|
||||||
.repositories
|
.repositories
|
||||||
.role
|
.role
|
||||||
.add_to_default(user.id, state.default_server.id)
|
.add_to_default(user.id, target_server_id)
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
|
state
|
||||||
|
.repositories
|
||||||
|
.read_state
|
||||||
|
.mark_server_read(target_server_id, user.id)
|
||||||
|
.await?;
|
||||||
|
|
||||||
Ok(StatusCode::CREATED)
|
Ok(StatusCode::CREATED)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -214,6 +214,14 @@ pub async fn create(
|
|||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(|error| HTTPError::BadRequest(error.to_string()))?;
|
.map_err(|error| HTTPError::BadRequest(error.to_string()))?;
|
||||||
|
|
||||||
|
// L'auteur a forcément lu le message qu'il vient d'envoyer.
|
||||||
|
state
|
||||||
|
.repositories
|
||||||
|
.read_state
|
||||||
|
.set(message.channel_id, user.id, Some(message.id))
|
||||||
|
.await?;
|
||||||
|
|
||||||
let attachments = state
|
let attachments = state
|
||||||
.repositories
|
.repositories
|
||||||
.message
|
.message
|
||||||
|
|||||||
@@ -16,12 +16,14 @@ pub mod message;
|
|||||||
pub mod openapi;
|
pub mod openapi;
|
||||||
pub mod role;
|
pub mod role;
|
||||||
pub mod server;
|
pub mod server;
|
||||||
|
pub mod server_item_order;
|
||||||
pub mod user;
|
pub mod user;
|
||||||
|
|
||||||
pub fn router() -> OxRouter {
|
pub fn router() -> OxRouter {
|
||||||
// Routes nécessitant une authentification
|
// Routes nécessitant une authentification
|
||||||
let secure_routes = Router::new()
|
let secure_routes = Router::new()
|
||||||
.merge(server::routes::router())
|
.merge(server::routes::router())
|
||||||
|
.merge(server_item_order::routes::router())
|
||||||
.merge(category::routes::router())
|
.merge(category::routes::router())
|
||||||
.merge(channel::routes::router())
|
.merge(channel::routes::router())
|
||||||
.merge(conversation::routes::router())
|
.merge(conversation::routes::router())
|
||||||
|
|||||||
@@ -18,9 +18,11 @@ use utoipa::{Modify, OpenApi};
|
|||||||
server::handlers::get_all,
|
server::handlers::get_all,
|
||||||
server::handlers::get_by_id,
|
server::handlers::get_by_id,
|
||||||
server::handlers::create,
|
server::handlers::create,
|
||||||
|
server::handlers::join,
|
||||||
server::handlers::update,
|
server::handlers::update,
|
||||||
server::handlers::delete,
|
server::handlers::delete,
|
||||||
server::handlers::get_tree,
|
server::handlers::get_tree,
|
||||||
|
server_item_order::handlers::reorder,
|
||||||
category::handlers::get_all,
|
category::handlers::get_all,
|
||||||
category::handlers::get_by_id,
|
category::handlers::get_by_id,
|
||||||
category::handlers::create,
|
category::handlers::create,
|
||||||
@@ -64,7 +66,11 @@ use utoipa::{Modify, OpenApi};
|
|||||||
crate::domain::dto::user::UpdateUserRequest,
|
crate::domain::dto::user::UpdateUserRequest,
|
||||||
crate::domain::dto::server::ServerResponse,
|
crate::domain::dto::server::ServerResponse,
|
||||||
crate::domain::dto::server::CreateServerRequest,
|
crate::domain::dto::server::CreateServerRequest,
|
||||||
|
crate::domain::dto::server::JoinServerRequest,
|
||||||
crate::domain::dto::server::UpdateServerRequest,
|
crate::domain::dto::server::UpdateServerRequest,
|
||||||
|
crate::domain::dto::server_item_order::ReorderServerItemRequest,
|
||||||
|
crate::domain::dto::server_item_order::ServerItemOrderReference,
|
||||||
|
crate::domain::dto::server_item_order::ServerItemOrderPosition,
|
||||||
crate::domain::dto::category::CategoryResponse,
|
crate::domain::dto::category::CategoryResponse,
|
||||||
crate::domain::dto::category::CreateCategoryRequest,
|
crate::domain::dto::category::CreateCategoryRequest,
|
||||||
crate::domain::dto::category::UpdateCategoryRequest,
|
crate::domain::dto::category::UpdateCategoryRequest,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
use crate::core::state::AppState;
|
use crate::core::state::AppState;
|
||||||
use crate::domain::dto::server::{
|
use crate::domain::dto::server::{
|
||||||
CreateServerRequest, ServerResponse, ServerRolePermissionResponse, ServerTreeResponse,
|
CreateServerRequest, JoinServerRequest, ServerResponse, ServerRolePermissionResponse,
|
||||||
|
ServerTreeResponse,
|
||||||
ServerUserPermissionResponse, SetServerPermissionRequest, UpdateServerRequest,
|
ServerUserPermissionResponse, SetServerPermissionRequest, UpdateServerRequest,
|
||||||
};
|
};
|
||||||
use crate::http::context::{CurrentUser, Superuser};
|
use crate::http::context::{CurrentUser, Superuser};
|
||||||
@@ -54,7 +55,7 @@ pub async fn get_all(
|
|||||||
user: CurrentUser,
|
user: CurrentUser,
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
) -> Result<Json<Vec<ServerResponse>>, HTTPError> {
|
) -> Result<Json<Vec<ServerResponse>>, HTTPError> {
|
||||||
let servers = state.repositories.server.get_all().await?;
|
let servers = state.repositories.server.get_all_for_user(user.id).await?;
|
||||||
let unread_counts = state
|
let unread_counts = state
|
||||||
.repositories
|
.repositories
|
||||||
.read_state
|
.read_state
|
||||||
@@ -117,14 +118,14 @@ pub async fn get_by_id(
|
|||||||
)
|
)
|
||||||
)]
|
)]
|
||||||
pub async fn create(
|
pub async fn create(
|
||||||
_admin: Superuser,
|
user: CurrentUser,
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Json(payload): Json<CreateServerRequest>,
|
Json(payload): Json<CreateServerRequest>,
|
||||||
) -> Result<(StatusCode, Json<ServerResponse>), HTTPError> {
|
) -> Result<(StatusCode, Json<ServerResponse>), HTTPError> {
|
||||||
let server = state
|
let server = state
|
||||||
.services
|
.services
|
||||||
.server
|
.server
|
||||||
.create_server(payload.name, payload.is_default)
|
.create_server(payload.name, payload.password, payload.is_default, user.id)
|
||||||
.await?;
|
.await?;
|
||||||
Ok((
|
Ok((
|
||||||
StatusCode::CREATED,
|
StatusCode::CREATED,
|
||||||
@@ -132,6 +133,41 @@ pub async fn create(
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
post,
|
||||||
|
path = "/servers/{id}/join",
|
||||||
|
request_body = JoinServerRequest,
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "Serveur rejoint", body = ServerResponse),
|
||||||
|
(status = 404, description = "Serveur introuvable"),
|
||||||
|
(status = 400, description = "Mot de passe incorrect")
|
||||||
|
),
|
||||||
|
params(("id" = Uuid, Path, description = "ID du serveur")),
|
||||||
|
tag = "Servers",
|
||||||
|
security(("bearerAuth" = []))
|
||||||
|
)]
|
||||||
|
pub async fn join(
|
||||||
|
user: CurrentUser,
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
Json(payload): Json<JoinServerRequest>,
|
||||||
|
) -> Result<Json<ServerResponse>, HTTPError> {
|
||||||
|
let result = state
|
||||||
|
.services
|
||||||
|
.server
|
||||||
|
.join_server(id, user.id, payload.password)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(server) => Ok(Json(mapper::server_model_to_server_response(server))),
|
||||||
|
Err(error) if error.to_string() == "Server not found" => Err(HTTPError::NotFound),
|
||||||
|
Err(error) if error.to_string() == "Invalid server password" => {
|
||||||
|
Err(HTTPError::BadRequest("Invalid server password".into()))
|
||||||
|
}
|
||||||
|
Err(error) => Err(HTTPError::Internal(error)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Met à jour un serveur existant
|
/// Met à jour un serveur existant
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
put,
|
put,
|
||||||
@@ -251,6 +287,7 @@ pub async fn list_user_permissions(
|
|||||||
.get_by_id(server_id)
|
.get_by_id(server_id)
|
||||||
.await?
|
.await?
|
||||||
.ok_or(HTTPError::NotFound)?;
|
.ok_or(HTTPError::NotFound)?;
|
||||||
|
|
||||||
let permissions = state
|
let permissions = state
|
||||||
.repositories
|
.repositories
|
||||||
.server
|
.server
|
||||||
@@ -491,7 +528,7 @@ pub async fn get_tree(
|
|||||||
state
|
state
|
||||||
.repositories
|
.repositories
|
||||||
.server
|
.server
|
||||||
.get_by_id(server_id)
|
.get_by_id_for_user(server_id, user.id)
|
||||||
.await?
|
.await?
|
||||||
.ok_or(HTTPError::NotFound)?;
|
.ok_or(HTTPError::NotFound)?;
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
use super::handlers;
|
use super::handlers;
|
||||||
use crate::core::state::AppState;
|
use crate::core::state::AppState;
|
||||||
use axum::{Router, routing::get};
|
use axum::{Router, routing::{get, post}};
|
||||||
|
|
||||||
pub fn router() -> Router<AppState> {
|
pub fn router() -> Router<AppState> {
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/servers", get(handlers::get_all).post(handlers::create))
|
.route("/servers", get(handlers::get_all).post(handlers::create))
|
||||||
|
.route("/servers/{id}/join", post(handlers::join))
|
||||||
.route(
|
.route(
|
||||||
"/servers/{id}",
|
"/servers/{id}",
|
||||||
get(handlers::get_by_id)
|
get(handlers::get_by_id)
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
use crate::core::state::AppState;
|
||||||
|
use crate::domain::dto::server_item_order::ReorderServerItemRequest;
|
||||||
|
use crate::domain::events::server_tree::ServerTreeInvalidatedEvent;
|
||||||
|
use crate::http::context::CurrentUser;
|
||||||
|
use crate::http::error::HTTPError;
|
||||||
|
use axum::http::StatusCode;
|
||||||
|
use axum::{Json, extract::State};
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
put,
|
||||||
|
path = "/server-item-orders/reorder",
|
||||||
|
request_body = ReorderServerItemRequest,
|
||||||
|
responses((status = 204, description = "Ordre mis à jour")),
|
||||||
|
tag = "Server item orders",
|
||||||
|
security(("bearerAuth" = []))
|
||||||
|
)]
|
||||||
|
pub async fn reorder(
|
||||||
|
_user: CurrentUser,
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Json(payload): Json<ReorderServerItemRequest>,
|
||||||
|
) -> Result<StatusCode, HTTPError> {
|
||||||
|
let server_id = payload.server_id;
|
||||||
|
state
|
||||||
|
.repositories
|
||||||
|
.server
|
||||||
|
.get_by_id(server_id)
|
||||||
|
.await?
|
||||||
|
.ok_or(HTTPError::NotFound)?;
|
||||||
|
|
||||||
|
state.services.server_order.reorder(payload).await?;
|
||||||
|
state.event_bus.emit(
|
||||||
|
"server_tree_invalidated",
|
||||||
|
ServerTreeInvalidatedEvent {
|
||||||
|
server_id,
|
||||||
|
user_ids: None,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(StatusCode::NO_CONTENT)
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
pub mod handlers;
|
||||||
|
pub mod routes;
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
use super::handlers;
|
||||||
|
use crate::core::state::AppState;
|
||||||
|
use axum::{Router, routing::put};
|
||||||
|
|
||||||
|
pub fn router() -> Router<AppState> {
|
||||||
|
Router::new().route("/server-item-orders/reorder", put(handlers::reorder))
|
||||||
|
}
|
||||||
+81
-3
@@ -1,5 +1,4 @@
|
|||||||
use crate::models::{role, server, server_user};
|
use crate::models::{role, role_user, server, server_user};
|
||||||
use crate::repositories::Repositories;
|
|
||||||
use crate::services::ServicesContext;
|
use crate::services::ServicesContext;
|
||||||
use sea_orm::{
|
use sea_orm::{
|
||||||
ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, QuerySelect, Set,
|
ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, QuerySelect, Set,
|
||||||
@@ -21,7 +20,9 @@ impl ServerService {
|
|||||||
pub async fn create_server(
|
pub async fn create_server(
|
||||||
&self,
|
&self,
|
||||||
name: String,
|
name: String,
|
||||||
|
password: Option<String>,
|
||||||
is_default: bool,
|
is_default: bool,
|
||||||
|
owner_id: Uuid,
|
||||||
) -> Result<server::Model, anyhow::Error> {
|
) -> Result<server::Model, anyhow::Error> {
|
||||||
let db = &self.service_context.repositories.server.context.db;
|
let db = &self.service_context.repositories.server.context.db;
|
||||||
let event_bus = &self.service_context.event_bus;
|
let event_bus = &self.service_context.event_bus;
|
||||||
@@ -30,7 +31,9 @@ impl ServerService {
|
|||||||
|
|
||||||
let active = server::ActiveModel {
|
let active = server::ActiveModel {
|
||||||
name: Set(name),
|
name: Set(name),
|
||||||
|
password: Set(password),
|
||||||
is_default: Set(is_default),
|
is_default: Set(is_default),
|
||||||
|
owner_id: Set(Some(owner_id)),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
let srv = active.insert(&txn).await?;
|
let srv = active.insert(&txn).await?;
|
||||||
@@ -41,7 +44,22 @@ impl ServerService {
|
|||||||
is_default: Set(true),
|
is_default: Set(true),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
default_group.insert(&txn).await?;
|
let default_group = default_group.insert(&txn).await?;
|
||||||
|
|
||||||
|
server_user::ActiveModel {
|
||||||
|
server_id: Set(srv.id),
|
||||||
|
user_id: Set(owner_id),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
.insert(&txn)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
role_user::ActiveModel {
|
||||||
|
role_id: Set(default_group.id),
|
||||||
|
user_id: Set(owner_id),
|
||||||
|
}
|
||||||
|
.insert(&txn)
|
||||||
|
.await?;
|
||||||
|
|
||||||
txn.commit().await?;
|
txn.commit().await?;
|
||||||
|
|
||||||
@@ -50,6 +68,66 @@ impl ServerService {
|
|||||||
Ok(srv)
|
Ok(srv)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn join_server(
|
||||||
|
&self,
|
||||||
|
server_id: Uuid,
|
||||||
|
user_id: Uuid,
|
||||||
|
password: Option<String>,
|
||||||
|
) -> Result<server::Model, anyhow::Error> {
|
||||||
|
let db = &self.service_context.repositories.server.context.db;
|
||||||
|
let event_bus = &self.service_context.event_bus;
|
||||||
|
let txn = db.begin().await?;
|
||||||
|
let srv = server::Entity::find_by_id(server_id)
|
||||||
|
.one(&txn)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Server not found"))?;
|
||||||
|
|
||||||
|
if srv.password != password {
|
||||||
|
return Err(anyhow::anyhow!("Invalid server password"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if server_user::Entity::find()
|
||||||
|
.filter(server_user::Column::ServerId.eq(server_id))
|
||||||
|
.filter(server_user::Column::UserId.eq(user_id))
|
||||||
|
.one(&txn)
|
||||||
|
.await?
|
||||||
|
.is_some()
|
||||||
|
{
|
||||||
|
txn.commit().await?;
|
||||||
|
return Ok(srv);
|
||||||
|
}
|
||||||
|
|
||||||
|
server_user::ActiveModel {
|
||||||
|
server_id: Set(server_id),
|
||||||
|
user_id: Set(user_id),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
.insert(&txn)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let default_role = role::Entity::find()
|
||||||
|
.filter(role::Column::ServerId.eq(server_id))
|
||||||
|
.filter(role::Column::IsDefault.eq(true))
|
||||||
|
.one(&txn)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Default role not found"))?;
|
||||||
|
role_user::ActiveModel {
|
||||||
|
role_id: Set(default_role.id),
|
||||||
|
user_id: Set(user_id),
|
||||||
|
}
|
||||||
|
.insert(&txn)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
txn.commit().await?;
|
||||||
|
event_bus.emit("server_user_created", (server_id, user_id));
|
||||||
|
self.service_context
|
||||||
|
.repositories
|
||||||
|
.read_state
|
||||||
|
.mark_server_read(server_id, user_id)
|
||||||
|
.await?;
|
||||||
|
Ok(srv)
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn update_server(
|
pub async fn update_server(
|
||||||
&self,
|
&self,
|
||||||
id: Uuid,
|
id: Uuid,
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
|
use crate::domain::dto::server_item_order::{ReorderServerItemRequest, ServerItemOrderPosition};
|
||||||
use crate::models::server_item_order::{self, OrderedResourceType};
|
use crate::models::server_item_order::{self, OrderedResourceType};
|
||||||
|
use crate::models::{category, channel};
|
||||||
use crate::services::ServicesContext;
|
use crate::services::ServicesContext;
|
||||||
use sea_orm::{
|
use sea_orm::{
|
||||||
ActiveModelTrait, ColumnTrait, DatabaseTransaction, EntityTrait, QueryFilter, QuerySelect, Set,
|
ActiveModelTrait, ColumnTrait, DatabaseTransaction, EntityTrait, QueryFilter, QueryOrder,
|
||||||
|
QuerySelect, Set, TransactionTrait,
|
||||||
};
|
};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
@@ -107,6 +110,144 @@ impl ServerOrderService {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn reorder(&self, request: ReorderServerItemRequest) -> Result<(), anyhow::Error> {
|
||||||
|
let db = &self.service_context.repositories.server.context.db;
|
||||||
|
let txn = db.begin().await?;
|
||||||
|
|
||||||
|
let mut orders = server_item_order::Entity::find()
|
||||||
|
.filter(server_item_order::Column::ServerId.eq(request.server_id))
|
||||||
|
.order_by_asc(server_item_order::Column::ParentCategoryId)
|
||||||
|
.order_by_asc(server_item_order::Column::OrderKey)
|
||||||
|
.all(&txn)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let target_index = orders
|
||||||
|
.iter()
|
||||||
|
.position(|order| {
|
||||||
|
order.resource_id == request.resource_id
|
||||||
|
&& order.resource_type == request.resource_type
|
||||||
|
})
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Ordered resource not found"))?;
|
||||||
|
let target = orders.remove(target_index);
|
||||||
|
|
||||||
|
if request.resource_type == OrderedResourceType::Category
|
||||||
|
&& request.parent_category_id.is_some()
|
||||||
|
{
|
||||||
|
return Err(anyhow::anyhow!("A category cannot have a parent category"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(category_id) = request.parent_category_id {
|
||||||
|
let parent = category::Entity::find_by_id(category_id)
|
||||||
|
.one(&txn)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Parent category not found"))?;
|
||||||
|
if parent.server_id != request.server_id {
|
||||||
|
return Err(anyhow::anyhow!("Parent category belongs to another server"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if request.resource_type == OrderedResourceType::Channel {
|
||||||
|
let channel_model = channel::Entity::find_by_id(request.resource_id)
|
||||||
|
.one(&txn)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Channel not found"))?;
|
||||||
|
if channel_model.server_id != Some(request.server_id) {
|
||||||
|
return Err(anyhow::anyhow!("Channel belongs to another server"));
|
||||||
|
}
|
||||||
|
if channel_model.category_id != request.parent_category_id {
|
||||||
|
let mut active: channel::ActiveModel = channel_model.into();
|
||||||
|
active.category_id = Set(request.parent_category_id);
|
||||||
|
active.update(&txn).await?;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let category_model = category::Entity::find_by_id(request.resource_id)
|
||||||
|
.one(&txn)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Category not found"))?;
|
||||||
|
if category_model.server_id != request.server_id {
|
||||||
|
return Err(anyhow::anyhow!("Category belongs to another server"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let reference_index = request.reference.as_ref().map(|reference| {
|
||||||
|
orders.iter().position(|order| {
|
||||||
|
order.resource_id == reference.resource_id
|
||||||
|
&& order.resource_type == reference.resource_type
|
||||||
|
})
|
||||||
|
});
|
||||||
|
let Some(reference_index) = reference_index.flatten() else {
|
||||||
|
if request.reference.is_some() {
|
||||||
|
return Err(anyhow::anyhow!("Order reference not found"));
|
||||||
|
}
|
||||||
|
let insertion_index = orders
|
||||||
|
.iter()
|
||||||
|
.filter(|order| order.parent_category_id == request.parent_category_id)
|
||||||
|
.count();
|
||||||
|
let destination_indices: Vec<usize> = orders
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.filter_map(|(index, order)| {
|
||||||
|
(order.parent_category_id == request.parent_category_id).then_some(index)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let insert_at = destination_indices
|
||||||
|
.get(insertion_index.saturating_sub(1))
|
||||||
|
.map(|index| index + 1)
|
||||||
|
.unwrap_or_else(|| {
|
||||||
|
orders
|
||||||
|
.iter()
|
||||||
|
.position(|order| order.parent_category_id == request.parent_category_id)
|
||||||
|
.unwrap_or(orders.len())
|
||||||
|
});
|
||||||
|
orders.insert(
|
||||||
|
insert_at,
|
||||||
|
server_item_order::Model {
|
||||||
|
parent_category_id: request.parent_category_id,
|
||||||
|
order_key: target.order_key,
|
||||||
|
..target
|
||||||
|
},
|
||||||
|
);
|
||||||
|
self.persist_reordered(&txn, orders).await?;
|
||||||
|
txn.commit().await?;
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
|
||||||
|
let reference = orders[reference_index].clone();
|
||||||
|
if reference.parent_category_id != request.parent_category_id {
|
||||||
|
return Err(anyhow::anyhow!("Order reference belongs to another list"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let insert_at = match request.position {
|
||||||
|
ServerItemOrderPosition::Before => reference_index,
|
||||||
|
ServerItemOrderPosition::After => reference_index + 1,
|
||||||
|
};
|
||||||
|
orders.insert(
|
||||||
|
insert_at,
|
||||||
|
server_item_order::Model {
|
||||||
|
parent_category_id: request.parent_category_id,
|
||||||
|
order_key: target.order_key,
|
||||||
|
..target
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
self.persist_reordered(&txn, orders).await?;
|
||||||
|
txn.commit().await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn persist_reordered(
|
||||||
|
&self,
|
||||||
|
txn: &DatabaseTransaction,
|
||||||
|
orders: Vec<server_item_order::Model>,
|
||||||
|
) -> Result<(), anyhow::Error> {
|
||||||
|
for (index, order) in orders.into_iter().enumerate() {
|
||||||
|
let mut active: server_item_order::ActiveModel = order.into();
|
||||||
|
active.order_key = Set((index as i64) + 1);
|
||||||
|
active.update(txn).await?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
async fn next_order_key(
|
async fn next_order_key(
|
||||||
&self,
|
&self,
|
||||||
txn: &DatabaseTransaction,
|
txn: &DatabaseTransaction,
|
||||||
|
|||||||
Reference in New Issue
Block a user