This commit is contained in:
2026-07-31 11:04:34 +02:00
parent 086e5ab0ea
commit 621f8cefa0
9 changed files with 165 additions and 46 deletions
+48 -10
View File
@@ -7,6 +7,7 @@ import {useRoute} from 'vue-router'
import CreateChannelDialog from '@/components/channel/CreateChannelDialog.vue' import CreateChannelDialog from '@/components/channel/CreateChannelDialog.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";
const props = defineProps<{ const props = defineProps<{
serverId: string serverId: string
@@ -17,8 +18,8 @@ const route = useRoute()
const channelStore = useChannelStore() const channelStore = useChannelStore()
const categoryStore = useCategoryStore() const categoryStore = useCategoryStore()
const userStore = useUserStore() const userStore = useUserStore()
const {channels} = storeToRefs(channelStore) const serverStore = useServerStore()
const {categories} = storeToRefs(categoryStore) const {currentTree} = storeToRefs(serverStore)
const {openContextMenu} = useContextMenu() const {openContextMenu} = useContextMenu()
@@ -29,9 +30,8 @@ const loadServerData = async (targetServerId: string) => {
userStore.reset() userStore.reset()
try { try {
await Promise.all([ await Promise.all([
channelStore.fetchChannels(targetServerId), userStore.fetchUsers(targetServerId),
categoryStore.fetchCategories(targetServerId), serverStore.fetchServerTree(targetServerId)
userStore.fetchUsers(targetServerId)
]) ])
} catch (error) { } catch (error) {
console.error('Failed to load server-scoped channels and categories:', error) console.error('Failed to load server-scoped channels and categories:', error)
@@ -50,7 +50,24 @@ watch(
const showDialog = ref(false) const showDialog = ref(false)
// Right click menu // Right click menu (sidebar)
function onSidebarContextMenu(event: MouseEvent) {
const menuItems: MenuItem[] = [
{
label: 'Nouveau canal',
icon: 'mdi-plus',
action: () => console.log('Nouveau canal'),
},
{
label: 'Nouvelle catégorie',
icon: 'mdi-folder-plus',
action: () => console.log('Nouvelle catégorie'),
}
]
openContextMenu(event, menuItems);
}
// Right click menu (channel)
async function openEditDialog(channel: any) { async function openEditDialog(channel: any) {
console.log("edit dialog clicked") console.log("edit dialog clicked")
} }
@@ -80,10 +97,12 @@ function onChannelContextMenu(event: MouseEvent, channel: any) {
] ]
openContextMenu(event, menuItems); openContextMenu(event, menuItems);
} }
</script> </script>
<template> <template>
<v-navigation-drawer width="244"> <v-navigation-drawer width="244" @contextmenu="onSidebarContextMenu">
<v-sheet <v-sheet
color="grey-lighten-5" color="grey-lighten-5"
height="128" height="128"
@@ -100,15 +119,34 @@ function onChannelContextMenu(event: MouseEvent, channel: any) {
New Channel New Channel
</v-btn> </v-btn>
<v-list> <v-list density="compact">
<template v-for="(item, index) in currentTree" :key="index">
<!-- Catégorie et ses canaux enfants -->
<v-list-group v-if="'Category' in item" :value="item.Category[0].id">
<template #activator="{ props: groupProps }">
<v-list-item :title="item.Category[0].name" v-bind="groupProps"/>
</template>
<v-list-item <v-list-item
v-for="channel in channels" 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}`"
link link
@contextmenu="onChannelContextMenu($event, channel)" @contextmenu="onChannelContextMenu($event, channel)"
></v-list-item> />
</v-list-group>
<!-- Canal orphelin (racine) -->
<v-list-item
v-else-if="'Channel' in item"
:key="item.Channel.id"
:title="item.Channel.name"
:to="`/server/${serverId}/channel/${item.Channel.id}`"
link
@contextmenu="onChannelContextMenu($event, item.Channel)"
/>
</template>
</v-list> </v-list>
</v-navigation-drawer> </v-navigation-drawer>
+9 -1
View File
@@ -1,9 +1,17 @@
import {defineStore} from 'pinia' import {defineStore} from 'pinia'
import {useApi} from "@/composables/useApi.ts"; import {useApi} from "@/composables/useApi.ts";
interface Category {
id: string
name: string
server_id?: string | null
created_at: string
updated_at: string
}
export const useCategoryStore = defineStore("category", { export const useCategoryStore = defineStore("category", {
state: () => ({ state: () => ({
categories: [] categories: [] as Category[]
}), }),
actions: { actions: {
async fetchCategories(serverId?: string) { async fetchCategories(serverId?: string) {
-1
View File
@@ -7,7 +7,6 @@ interface Channel {
channel_type: string channel_type: string
server_id?: string | null server_id?: string | null
category_id?: string | null category_id?: string | null
position: number
created_at: string created_at: string
updated_at: string updated_at: string
} }
+34 -1
View File
@@ -1,5 +1,7 @@
import {defineStore} from "pinia"; import {defineStore} from "pinia";
import {useApi} from "@/composables/useApi.ts"; import {useApi} from "@/composables/useApi.ts";
import {useChannelStore} from "@/stores/channel.ts";
import {useCategoryStore} from "@/stores/category.ts";
interface Server { interface Server {
id: string id: string
@@ -13,7 +15,8 @@ export const useServerStore = defineStore("server", {
state: () => ({ state: () => ({
servers: [] as Server[], servers: [] as Server[],
loading: false, loading: false,
error: null as string | null error: null as string | null,
currentTree: [] as any[],
}), }),
actions: { actions: {
async fetchServers() { async fetchServers() {
@@ -41,6 +44,36 @@ export const useServerStore = defineStore("server", {
this.loading = false; this.loading = false;
} }
}, },
async fetchServerTree(serverId: string) {
const api = useApi();
const channelStore = useChannelStore();
const categoryStore = useCategoryStore();
const response = await api.get(`/servers/${serverId}/tree`);
const tree = await response.json(); // { items: [...] }
this.currentTree = tree.items;
// Extraction à plat pour alimenter les stores spécialisés
const extractedCategories: any[] = [];
const extractedChannels: any[] = [];
for (const item of tree.items) {
if ("Category" in item) {
const [category, channels] = item.Category;
extractedCategories.push(category);
extractedChannels.push(...channels);
} else if ("Channel" in item) {
extractedChannels.push(item.Channel);
}
}
// Population / Synchro des stores individuels
categoryStore.categories = extractedCategories;
channelStore.channels = extractedChannels;
return tree.items;
},
reset() { reset() {
this.servers = []; this.servers = [];
this.loading = false; this.loading = false;
+13
View File
@@ -1,3 +1,5 @@
use crate::domain::dto::category::CategoryResponse;
use crate::domain::dto::channel::ChannelResponse;
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use utoipa::ToSchema; use utoipa::ToSchema;
@@ -50,3 +52,14 @@ pub struct ServerRolePermissionResponse {
pub role_id: Uuid, pub role_id: Uuid,
pub permissions: u64, pub permissions: u64,
} }
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub enum ServerExplorerItemResponse {
Category(CategoryResponse, Vec<ChannelResponse>),
Channel(ChannelResponse),
}
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct ServerTreeResponse {
pub items: Vec<ServerExplorerItemResponse>,
}
+1
View File
@@ -20,6 +20,7 @@ use utoipa::{Modify, OpenApi};
server::handlers::create, server::handlers::create,
server::handlers::update, server::handlers::update,
server::handlers::delete, server::handlers::delete,
server::handlers::get_tree,
category::handlers::get_all, category::handlers::get_all,
category::handlers::get_by_id, category::handlers::get_by_id,
category::handlers::create, category::handlers::create,
+27 -16
View File
@@ -1,11 +1,11 @@
use crate::core::state::AppState; use crate::core::state::AppState;
use crate::http::context::Superuser;
use crate::http::error::HTTPError;
use crate::repositories::types::{ChannelFilter, ServerTree};
use crate::domain::dto::server::{ use crate::domain::dto::server::{
CreateServerRequest, ServerResponse, ServerRolePermissionResponse, CreateServerRequest, ServerResponse, ServerRolePermissionResponse, ServerTreeResponse,
ServerUserPermissionResponse, SetServerPermissionRequest, UpdateServerRequest, ServerUserPermissionResponse, SetServerPermissionRequest, UpdateServerRequest,
}; };
use crate::http::context::Superuser;
use crate::http::error::HTTPError;
use crate::repositories::types::ChannelFilter;
use crate::routes::server::mapper; use crate::routes::server::mapper;
use axum::{ use axum::{
Json, Json,
@@ -83,7 +83,11 @@ pub async fn create(
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.services.server.create_server(payload.name, payload.is_default).await?; let server = state
.services
.server
.create_server(payload.name, payload.is_default)
.await?;
Ok(( Ok((
StatusCode::CREATED, StatusCode::CREATED,
Json(mapper::server_model_to_server_response(server)), Json(mapper::server_model_to_server_response(server)),
@@ -122,7 +126,11 @@ pub async fn update(
.await? .await?
.ok_or(HTTPError::NotFound)?; .ok_or(HTTPError::NotFound)?;
let server = state.services.server.update_server(id, payload.name, payload.is_default).await?; let server = state
.services
.server
.update_server(id, payload.name, payload.is_default)
.await?;
Ok(Json(mapper::server_model_to_server_response(server))) Ok(Json(mapper::server_model_to_server_response(server)))
} }
@@ -364,39 +372,42 @@ pub async fn remove_role_permission(
#[utoipa::path( #[utoipa::path(
get, get,
path = "/servers/{id}/tree", path = "/servers/{server_id}/tree",
responses( responses(
(status = 200, description = "Arborescence du serveur récupérée avec succès"), (status = 200, description = "Arborescence du serveur récupérée avec succès"),
(status = 404, description = "Serveur introuvable"), (status = 404, description = "Serveur introuvable"),
(status = 500, description = "Erreur interne du serveur") (status = 500, description = "Erreur interne du serveur")
), ),
params( params(
("id" = Uuid, Path, description = "ID du serveur") ("server_id" = Uuid, Path, description = "ID du serveur")
), ),
tag = "Servers" tag = "Servers"
)] )]
pub async fn get_server_tree( pub async fn get_tree(
State(state): State<AppState>, State(state): State<AppState>,
Path(id): Path<Uuid>, Path(server_id): Path<Uuid>,
) -> Result<Json<ServerTree>, HTTPError> { ) -> Result<Json<ServerTreeResponse>, HTTPError> {
// Vérifier l'existence du serveur // Vérifier l'existence du serveur
state state
.repositories .repositories
.server .server
.get_by_id(id) .get_by_id(server_id)
.await? .await?
.ok_or(HTTPError::NotFound)?; .ok_or(HTTPError::NotFound)?;
// Exécution parallèle des 3 requêtes via leurs repositories respectifs // Exécution parallèle des 3 requêtes via leurs repositories respectifs
let (orders, channels, categories) = tokio::try_join!( let (orders, channels, categories) = tokio::try_join!(
state.repositories.server_item_order.get_by_server(id), state
.repositories
.server_item_order
.get_by_server(server_id),
state.repositories.channel.filter(ChannelFilter { state.repositories.channel.filter(ChannelFilter {
server_id: Some(id) server_id: Some(server_id)
}), }),
state.repositories.category.get_by_server(id), state.repositories.category.get_by_server(server_id),
)?; )?;
// Assemblage du ServerTree via le mapper // Assemblage du ServerTreeResponse via le mapper
let tree = mapper::build_server_tree(orders, channels, categories); let tree = mapper::build_server_tree(orders, channels, categories);
Ok(Json(tree)) Ok(Json(tree))
+25 -10
View File
@@ -1,11 +1,12 @@
use crate::domain::dto::server::{
CreateServerRequest, ServerExplorerItemResponse, ServerResponse, ServerRolePermissionResponse,
ServerTreeResponse, ServerUserPermissionResponse, UpdateServerRequest,
};
use crate::models::{ use crate::models::{
category, channel, server, server_item_order, server_role_permission, server_user_permission, category, channel, server, server_item_order, server_role_permission, server_user_permission,
}; };
use crate::repositories::types::{ServerExplorerItem, ServerTree}; use crate::routes::category::mapper::category_model_to_category_response;
use crate::domain::dto::server::{ use crate::routes::channel::mapper::channel_model_to_channel_response;
CreateServerRequest, ServerResponse, ServerRolePermissionResponse,
ServerUserPermissionResponse, UpdateServerRequest,
};
use sea_orm::Set; use sea_orm::Set;
use std::collections::HashMap; use std::collections::HashMap;
use uuid::Uuid; use uuid::Uuid;
@@ -66,7 +67,7 @@ pub fn build_server_tree(
orders: Vec<server_item_order::Model>, orders: Vec<server_item_order::Model>,
channels: Vec<channel::Model>, channels: Vec<channel::Model>,
categories: Vec<category::Model>, categories: Vec<category::Model>,
) -> ServerTree { ) -> ServerTreeResponse {
// Map d'accès rapide : resource_id -> order_key // Map d'accès rapide : resource_id -> order_key
let order_map: HashMap<Uuid, i64> = orders let order_map: HashMap<Uuid, i64> = orders
.into_iter() .into_iter()
@@ -91,18 +92,32 @@ pub fn build_server_tree(
} }
// Préparer les éléments racine (catégories & canaux orphelins) avec leur order_key // Préparer les éléments racine (catégories & canaux orphelins) avec leur order_key
let mut root_items: Vec<(ServerExplorerItem, i64)> = Vec::new(); let mut root_items: Vec<(ServerExplorerItemResponse, i64)> = Vec::new();
for cat in categories { for cat in categories {
let cat_id = cat.id; let cat_id = cat.id;
let order_key = order_map.get(&cat_id).copied().unwrap_or(i64::MAX); let order_key = order_map.get(&cat_id).copied().unwrap_or(i64::MAX);
let chans = category_channels.remove(&cat_id).unwrap_or_default(); let chans = category_channels.remove(&cat_id).unwrap_or_default();
root_items.push((ServerExplorerItem::Category(cat, chans), order_key));
let cat_response = category_model_to_category_response(cat);
let chan_responses = chans
.into_iter()
.map(channel_model_to_channel_response)
.collect();
root_items.push((
ServerExplorerItemResponse::Category(cat_response, chan_responses),
order_key,
));
} }
for chan in orphan_channels { for chan in orphan_channels {
let order_key = order_map.get(&chan.id).copied().unwrap_or(i64::MAX); let order_key = order_map.get(&chan.id).copied().unwrap_or(i64::MAX);
root_items.push((ServerExplorerItem::Channel(chan), order_key)); let chan_response = channel_model_to_channel_response(chan);
root_items.push((
ServerExplorerItemResponse::Channel(chan_response),
order_key,
));
} }
// Tri du niveau racine par order_key // Tri du niveau racine par order_key
@@ -110,5 +125,5 @@ pub fn build_server_tree(
let items = root_items.into_iter().map(|(item, _)| item).collect(); let items = root_items.into_iter().map(|(item, _)| item).collect();
ServerTree { items } ServerTreeResponse { items }
} }
+2 -1
View File
@@ -1,6 +1,6 @@
use super::handlers; use super::handlers;
use crate::core::state::AppState; use crate::core::state::AppState;
use axum::{routing::get, Router}; use axum::{Router, routing::get};
pub fn router() -> Router<AppState> { pub fn router() -> Router<AppState> {
Router::new() Router::new()
@@ -11,6 +11,7 @@ pub fn router() -> Router<AppState> {
.put(handlers::update) .put(handlers::update)
.delete(handlers::delete), .delete(handlers::delete),
) )
.route("/servers/{server_id}/tree", get(handlers::get_tree))
.route( .route(
"/servers/{server_id}/permissions/users/{user_id}", "/servers/{server_id}/permissions/users/{user_id}",
get(handlers::get_user_permission) get(handlers::get_user_permission)