2026-08-11 23:17:04 +07:00
|
|
|
<template>
|
2026-08-24 08:07:23 +07:00
|
|
|
<div class="h-full min-h-[420px]">
|
2026-08-11 23:17:04 +07:00
|
|
|
<DocumentTreePanel
|
|
|
|
|
:tree="tree"
|
|
|
|
|
:loading="loading"
|
|
|
|
|
:selected-id="selectedId"
|
|
|
|
|
v-model:search-term="searchTerm"
|
|
|
|
|
:creating-label="creatingLabel"
|
2026-08-24 08:07:23 +07:00
|
|
|
:can-create="canCreate"
|
|
|
|
|
:members="members"
|
2026-08-11 23:17:04 +07:00
|
|
|
@select="selectDocument"
|
|
|
|
|
@create="openCreate"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
|
2026-08-24 08:07:23 +07:00
|
|
|
<DocumentViewerModal
|
|
|
|
|
:visible="viewerVisible"
|
|
|
|
|
:doc="doc"
|
|
|
|
|
:model-value="contentModel"
|
|
|
|
|
:editing="editing"
|
|
|
|
|
:save-state="saveState"
|
|
|
|
|
:can-edit="canEdit"
|
|
|
|
|
:can-delete="canDelete"
|
|
|
|
|
@update:visible="onViewerClose"
|
|
|
|
|
@update:model-value="onContentChange"
|
|
|
|
|
@edit="startEdit"
|
|
|
|
|
@cancel-edit="cancelEdit"
|
|
|
|
|
@save="onSave"
|
|
|
|
|
@rename="openRename"
|
|
|
|
|
@move="openMove"
|
|
|
|
|
@delete="confirmDelete"
|
|
|
|
|
/>
|
|
|
|
|
|
2026-08-26 00:11:45 +07:00
|
|
|
<Dialog v-model:visible="createDialog" :header="`New ${createType}`" :modal="true" style="width: min(480px, 92vw)">
|
2026-08-11 23:17:04 +07:00
|
|
|
<div class="field">
|
2026-08-26 00:11:45 +07:00
|
|
|
<label>Path</label>
|
|
|
|
|
<InputText v-model.trim="createPath" class="w-full font-mono" placeholder="/folder/file.md" @keyup.enter="onCreate" />
|
|
|
|
|
<small class="muted-note">Folders in the path will be created if they don't exist.</small>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="field">
|
|
|
|
|
<label>Name</label>
|
2026-08-11 23:17:04 +07:00
|
|
|
<InputText v-model.trim="createTitle" class="w-full" autofocus @keyup.enter="onCreate" />
|
|
|
|
|
</div>
|
|
|
|
|
<template #footer>
|
|
|
|
|
<Button label="Cancel" severity="secondary" text @click="createDialog = false" />
|
2026-08-26 00:11:45 +07:00
|
|
|
<Button label="Create" :loading="creating" @click="onCreate" />
|
2026-08-11 23:17:04 +07:00
|
|
|
</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'
|
2026-08-24 08:07:23 +07:00
|
|
|
import DocumentViewerModal from '../../components/DocumentViewerModal.vue'
|
2026-08-11 23:17:04 +07:00
|
|
|
import {
|
|
|
|
|
getDocumentTree,
|
|
|
|
|
getDocument,
|
|
|
|
|
createDocument,
|
|
|
|
|
updateDocument,
|
|
|
|
|
moveDocument,
|
|
|
|
|
deleteDocument,
|
|
|
|
|
searchDocuments,
|
|
|
|
|
} from '../../services/modules'
|
2026-08-24 08:07:23 +07:00
|
|
|
import { getMembers } from '../../services/backend'
|
2026-08-11 23:17:04 +07:00
|
|
|
import { errorMessage } from '../../services/api'
|
|
|
|
|
import { useAuthStore } from '../../stores/auth'
|
2026-08-24 08:07:23 +07:00
|
|
|
import type { DocumentItem, DocumentNode, DocumentType, ProjectMember } from '../../types'
|
2026-08-11 23:17:04 +07:00
|
|
|
|
|
|
|
|
const route = useRoute()
|
|
|
|
|
const toast = useToast()
|
|
|
|
|
const confirm = useConfirm()
|
|
|
|
|
const auth = useAuthStore()
|
|
|
|
|
|
2026-09-12 19:38:11 +07:00
|
|
|
const projectId = ref(String(route.params.id))
|
2026-08-11 23:17:04 +07:00
|
|
|
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')
|
2026-08-24 08:07:23 +07:00
|
|
|
const editing = ref(false)
|
|
|
|
|
const viewerVisible = ref(false)
|
|
|
|
|
|
|
|
|
|
const members = ref<ProjectMember[]>([])
|
2026-08-11 23:17:04 +07:00
|
|
|
|
|
|
|
|
const searchTerm = ref('')
|
|
|
|
|
const searchMode = ref(false)
|
|
|
|
|
|
|
|
|
|
const createDialog = ref(false)
|
|
|
|
|
const createType = ref<DocumentType>('Document')
|
|
|
|
|
const createTitle = ref('')
|
2026-08-26 00:11:45 +07:00
|
|
|
const createPath = ref('/')
|
|
|
|
|
const creating = ref(false)
|
2026-08-11 23:17:04 +07:00
|
|
|
const renameDialog = ref(false)
|
|
|
|
|
const renameTitle = ref('')
|
|
|
|
|
const moveDialog = ref(false)
|
|
|
|
|
const moveTarget = ref<string | null>(null)
|
|
|
|
|
|
|
|
|
|
let searchTimer: ReturnType<typeof setTimeout> | undefined
|
|
|
|
|
|
2026-08-24 08:07:23 +07:00
|
|
|
const currentMember = computed(() => members.value.find((m) => m.userId === auth.user?.id))
|
|
|
|
|
const canCreate = computed(() => currentMember.value?.canCreateDocuments ?? false)
|
|
|
|
|
const canEdit = computed(() => currentMember.value?.canEditDocuments ?? false)
|
|
|
|
|
const canDelete = computed(() => currentMember.value?.canDeleteDocuments ?? false)
|
|
|
|
|
|
2026-08-11 23:17:04 +07:00
|
|
|
const creatingLabel = computed(() =>
|
2026-08-24 08:07:23 +07:00
|
|
|
doc.value?.type === 'Folder' ? `New items go inside "${doc.value.title}"` : 'New items are created at the root',
|
2026-08-11 23:17:04 +07:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
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 {
|
2026-09-12 19:38:11 +07:00
|
|
|
tree.value = searchMode.value ? await searchDocuments(searchTerm.value) : await getDocumentTree(projectId.value)
|
2026-08-11 23:17:04 +07:00
|
|
|
} catch (e) {
|
|
|
|
|
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
|
2026-08-24 08:07:23 +07:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function loadMembers() {
|
|
|
|
|
try {
|
2026-09-12 19:38:11 +07:00
|
|
|
const res = await getMembers(projectId.value, 1, 100)
|
2026-08-24 08:07:23 +07:00
|
|
|
members.value = res.items
|
|
|
|
|
} catch (e) {
|
|
|
|
|
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
|
2026-08-11 23:17:04 +07:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
watch(searchTerm, () => {
|
|
|
|
|
clearTimeout(searchTimer)
|
|
|
|
|
searchTimer = setTimeout(async () => {
|
|
|
|
|
searchMode.value = !!searchTerm.value
|
|
|
|
|
await loadTree()
|
|
|
|
|
}, 300)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
async function selectDocument(id: string) {
|
|
|
|
|
selectedId.value = id
|
|
|
|
|
try {
|
|
|
|
|
doc.value = await getDocument(id)
|
|
|
|
|
contentModel.value = doc.value.content ?? ''
|
2026-08-24 08:07:23 +07:00
|
|
|
editing.value = false
|
2026-08-11 23:17:04 +07:00
|
|
|
saveState.value = 'idle'
|
2026-08-24 08:07:23 +07:00
|
|
|
viewerVisible.value = true
|
2026-08-11 23:17:04 +07:00
|
|
|
} catch (e) {
|
|
|
|
|
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-24 08:07:23 +07:00
|
|
|
function onViewerClose(value: boolean) {
|
|
|
|
|
viewerVisible.value = value
|
|
|
|
|
if (!value) {
|
|
|
|
|
editing.value = false
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function startEdit() {
|
|
|
|
|
editing.value = true
|
|
|
|
|
saveState.value = 'idle'
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function cancelEdit() {
|
|
|
|
|
editing.value = false
|
|
|
|
|
contentModel.value = doc.value?.content ?? ''
|
|
|
|
|
saveState.value = 'idle'
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function onContentChange(value: string) {
|
|
|
|
|
contentModel.value = value
|
|
|
|
|
saveState.value = 'idle'
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function onSave() {
|
|
|
|
|
if (!doc.value) return
|
|
|
|
|
saveState.value = 'saving'
|
|
|
|
|
try {
|
|
|
|
|
const updated = await updateDocument(doc.value.id, { title: doc.value.title, content: contentModel.value })
|
|
|
|
|
doc.value = updated
|
|
|
|
|
contentModel.value = updated.content ?? ''
|
|
|
|
|
editing.value = false
|
|
|
|
|
saveState.value = 'saved'
|
|
|
|
|
toast.add({ severity: 'success', summary: 'Saved', life: 2000 })
|
|
|
|
|
await loadTree()
|
|
|
|
|
} catch (e) {
|
|
|
|
|
saveState.value = 'idle'
|
|
|
|
|
toast.add({ severity: 'error', summary: 'Save failed', detail: errorMessage(e), life: 4000 })
|
|
|
|
|
}
|
2026-08-11 23:17:04 +07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function openCreate(type: DocumentType) {
|
|
|
|
|
createType.value = type
|
|
|
|
|
createTitle.value = ''
|
2026-08-26 00:11:45 +07:00
|
|
|
const currentFolder = doc.value?.type === 'Folder' ? doc.value.title : ''
|
|
|
|
|
createPath.value = currentFolder ? `/${currentFolder}/` : '/'
|
2026-08-11 23:17:04 +07:00
|
|
|
createDialog.value = true
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-26 00:11:45 +07:00
|
|
|
function findFolderByPath(segments: string[]): string | null {
|
|
|
|
|
let nodes = tree.value
|
|
|
|
|
let parentId: string | null = null
|
|
|
|
|
for (const seg of segments) {
|
|
|
|
|
const folder = nodes.find((n) => n.type === 'Folder' && n.title.toLowerCase() === seg.toLowerCase())
|
|
|
|
|
if (!folder) return parentId
|
|
|
|
|
parentId = folder.id
|
|
|
|
|
nodes = folder.children
|
|
|
|
|
}
|
|
|
|
|
return parentId
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-11 23:17:04 +07:00
|
|
|
async function onCreate() {
|
|
|
|
|
if (!createTitle.value) {
|
|
|
|
|
toast.add({ severity: 'warn', summary: 'Title is required', life: 3000 })
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-08-26 00:11:45 +07:00
|
|
|
creating.value = true
|
2026-08-11 23:17:04 +07:00
|
|
|
try {
|
2026-08-26 00:11:45 +07:00
|
|
|
const rawPath = createPath.value.trim()
|
|
|
|
|
const segments = rawPath.split('/').filter(Boolean)
|
|
|
|
|
let parentId = findFolderByPath(segments)
|
|
|
|
|
|
|
|
|
|
// Create missing folders from path
|
|
|
|
|
let nodes = tree.value
|
|
|
|
|
for (const seg of segments) {
|
|
|
|
|
const existing = nodes.find((n) => n.type === 'Folder' && n.title.toLowerCase() === seg.toLowerCase())
|
|
|
|
|
if (existing) {
|
|
|
|
|
nodes = existing.children
|
|
|
|
|
} else {
|
2026-09-12 19:38:11 +07:00
|
|
|
const created = await createDocument(projectId.value, {
|
2026-08-26 00:11:45 +07:00
|
|
|
title: seg,
|
|
|
|
|
type: 'Folder',
|
|
|
|
|
parentId,
|
|
|
|
|
content: null,
|
|
|
|
|
})
|
|
|
|
|
parentId = created.id
|
|
|
|
|
nodes = []
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-12 19:38:11 +07:00
|
|
|
const created = await createDocument(projectId.value, {
|
2026-08-11 23:17:04 +07:00
|
|
|
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 })
|
2026-08-26 00:11:45 +07:00
|
|
|
} finally {
|
|
|
|
|
creating.value = false
|
2026-08-11 23:17:04 +07:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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',
|
2026-08-24 08:07:23 +07:00
|
|
|
acceptProps: { severity: 'danger' },
|
|
|
|
|
rejectProps: { severity: 'secondary', outlined: true },
|
2026-08-11 23:17:04 +07:00
|
|
|
accept: async () => {
|
|
|
|
|
try {
|
|
|
|
|
await deleteDocument(doc.value!.id)
|
|
|
|
|
doc.value = null
|
|
|
|
|
selectedId.value = null
|
2026-08-24 08:07:23 +07:00
|
|
|
viewerVisible.value = false
|
2026-08-11 23:17:04 +07:00
|
|
|
toast.add({ severity: 'success', summary: 'Deleted', life: 2000 })
|
|
|
|
|
await loadTree()
|
|
|
|
|
} catch (e) {
|
|
|
|
|
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
onMounted(() => {
|
|
|
|
|
loading.value = true
|
2026-08-24 08:07:23 +07:00
|
|
|
Promise.all([loadTree(), loadMembers()]).finally(() => {
|
|
|
|
|
loading.value = false
|
|
|
|
|
})
|
2026-08-11 23:17:04 +07:00
|
|
|
})
|
2026-09-12 19:38:11 +07:00
|
|
|
|
|
|
|
|
watch(
|
|
|
|
|
() => route.params.id,
|
|
|
|
|
async (id) => {
|
|
|
|
|
if (!id) return
|
|
|
|
|
projectId.value = String(id)
|
|
|
|
|
selectedId.value = null
|
|
|
|
|
doc.value = null
|
|
|
|
|
viewerVisible.value = false
|
|
|
|
|
editing.value = false
|
|
|
|
|
searchTerm.value = ''
|
|
|
|
|
searchMode.value = false
|
|
|
|
|
loading.value = true
|
|
|
|
|
try {
|
|
|
|
|
await Promise.all([loadTree(), loadMembers()])
|
|
|
|
|
} finally {
|
|
|
|
|
loading.value = false
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
)
|
2026-08-24 08:07:23 +07:00
|
|
|
</script>
|