Files
mws.frontend.vue/src/views/documents/DocumentsView.vue
T

314 lines
8.8 KiB
Vue
Raw Normal View History

2026-08-11 23:17:04 +07:00
<template>
<Splitter v-if="isDesktop" class="h-[calc(100vh-170px)]">
<SplitterPanel :size="30" :minSize="20">
<DocumentTreePanel
:tree="tree"
:loading="loading"
:selected-id="selectedId"
v-model:search-term="searchTerm"
:creating-label="creatingLabel"
:can-create="auth.can('documents', 'create')"
@select="selectDocument"
@create="openCreate"
/>
</SplitterPanel>
<SplitterPanel>
<DocumentEditorPanel
:doc="doc"
:model-value="contentModel"
:save-state="saveState"
:can-edit="auth.can('documents', 'edit')"
:can-delete="auth.can('documents', 'delete')"
@update:model-value="onContentChange"
@rename="openRename"
@move="openMove"
@delete="confirmDelete"
/>
</SplitterPanel>
</Splitter>
<div v-else>
<DocumentTreePanel
v-if="!doc"
:tree="tree"
:loading="loading"
:selected-id="selectedId"
v-model:search-term="searchTerm"
:creating-label="creatingLabel"
:can-create="auth.can('documents', 'create')"
@select="selectDocument"
@create="openCreate"
/>
<DocumentEditorPanel
v-else
:doc="doc"
:model-value="contentModel"
:save-state="saveState"
:can-edit="auth.can('documents', 'edit')"
:can-delete="auth.can('documents', 'delete')"
@update:model-value="onContentChange"
@rename="openRename"
@move="openMove"
@delete="confirmDelete"
@back="closeDocument"
/>
</div>
<Dialog v-model:visible="createDialog" :header="`New ${createType}`" :modal="true" style="width: min(420px, 92vw)">
<div class="field">
<label>Title</label>
<InputText v-model.trim="createTitle" class="w-full" autofocus @keyup.enter="onCreate" />
</div>
<template #footer>
<Button label="Cancel" severity="secondary" text @click="createDialog = false" />
<Button label="Create" @click="onCreate" />
</template>
</Dialog>
<Dialog v-model:visible="renameDialog" header="Rename" :modal="true" style="width: min(420px, 92vw)">
<div class="field">
<label>Title</label>
<InputText v-model.trim="renameTitle" class="w-full" @keyup.enter="onRename" />
</div>
<template #footer>
<Button label="Cancel" severity="secondary" text @click="renameDialog = false" />
<Button label="Save" @click="onRename" />
</template>
</Dialog>
<Dialog v-model:visible="moveDialog" header="Move to Folder" :modal="true" style="width: min(440px, 92vw)">
<Select
v-model="moveTarget"
:options="folderOptions"
optionLabel="label"
optionValue="value"
placeholder="Root"
class="w-full"
/>
<template #footer>
<Button label="Cancel" severity="secondary" text @click="moveDialog = false" />
<Button label="Move" @click="onMove" />
</template>
</Dialog>
</template>
<script setup lang="ts">
import DocumentTreePanel from '../../components/DocumentTreePanel.vue'
import DocumentEditorPanel from '../../components/DocumentEditorPanel.vue'
import {
getDocumentTree,
getDocument,
createDocument,
updateDocument,
moveDocument,
deleteDocument,
searchDocuments,
} from '../../services/modules'
import { errorMessage } from '../../services/api'
import { useAuthStore } from '../../stores/auth'
import type { DocumentItem, DocumentNode, DocumentType } from '../../types'
const route = useRoute()
const toast = useToast()
const confirm = useConfirm()
const auth = useAuthStore()
const projectId = String(route.params.id)
const tree = ref<DocumentNode[]>([])
const loading = ref(false)
const selectedId = ref<string | null>(null)
const doc = ref<DocumentItem | null>(null)
const contentModel = ref('')
const saveState = ref<'idle' | 'saving' | 'saved'>('idle')
const searchTerm = ref('')
const searchMode = ref(false)
const createDialog = ref(false)
const createType = ref<DocumentType>('Document')
const createTitle = ref('')
const renameDialog = ref(false)
const renameTitle = ref('')
const moveDialog = ref(false)
const moveTarget = ref<string | null>(null)
const isDesktop = ref(false)
let mediaQuery: MediaQueryList | null = null
let saveTimer: ReturnType<typeof setTimeout> | undefined
let searchTimer: ReturnType<typeof setTimeout> | undefined
const creatingLabel = computed(() =>
doc.value?.type === 'Folder' ? `New items go inside: ${doc.value.title}` : 'New items are created at root',
)
const folderOptions = computed(() => {
const folders: { label: string; value: string }[] = []
function walk(nodes: DocumentNode[], prefix: string) {
for (const node of nodes) {
if (node.type === 'Folder') {
const label = prefix + node.title
folders.push({ label, value: node.id })
walk(node.children, label + '/')
}
}
}
walk(tree.value, '')
return folders
})
async function loadTree() {
try {
tree.value = searchMode.value ? await searchDocuments(searchTerm.value) : await getDocumentTree(projectId)
} catch (e) {
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
} finally {
loading.value = false
}
}
watch(searchTerm, () => {
clearTimeout(searchTimer)
searchTimer = setTimeout(async () => {
searchMode.value = !!searchTerm.value
await loadTree()
}, 300)
})
async function selectDocument(id: string) {
selectedId.value = id
clearTimeout(saveTimer)
try {
doc.value = await getDocument(id)
contentModel.value = doc.value.content ?? ''
saveState.value = 'idle'
} catch (e) {
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
}
}
function closeDocument() {
doc.value = null
selectedId.value = null
}
function openCreate(type: DocumentType) {
createType.value = type
createTitle.value = ''
createDialog.value = true
}
async function onCreate() {
if (!createTitle.value) {
toast.add({ severity: 'warn', summary: 'Title is required', life: 3000 })
return
}
const parentId = doc.value?.type === 'Folder' ? doc.value.id : null
try {
const created = await createDocument(projectId, {
title: createTitle.value,
type: createType.value,
parentId,
content: createType.value === 'Document' ? '' : null,
})
createDialog.value = false
await loadTree()
if (created.type === 'Document') {
await selectDocument(created.id)
}
} catch (e) {
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
}
}
function openRename() {
if (doc.value) {
renameTitle.value = doc.value.title
renameDialog.value = true
}
}
async function onRename() {
if (!doc.value || !renameTitle.value) return
try {
const updated = await updateDocument(doc.value.id, { title: renameTitle.value, content: doc.value.content })
doc.value = updated
renameDialog.value = false
toast.add({ severity: 'success', summary: 'Renamed', life: 2000 })
await loadTree()
} catch (e) {
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
}
}
function openMove() {
moveTarget.value = doc.value?.parentId ?? null
moveDialog.value = true
}
async function onMove() {
if (!doc.value) return
try {
await moveDocument(doc.value.id, moveTarget.value)
moveDialog.value = false
toast.add({ severity: 'success', summary: 'Moved', life: 2000 })
await loadTree()
} catch (e) {
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
}
}
function confirmDelete() {
if (!doc.value) return
confirm.require({
message: `Delete "${doc.value.title}"?`,
header: 'Delete',
accept: async () => {
try {
await deleteDocument(doc.value!.id)
doc.value = null
selectedId.value = null
toast.add({ severity: 'success', summary: 'Deleted', life: 2000 })
await loadTree()
} catch (e) {
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
}
},
})
}
function onContentChange(value: string) {
if (!doc.value) return
contentModel.value = value
saveState.value = 'saving'
clearTimeout(saveTimer)
saveTimer = setTimeout(async () => {
try {
const updated = await updateDocument(doc.value!.id, { title: doc.value!.title, content: contentModel.value })
doc.value = updated
saveState.value = 'saved'
} catch (e) {
saveState.value = 'idle'
toast.add({ severity: 'error', summary: 'Save failed', detail: errorMessage(e), life: 5000 })
}
}, 800)
}
function syncDesktop() {
isDesktop.value = mediaQuery?.matches ?? false
}
onMounted(() => {
mediaQuery = window.matchMedia('(min-width: 1024px)')
syncDesktop()
mediaQuery.addEventListener('change', syncDesktop)
loading.value = true
void loadTree()
})
onUnmounted(() => {
mediaQuery?.removeEventListener('change', syncDesktop)
})
</script>