init
This commit is contained in:
@@ -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>
|
||||||
@@ -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}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,11 +178,19 @@ 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>
|
||||||
<router-view/>
|
<router-view/>
|
||||||
</v-main>
|
</v-main>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
+42
-11
@@ -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 {
|
||||||
|
server_id: Set(server_id),
|
||||||
|
name: Set(name),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let cat = active.insert(txn).await?;
|
||||||
|
|
||||||
let active = category::ActiveModel {
|
let max_order: Option<i64> = server_item_order::Entity::find()
|
||||||
server_id: Set(server_id),
|
.filter(server_item_order::Column::ServerId.eq(server_id))
|
||||||
name: Set(name),
|
.filter(server_item_order::Column::ParentCategoryId.is_null())
|
||||||
..Default::default()
|
.select_only()
|
||||||
};
|
.column_as(server_item_order::Column::OrderKey.max(), "max_key")
|
||||||
let cat = active.insert(&txn).await?;
|
.into_tuple::<Option<i64>>()
|
||||||
|
.one(txn)
|
||||||
|
.await?
|
||||||
|
.flatten();
|
||||||
|
|
||||||
txn.commit().await?;
|
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?;
|
||||||
|
|||||||
Reference in New Issue
Block a user