init
This commit is contained in:
@@ -17,6 +17,7 @@
|
||||
"dependencies": {
|
||||
"@fontsource/roboto": "^5.2.10",
|
||||
"@mdi/font": "7.4.47",
|
||||
"highlight.js": "^11.11.1",
|
||||
"markdown-it": "^14.3.0",
|
||||
"pinia": "^3.0.4",
|
||||
"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>
|
||||
import MarkdownIt from 'markdown-it'
|
||||
import 'highlight.js/styles/github-dark.css'
|
||||
import {computed, nextTick, onMounted, ref, watch} from 'vue';
|
||||
import {useRoute} from 'vue-router';
|
||||
import {storeToRefs} from 'pinia';
|
||||
import {useMessageStore} from '@/stores/message';
|
||||
import {useUserStore} from "@/stores/user.ts";
|
||||
import {useMarkdown} from '@/composables/useMarkdown'
|
||||
|
||||
const props = defineProps<{
|
||||
serverId: string
|
||||
@@ -16,6 +17,7 @@ const channelId = computed(() => props.channelId);
|
||||
const route = useRoute();
|
||||
const messageStore = useMessageStore();
|
||||
const userStore = useUserStore();
|
||||
const {renderMarkdown} = useMarkdown()
|
||||
|
||||
// Référence vers l'élément scrollable
|
||||
const messageContainer = ref<HTMLElement | null>(null);
|
||||
@@ -25,15 +27,6 @@ const {messages, loading} = storeToRefs(messageStore);
|
||||
|
||||
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 () => {
|
||||
await nextTick();
|
||||
@@ -100,9 +93,9 @@ watch(messages, () => {
|
||||
<span class="text-caption text-grey">{{ msg.created_at }}</span>
|
||||
</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>
|
||||
</v-list-item-subtitle>
|
||||
</div>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,7 @@ import {useCategoryStore} from '@/stores/category'
|
||||
import {ref, watch} from 'vue'
|
||||
import {useRoute} from 'vue-router'
|
||||
import CreateChannelDialog from '@/components/channel/CreateChannelDialog.vue'
|
||||
import CreateCategoryDialog from '@/components/category/CreateCategoryDialog.vue'
|
||||
import {type MenuItem, useContextMenu} from '@/composables/useContextMenu'
|
||||
import {useUserStore} from "@/stores/user.ts";
|
||||
import {useServerStore} from "@/stores/server.ts";
|
||||
@@ -33,6 +34,7 @@ const loadServerData = async (targetServerId: string) => {
|
||||
userStore.fetchUsers(targetServerId),
|
||||
serverStore.fetchServerTree(targetServerId)
|
||||
])
|
||||
syncOpenedCategories()
|
||||
} catch (error) {
|
||||
console.error('Failed to load server-scoped channels and categories:', error)
|
||||
}
|
||||
@@ -48,7 +50,21 @@ watch(
|
||||
{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)
|
||||
function onSidebarContextMenu(event: MouseEvent) {
|
||||
@@ -56,17 +72,34 @@ function onSidebarContextMenu(event: MouseEvent) {
|
||||
{
|
||||
label: 'Nouveau canal',
|
||||
icon: 'mdi-plus',
|
||||
action: () => console.log('Nouveau canal'),
|
||||
action: () => {
|
||||
selectedCategoryId.value = null
|
||||
showChannelDialog.value = true
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Nouvelle catégorie',
|
||||
icon: 'mdi-folder-plus',
|
||||
action: () => console.log('Nouvelle catégorie'),
|
||||
action: () => { showCategoryDialog.value = true },
|
||||
}
|
||||
]
|
||||
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)
|
||||
async function openEditDialog(channel: any) {
|
||||
console.log("edit dialog clicked")
|
||||
@@ -109,22 +142,16 @@ function onChannelContextMenu(event: MouseEvent, channel: any) {
|
||||
width="100%"
|
||||
></v-sheet>
|
||||
|
||||
<v-btn
|
||||
block
|
||||
class="ma-2"
|
||||
prepend-icon="mdi-plus"
|
||||
variant="text"
|
||||
@click="showDialog = true"
|
||||
>
|
||||
New Channel
|
||||
</v-btn>
|
||||
|
||||
<v-list density="compact">
|
||||
<v-list v-model:opened="openedCategories" 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"/>
|
||||
<v-list-item
|
||||
:title="item.Category[0].name"
|
||||
v-bind="groupProps"
|
||||
@contextmenu="onCategoryContextMenu($event, item.Category[0])"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<v-list-item
|
||||
@@ -151,8 +178,16 @@ function onChannelContextMenu(event: MouseEvent, channel: any) {
|
||||
</v-navigation-drawer>
|
||||
|
||||
<CreateChannelDialog
|
||||
v-model="showDialog"
|
||||
v-model="showChannelDialog"
|
||||
:category-id="selectedCategoryId"
|
||||
:server-id="serverId"
|
||||
@created="refreshServerTree"
|
||||
/>
|
||||
|
||||
<CreateCategoryDialog
|
||||
v-model="showCategoryDialog"
|
||||
:server-id="serverId"
|
||||
@created="refreshServerTree"
|
||||
/>
|
||||
|
||||
<v-main>
|
||||
|
||||
@@ -11,7 +11,9 @@ interface Category {
|
||||
|
||||
export const useCategoryStore = defineStore("category", {
|
||||
state: () => ({
|
||||
categories: [] as Category[]
|
||||
categories: [] as Category[],
|
||||
loading: false,
|
||||
error: null as string | null
|
||||
}),
|
||||
actions: {
|
||||
async fetchCategories(serverId?: string) {
|
||||
@@ -23,8 +25,32 @@ export const useCategoryStore = defineStore("category", {
|
||||
let response = await api.get(url);
|
||||
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() {
|
||||
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"
|
||||
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:
|
||||
version "5.5.3"
|
||||
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 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 uuid::Uuid;
|
||||
|
||||
@@ -22,16 +25,40 @@ impl CategoryService {
|
||||
let db = &self.service_context.repositories.server.context.db;
|
||||
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 {
|
||||
server_id: Set(server_id),
|
||||
name: Set(name),
|
||||
..Default::default()
|
||||
};
|
||||
let cat = active.insert(&txn).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();
|
||||
|
||||
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());
|
||||
|
||||
@@ -71,10 +98,14 @@ impl CategoryService {
|
||||
|
||||
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)
|
||||
.await?;
|
||||
|
||||
let res = category::Entity::delete_by_id(id).exec(&txn).await?;
|
||||
|
||||
let deleted = res.rows_affected > 0;
|
||||
|
||||
txn.commit().await?;
|
||||
|
||||
Reference in New Issue
Block a user