This commit is contained in:
2026-08-02 09:17:39 +02:00
parent 73379e9ca8
commit ba51dde1c2
8 changed files with 253 additions and 42 deletions
+1
View File
@@ -17,6 +17,7 @@
"dependencies": { "dependencies": {
"@fontsource/roboto": "^5.2.10", "@fontsource/roboto": "^5.2.10",
"@mdi/font": "7.4.47", "@mdi/font": "7.4.47",
"highlight.js": "^11.11.1",
"markdown-it": "^14.3.0", "markdown-it": "^14.3.0",
"pinia": "^3.0.4", "pinia": "^3.0.4",
"vue": "^3.5.30", "vue": "^3.5.30",
@@ -0,0 +1,90 @@
<script lang="ts" setup>
import {ref, watch} from 'vue'
import {useCategoryStore} from '@/stores/category'
const props = defineProps<{
modelValue: boolean
serverId: string
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void
(e: 'created', category: any): void
}>()
const categoryStore = useCategoryStore()
const name = ref('')
const isSubmitting = ref(false)
const resetForm = () => {
name.value = ''
}
const handleClose = () => {
emit('update:modelValue', false)
resetForm()
}
const handleSubmit = async () => {
if (!name.value.trim()) {
alert('Category name is required')
return
}
isSubmitting.value = true
try {
const newCategory = await categoryStore.createCategory({
name: name.value.trim(),
server_id: props.serverId
})
emit('created', newCategory)
handleClose()
} catch (error) {
console.error('Failed to create category:', error)
} finally {
isSubmitting.value = false
}
}
watch(
() => props.modelValue,
(isOpen) => {
if (!isOpen) {
resetForm()
}
}
)
</script>
<template>
<v-dialog :model-value="modelValue" width="400" @update:model-value="handleClose">
<v-card>
<v-card-title>Create Category</v-card-title>
<v-card-text>
<v-text-field
v-model="name"
autofocus
density="compact"
label="Category Name"
outlined
@keyup.enter="handleSubmit"
/>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn :disabled="isSubmitting" variant="text" @click="handleClose">
Cancel
</v-btn>
<v-btn
:disabled="!name.trim()"
:loading="isSubmitting"
color="primary"
variant="tonal"
@click="handleSubmit"
>
Create
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
+30
View File
@@ -0,0 +1,30 @@
import MarkdownIt from 'markdown-it'
import hljs from 'highlight.js'
// Déclaré hors de la fonction = instancié une seule fois pour toute l'application
const md = new MarkdownIt({
html: false,
linkify: true,
typographer: true,
breaks: true,
})
md.set({
highlight: (str: string, lang: string): string => {
if (lang && hljs.getLanguage(lang)) {
try {
return `<pre class="hljs"><code>${hljs.highlight(str, {
language: lang,
ignoreIllegals: true
}).value}</code></pre>`
} catch (__) {
}
}
return `<pre class="hljs"><code>${md.utils.escapeHtml(str)}</code></pre>`
}
})
export function useMarkdown() {
const renderMarkdown = (content: string) => md.render(content)
return {renderMarkdown}
}
+5 -12
View File
@@ -1,10 +1,11 @@
<script lang="ts" setup> <script lang="ts" setup>
import MarkdownIt from 'markdown-it' import 'highlight.js/styles/github-dark.css'
import {computed, nextTick, onMounted, ref, watch} from 'vue'; import {computed, nextTick, onMounted, ref, watch} from 'vue';
import {useRoute} from 'vue-router'; import {useRoute} from 'vue-router';
import {storeToRefs} from 'pinia'; import {storeToRefs} from 'pinia';
import {useMessageStore} from '@/stores/message'; import {useMessageStore} from '@/stores/message';
import {useUserStore} from "@/stores/user.ts"; import {useUserStore} from "@/stores/user.ts";
import {useMarkdown} from '@/composables/useMarkdown'
const props = defineProps<{ const props = defineProps<{
serverId: string serverId: string
@@ -16,6 +17,7 @@ const channelId = computed(() => props.channelId);
const route = useRoute(); const route = useRoute();
const messageStore = useMessageStore(); const messageStore = useMessageStore();
const userStore = useUserStore(); const userStore = useUserStore();
const {renderMarkdown} = useMarkdown()
// Référence vers l'élément scrollable // Référence vers l'élément scrollable
const messageContainer = ref<HTMLElement | null>(null); const messageContainer = ref<HTMLElement | null>(null);
@@ -25,15 +27,6 @@ const {messages, loading} = storeToRefs(messageStore);
const newMessage = ref(''); const newMessage = ref('');
const md = new MarkdownIt({
html: false, // Désactive le HTML pur pour la sécurité
linkify: true, // Convertit automatiquement les URLs en liens
typographer: true,
breaks: true // Convertit les retours à la ligne en <br> (comportement type chat)
})
const renderMarkdown = (content: string) => {
return md.render(content);
};
const scrollToBottom = async () => { const scrollToBottom = async () => {
await nextTick(); await nextTick();
@@ -100,9 +93,9 @@ watch(messages, () => {
<span class="text-caption text-grey">{{ msg.created_at }}</span> <span class="text-caption text-grey">{{ msg.created_at }}</span>
</v-list-item-title> </v-list-item-title>
<v-list-item-subtitle class="text-body-1 text-high-emphasis opacity-100"> <div class="text-body-1 text-high-emphasis opacity-100 mt-1">
<div class="markdown-content" v-html="renderMarkdown(msg.content)"></div> <div class="markdown-content" v-html="renderMarkdown(msg.content)"></div>
</v-list-item-subtitle> </div>
</v-list-item> </v-list-item>
</v-list> </v-list>
</div> </div>
+51 -16
View File
@@ -5,6 +5,7 @@ import {useCategoryStore} from '@/stores/category'
import {ref, watch} from 'vue' import {ref, watch} from 'vue'
import {useRoute} from 'vue-router' import {useRoute} from 'vue-router'
import CreateChannelDialog from '@/components/channel/CreateChannelDialog.vue' import CreateChannelDialog from '@/components/channel/CreateChannelDialog.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 {useServerStore} from "@/stores/server.ts";
@@ -33,6 +34,7 @@ const loadServerData = async (targetServerId: string) => {
userStore.fetchUsers(targetServerId), userStore.fetchUsers(targetServerId),
serverStore.fetchServerTree(targetServerId) serverStore.fetchServerTree(targetServerId)
]) ])
syncOpenedCategories()
} 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)
} }
@@ -48,7 +50,21 @@ watch(
{immediate: true} {immediate: true}
) )
const showDialog = ref(false) const showChannelDialog = ref(false)
const showCategoryDialog = ref(false)
const selectedCategoryId = ref<string | null>(null)
const openedCategories = ref<string[]>([])
function syncOpenedCategories() {
openedCategories.value = currentTree.value
.filter((item) => 'Category' in item)
.map((item) => item.Category[0].id)
}
async function refreshServerTree() {
await serverStore.fetchServerTree(props.serverId)
syncOpenedCategories()
}
// Right click menu (sidebar) // Right click menu (sidebar)
function onSidebarContextMenu(event: MouseEvent) { function onSidebarContextMenu(event: MouseEvent) {
@@ -56,17 +72,34 @@ function onSidebarContextMenu(event: MouseEvent) {
{ {
label: 'Nouveau canal', label: 'Nouveau canal',
icon: 'mdi-plus', icon: 'mdi-plus',
action: () => console.log('Nouveau canal'), action: () => {
selectedCategoryId.value = null
showChannelDialog.value = true
},
}, },
{ {
label: 'Nouvelle catégorie', label: 'Nouvelle catégorie',
icon: 'mdi-folder-plus', icon: 'mdi-folder-plus',
action: () => console.log('Nouvelle catégorie'), action: () => { showCategoryDialog.value = true },
} }
] ]
openContextMenu(event, menuItems); openContextMenu(event, menuItems);
} }
function onCategoryContextMenu(event: MouseEvent, category: any) {
const menuItems: MenuItem[] = [
{
label: 'Nouveau canal',
icon: 'mdi-plus',
action: () => {
selectedCategoryId.value = category.id
showChannelDialog.value = true
},
},
]
openContextMenu(event, menuItems)
}
// Right click menu (channel) // Right click menu (channel)
async function openEditDialog(channel: any) { async function openEditDialog(channel: any) {
console.log("edit dialog clicked") console.log("edit dialog clicked")
@@ -109,22 +142,16 @@ function onChannelContextMenu(event: MouseEvent, channel: any) {
width="100%" width="100%"
></v-sheet> ></v-sheet>
<v-btn <v-list v-model:opened="openedCategories" density="compact">
block
class="ma-2"
prepend-icon="mdi-plus"
variant="text"
@click="showDialog = true"
>
New Channel
</v-btn>
<v-list density="compact">
<template v-for="(item, index) in currentTree" :key="index"> <template v-for="(item, index) in currentTree" :key="index">
<!-- Catégorie et ses canaux enfants --> <!-- Catégorie et ses canaux enfants -->
<v-list-group v-if="'Category' in item" :value="item.Category[0].id"> <v-list-group v-if="'Category' in item" :value="item.Category[0].id">
<template #activator="{ props: groupProps }"> <template #activator="{ props: groupProps }">
<v-list-item :title="item.Category[0].name" v-bind="groupProps"/> <v-list-item
:title="item.Category[0].name"
v-bind="groupProps"
@contextmenu="onCategoryContextMenu($event, item.Category[0])"
/>
</template> </template>
<v-list-item <v-list-item
@@ -151,8 +178,16 @@ function onChannelContextMenu(event: MouseEvent, channel: any) {
</v-navigation-drawer> </v-navigation-drawer>
<CreateChannelDialog <CreateChannelDialog
v-model="showDialog" v-model="showChannelDialog"
:category-id="selectedCategoryId"
:server-id="serverId" :server-id="serverId"
@created="refreshServerTree"
/>
<CreateCategoryDialog
v-model="showCategoryDialog"
:server-id="serverId"
@created="refreshServerTree"
/> />
<v-main> <v-main>
+27 -1
View File
@@ -11,7 +11,9 @@ interface Category {
export const useCategoryStore = defineStore("category", { export const useCategoryStore = defineStore("category", {
state: () => ({ state: () => ({
categories: [] as Category[] categories: [] as Category[],
loading: false,
error: null as string | null
}), }),
actions: { actions: {
async fetchCategories(serverId?: string) { async fetchCategories(serverId?: string) {
@@ -23,8 +25,32 @@ export const useCategoryStore = defineStore("category", {
let response = await api.get(url); let response = await api.get(url);
this.categories = await response.json(); this.categories = await response.json();
}, },
async createCategory(payload: { server_id: string; name: string }) {
this.loading = true;
this.error = null;
try {
const api = useApi();
const response = await api.post("/categories", payload);
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || 'Failed to create category');
}
const newCategory = await response.json();
this.categories.push(newCategory);
return newCategory;
} catch (err) {
this.error = err instanceof Error ? err.message : 'Unknown error';
throw err;
} finally {
this.loading = false;
}
},
reset() { reset() {
this.categories = []; this.categories = [];
this.loading = false;
this.error = null;
} }
} }
}); });
+5
View File
@@ -1660,6 +1660,11 @@ has-flag@^4.0.0:
resolved "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz" resolved "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz"
integrity sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ== integrity sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==
highlight.js@^11.11.1:
version "11.11.1"
resolved "https://registry.yarnpkg.com/highlight.js/-/highlight.js-11.11.1.tgz#fca06fa0e5aeecf6c4d437239135fabc15213585"
integrity sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==
hookable@^5.5.3: hookable@^5.5.3:
version "5.5.3" version "5.5.3"
resolved "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz" resolved "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz"
+38 -7
View File
@@ -1,6 +1,9 @@
use crate::services::ServicesContext;
use crate::models::category; use crate::models::category;
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, TransactionTrait, Set}; use crate::models::server_item_order::{self, OrderedResourceType};
use crate::services::ServicesContext;
use sea_orm::{
ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QuerySelect, Set, TransactionTrait,
};
use std::sync::Arc; use std::sync::Arc;
use uuid::Uuid; use uuid::Uuid;
@@ -22,16 +25,40 @@ impl CategoryService {
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;
let txn = db.begin().await?; let cat = db
.transaction::<_, category::Model, anyhow::Error>(|txn| {
Box::pin(async move {
let active = category::ActiveModel { let active = category::ActiveModel {
server_id: Set(server_id), server_id: Set(server_id),
name: Set(name), name: Set(name),
..Default::default() ..Default::default()
}; };
let cat = active.insert(&txn).await?; let cat = active.insert(txn).await?;
txn.commit().await?; let max_order: Option<i64> = server_item_order::Entity::find()
.filter(server_item_order::Column::ServerId.eq(server_id))
.filter(server_item_order::Column::ParentCategoryId.is_null())
.select_only()
.column_as(server_item_order::Column::OrderKey.max(), "max_key")
.into_tuple::<Option<i64>>()
.one(txn)
.await?
.flatten();
let order_item = server_item_order::ActiveModel {
server_id: Set(server_id),
resource_id: Set(cat.id),
resource_type: Set(OrderedResourceType::Category),
parent_category_id: Set(None),
order_key: Set(max_order.unwrap_or(0) + 1),
..Default::default()
};
order_item.insert(txn).await?;
Ok(cat)
})
})
.await?;
event_bus.emit("category_created", cat.clone()); event_bus.emit("category_created", cat.clone());
@@ -71,10 +98,14 @@ impl CategoryService {
let txn = db.begin().await?; let txn = db.begin().await?;
let res = category::Entity::delete_by_id(id) server_item_order::Entity::delete_many()
.filter(server_item_order::Column::ResourceId.eq(id))
.filter(server_item_order::Column::ResourceType.eq(OrderedResourceType::Category))
.exec(&txn) .exec(&txn)
.await?; .await?;
let res = category::Entity::delete_by_id(id).exec(&txn).await?;
let deleted = res.rows_affected > 0; let deleted = res.rows_affected > 0;
txn.commit().await?; txn.commit().await?;