feat: integrate @tailwindcss/typography and enhance folder picker functionality
- Added @tailwindcss/typography dependency to package.json and tailwind.config.js. - Updated FolderPicker.vue to include additional properties (isGitRepo, hasDocuments) for directories. - Enhanced Sidebar.vue with folder creation and document handling features, including drag-and-drop functionality. - Implemented createFolder and moveDocument mutations in gql.ts for managing folder and document operations. - Added logic to handle folder creation and document creation prompts in Sidebar.vue. - Updated the layout and interactions in Sidebar.vue to improve user experience.
This commit is contained in:
@@ -14,15 +14,15 @@ const emit = defineEmits<{
|
||||
|
||||
const open = ref(false)
|
||||
const currentPath = ref(props.modelValue)
|
||||
const directories = ref<{ name: string; path: string }[]>([])
|
||||
const directories = ref<{ name: string; path: string; isGitRepo: boolean; hasDocuments: boolean }[]>([])
|
||||
const loading = ref(false)
|
||||
|
||||
async function fetchDirectories(path: string) {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await gql<{ serverDirectories: { name: string; path: string }[] }>(
|
||||
`query Dirs($p: String) { serverDirectories(path: $p) { name path } }`,
|
||||
{ p: path || null }
|
||||
const data = await gql<{ serverDirectories: { name: string; path: string; isGitRepo: boolean; hasDocuments: boolean }[] }>(
|
||||
`query Dirs($path: String) { serverDirectories(path: $path) { name path isGitRepo hasDocuments } }`,
|
||||
{ path: path || null }
|
||||
)
|
||||
directories.value = data.serverDirectories
|
||||
// Update input box to the fetched path if we passed empty and it resolved to root
|
||||
@@ -109,8 +109,10 @@ watch(() => props.modelValue, (val) => {
|
||||
class="w-fulltext-left flex items-center gap-2 px-3 py-2 w-full hover:bg-accent-50 dark:hover:bg-accent-500/10 focus:bg-accent-50 focus:outline-none transition group"
|
||||
@click="navigate(dir.path)"
|
||||
>
|
||||
<svg class="w-4 h-4 text-accent-500 group-hover:text-accent-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"/></svg>
|
||||
<svg class="w-4 h-4 text-accent-500 group-hover:text-accent-600 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"/></svg>
|
||||
<span class="text-sm font-medium text-slate-700 dark:text-slate-300 truncate">{{ dir.name }}</span>
|
||||
<span v-if="dir.hasDocuments" class="ml-auto text-[10px] font-bold px-1.5 py-0.5 rounded bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400 shrink-0">docs</span>
|
||||
<span v-if="dir.isGitRepo" :class="dir.hasDocuments ? 'ml-1' : 'ml-auto'" class="text-[10px] font-bold px-1.5 py-0.5 rounded bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400 shrink-0">git</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted, computed } from 'vue'
|
||||
import { ref, reactive, onMounted, computed, watch } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { gql } from '@/lib/gql'
|
||||
import { gql, createFolder, moveDocument } from '@/lib/gql'
|
||||
|
||||
const emit = defineEmits<{ close: [] }>()
|
||||
|
||||
@@ -23,9 +23,32 @@ const loading = ref(true)
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const openFolders = reactive<Record<string, boolean>>({})
|
||||
const allFolders = ref<string[]>([])
|
||||
const draggedItem = ref<{ type: 'folder' | 'doc'; path: string } | null>(null)
|
||||
const dropTarget = ref<string | null>(null)
|
||||
const showCreateMenu = reactive<Record<string, boolean>>({})
|
||||
|
||||
function buildTree(docs: DocMeta[]): TreeFolder {
|
||||
watch(() => route.fullPath, () => {
|
||||
for (const key in showCreateMenu) {
|
||||
showCreateMenu[key] = false
|
||||
}
|
||||
})
|
||||
|
||||
function buildTree(docs: DocMeta[], folders: string[]): TreeFolder {
|
||||
const root: TreeFolder = { name: '', path: '', folders: [], docs: [] }
|
||||
for (const f of folders) {
|
||||
const parts = f.split('/')
|
||||
let node = root
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
const folderPath = parts.slice(0, i + 1).join('/')
|
||||
let child = node.folders.find(c => c.path === folderPath)
|
||||
if (!child) {
|
||||
child = { name: parts[i], path: folderPath, folders: [], docs: [] }
|
||||
node.folders.push(child)
|
||||
}
|
||||
node = child
|
||||
}
|
||||
}
|
||||
for (const doc of docs) {
|
||||
const parts = doc.slug.split('/')
|
||||
if (parts.length === 1) {
|
||||
@@ -47,11 +70,96 @@ function buildTree(docs: DocMeta[]): TreeFolder {
|
||||
return root
|
||||
}
|
||||
|
||||
async function handleCreateRootFolder() {
|
||||
const name = prompt('Enter folder name:')
|
||||
if (!name?.trim()) return
|
||||
try {
|
||||
await createFolder(name)
|
||||
const fData = await gql<{ folders?: string[] }>(`{ folders }`)
|
||||
allFolders.value = fData.folders ?? []
|
||||
} catch (err) {
|
||||
console.error('Failed to create folder:', err)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreateFolder(parentPath: string) {
|
||||
const name = prompt('Enter folder name:')
|
||||
if (!name?.trim()) return
|
||||
try {
|
||||
const newPath = parentPath ? `${parentPath}/${name}` : name
|
||||
await createFolder(newPath)
|
||||
const fData = await gql<{ folders?: string[] }>(`{ folders }`)
|
||||
allFolders.value = fData.folders ?? []
|
||||
openFolders[parentPath] = true
|
||||
} catch (err) {
|
||||
console.error('Failed to create folder:', err)
|
||||
}
|
||||
}
|
||||
|
||||
function handleCreateDoc(parentPath: string) {
|
||||
const title = prompt('Enter document title:')
|
||||
if (!title?.trim()) return
|
||||
const slug = parentPath ? `${parentPath}/${title.toLowerCase().replace(/\\s+/g, '-')}` : title.toLowerCase().replace(/\\s+/g, '-')
|
||||
|
||||
// store intended initial slug for the /doc/new page to pick up via state or similar
|
||||
sessionStorage.setItem('newDocSlug', slug)
|
||||
router.push('/doc/new')
|
||||
emit('close')
|
||||
}
|
||||
|
||||
function handleDragStart(e: DragEvent, item: FlatItem) {
|
||||
if (item.type === 'folder') {
|
||||
draggedItem.value = { type: 'folder', path: item.path }
|
||||
} else {
|
||||
draggedItem.value = { type: 'doc', path: item.slug }
|
||||
}
|
||||
e.dataTransfer!.effectAllowed = 'move'
|
||||
}
|
||||
|
||||
function handleDragOver(e: DragEvent, targetPath: string) {
|
||||
e.preventDefault()
|
||||
e.dataTransfer!.dropEffect = 'move'
|
||||
dropTarget.value = targetPath
|
||||
}
|
||||
|
||||
function handleDragLeave() {
|
||||
dropTarget.value = null
|
||||
}
|
||||
|
||||
async function handleDrop(e: DragEvent, targetPath: string) {
|
||||
e.preventDefault()
|
||||
if (!draggedItem.value) return
|
||||
|
||||
try {
|
||||
if (draggedItem.value.type === 'doc') {
|
||||
const parts = draggedItem.value.path.split('/')
|
||||
const docName = parts.pop()
|
||||
const newSlug = targetPath ? `${targetPath}/${docName}` : docName!
|
||||
|
||||
await moveDocument(draggedItem.value.path, newSlug)
|
||||
|
||||
const data = await gql<{ documents?: DocMeta[] }>(`{ documents { slug title } }`)
|
||||
allDocs.value = data.documents ?? []
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to move item:', err)
|
||||
} finally {
|
||||
draggedItem.value = null
|
||||
dropTarget.value = null
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const data = await gql<{ documents?: DocMeta[] }>(`{ documents { slug title } }`)
|
||||
allDocs.value = data.documents ?? []
|
||||
const fData = await gql<{ folders?: string[] }>(`{ folders }`)
|
||||
allFolders.value = fData.folders ?? []
|
||||
|
||||
// expand all folders by default
|
||||
for (const f of allFolders.value) {
|
||||
openFolders[f] = true
|
||||
}
|
||||
for (const doc of allDocs.value) {
|
||||
const parts = doc.slug.split('/')
|
||||
for (let i = 1; i < parts.length; i++) {
|
||||
@@ -60,6 +168,7 @@ onMounted(async () => {
|
||||
}
|
||||
} catch {
|
||||
allDocs.value = []
|
||||
allFolders.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
@@ -67,7 +176,7 @@ onMounted(async () => {
|
||||
|
||||
const flatItems = computed<FlatItem[]>(() => {
|
||||
const items: FlatItem[] = []
|
||||
const tree = buildTree(allDocs.value)
|
||||
const tree = buildTree(allDocs.value, allFolders.value)
|
||||
|
||||
function traverse(node: TreeFolder, depth: number) {
|
||||
for (const folder of node.folders) {
|
||||
@@ -90,6 +199,12 @@ function toggleFolder(path: string) {
|
||||
openFolders[path] = !openFolders[path]
|
||||
}
|
||||
|
||||
function closeAllMenus() {
|
||||
for (const key in showCreateMenu) {
|
||||
showCreateMenu[key] = false
|
||||
}
|
||||
}
|
||||
|
||||
function navigate(slug: string) {
|
||||
router.push(`/doc/${slug}`)
|
||||
emit('close')
|
||||
@@ -104,7 +219,7 @@ function isActive(slug: string) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col h-full select-none">
|
||||
<div class="flex flex-col h-full select-none" @click="closeAllMenus">
|
||||
|
||||
<!-- Logo / header -->
|
||||
<div class="flex items-center justify-between px-4 py-4 border-b border-slate-200 dark:border-slate-700/60">
|
||||
@@ -154,35 +269,73 @@ function isActive(slug: string) {
|
||||
<template v-else>
|
||||
<template v-for="item in flatItems" :key="item.type === 'folder' ? 'f:' + item.path : 'd:' + item.slug">
|
||||
<!-- Folder row -->
|
||||
<button
|
||||
<div
|
||||
v-if="item.type === 'folder'"
|
||||
:style="{ paddingLeft: `${0.5 + item.depth * 1}rem` }"
|
||||
class="w-full flex items-center gap-1.5 pr-2 py-1.5 rounded-lg text-sm text-slate-600 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-700/60 transition"
|
||||
@click="toggleFolder(item.path)"
|
||||
class="flex items-center gap-1 group relative transition-colors duration-200"
|
||||
:class="dropTarget === item.path ? 'bg-accent-500/20 dark:bg-accent-500/30 ring-1 ring-accent-500' : ''"
|
||||
@dragover="handleDragOver($event, item.path)"
|
||||
@dragleave="handleDragLeave"
|
||||
@drop="handleDrop($event, item.path)"
|
||||
draggable="true"
|
||||
@dragstart="handleDragStart($event, item)"
|
||||
>
|
||||
<!-- chevron -->
|
||||
<svg
|
||||
class="w-3.5 h-3.5 flex-shrink-0 transition-transform"
|
||||
:class="openFolders[item.path] ? 'rotate-90' : ''"
|
||||
fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
||||
<button
|
||||
class="flex-1 flex items-center gap-1.5 pr-2 py-1.5 rounded-lg text-sm text-slate-600 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-700/60 transition"
|
||||
@click="toggleFolder(item.path)"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
|
||||
</svg>
|
||||
<!-- folder icon -->
|
||||
<svg class="w-4 h-4 flex-shrink-0 text-amber-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M3 7a2 2 0 012-2h4l2 2h8a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V7z"/>
|
||||
</svg>
|
||||
<span class="truncate font-medium">{{ item.name }}</span>
|
||||
</button>
|
||||
<!-- chevron -->
|
||||
<svg
|
||||
class="w-3.5 h-3.5 flex-shrink-0 transition-transform"
|
||||
:class="openFolders[item.path] ? 'rotate-90' : ''"
|
||||
fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
|
||||
</svg>
|
||||
<!-- folder icon -->
|
||||
<svg class="w-4 h-4 flex-shrink-0 text-amber-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M3 7a2 2 0 012-2h4l2 2h8a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V7z"/>
|
||||
</svg>
|
||||
<span class="truncate font-medium">{{ item.name }}</span>
|
||||
</button>
|
||||
<button
|
||||
class="opacity-0 group-hover:opacity-100 transition-opacity p-1 mr-1 rounded hover:bg-slate-200 dark:hover:bg-slate-600"
|
||||
@click.prevent.stop="showCreateMenu[item.path] = !showCreateMenu[item.path]"
|
||||
title="Add into folder"
|
||||
>
|
||||
<svg class="w-4 h-4 text-slate-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
|
||||
</svg>
|
||||
</button>
|
||||
<div
|
||||
v-if="showCreateMenu[item.path]"
|
||||
class="absolute right-0 top-full mt-1 w-32 bg-white dark:bg-slate-800 rounded-lg shadow-lg z-50 border border-slate-200 dark:border-slate-700"
|
||||
>
|
||||
<button
|
||||
@click.stop="handleCreateFolder(item.path); showCreateMenu[item.path] = false"
|
||||
class="w-full text-left px-3 py-2 text-sm hover:bg-slate-100 dark:hover:bg-slate-700 rounded-t-lg"
|
||||
>
|
||||
+ Folder
|
||||
</button>
|
||||
<button
|
||||
@click.stop="handleCreateDoc(item.path); showCreateMenu[item.path] = false"
|
||||
class="w-full text-left px-3 py-2 text-sm hover:bg-slate-100 dark:hover:bg-slate-700 rounded-b-lg"
|
||||
>
|
||||
+ Document
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Document row -->
|
||||
<button
|
||||
v-else
|
||||
draggable="true"
|
||||
@dragstart="handleDragStart($event, item)"
|
||||
:style="{ paddingLeft: `${0.5 + item.depth * 1}rem` }"
|
||||
:class="[
|
||||
'w-full flex items-center gap-1.5 pr-2 py-1.5 rounded-lg text-sm truncate transition',
|
||||
isActive(item.slug)
|
||||
isActive((item as any).slug)
|
||||
? 'bg-accent-500/10 text-accent-600 dark:text-accent-400 font-medium'
|
||||
: 'text-slate-700 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-700/60'
|
||||
]"
|
||||
@@ -201,9 +354,19 @@ function isActive(slug: string) {
|
||||
|
||||
<!-- Bottom actions -->
|
||||
<div class="p-3 border-t border-slate-200 dark:border-slate-700/60 flex flex-col gap-2">
|
||||
<button
|
||||
class="w-full btn-secondary text-center text-sm py-1.5 flex items-center justify-center gap-1.5"
|
||||
@click="handleCreateRootFolder"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
|
||||
</svg>
|
||||
New folder
|
||||
</button>
|
||||
|
||||
<router-link
|
||||
to="/doc/new"
|
||||
class="btn-primary w-full text-center text-sm py-2"
|
||||
class="btn-primary w-full text-center text-sm py-2 flex items-center justify-center gap-1.5"
|
||||
@click="emit('close')"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
|
||||
@@ -28,4 +28,19 @@ export async function gql<T = unknown>(
|
||||
return json.data as T
|
||||
}
|
||||
|
||||
export async function createFolder(path: string): Promise<{ createFolder: boolean }> {
|
||||
const query = `mutation CreateFolder($path: String!) { createFolder(path: $path) }`
|
||||
return gql(query, { path })
|
||||
}
|
||||
|
||||
export async function moveDocument(oldSlug: string, newSlug: string): Promise<{ moveDocument: boolean }> {
|
||||
const query = `mutation MoveDocument($oldSlug: String!, $newSlug: String!) { moveDocument(oldSlug: $oldSlug, newSlug: $newSlug) }`
|
||||
return gql(query, { oldSlug, newSlug })
|
||||
}
|
||||
|
||||
export async function getFolders(): Promise<{ folders: string[] }> {
|
||||
const query = `query GetFolders { folders }`
|
||||
return gql(query)
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user