feat: add LinkDialog and CreateItemDialog components; enhance editor with footnote and include functionalities

This commit is contained in:
Björn Blomberg
2026-04-14 09:18:49 +02:00
parent e89d2c87f0
commit 6a81de8bac
6 changed files with 601 additions and 67 deletions

View File

@@ -7,7 +7,7 @@ import { Table } from '@tiptap/extension-table'
import { TableRow } from '@tiptap/extension-table-row'
import { TableCell } from '@tiptap/extension-table-cell'
import { TableHeader } from '@tiptap/extension-table-header'
import { Admonition, CustomCodeBlock, AsciidocImage } from './asciidoc-extensions'
import { Admonition, CustomCodeBlock, AsciidocImage, CustomHeading, Footnote, AsciidocInclude } from './asciidoc-extensions'
const asciidoctor = Asciidoctor()
@@ -18,8 +18,13 @@ export function toTipTap(adoc: string, slug?: string): JSONContent {
return { type: 'doc', content: [{ type: 'paragraph' }] }
}
// Pre-process includes and footnotes before injecting to Tiptap parsing
let preAdoc = adoc.replace(/footnote:\[(.*?)\]/g, '+++<span data-type="footnote" data-content="$1">Fotnot</span>+++')
// Regex matches `include::file[attrs]` keeping context.
preAdoc = preAdoc.replace(/^include::(.*?)\[(.*?)\](?:$|\r?\n)/gm, '++++\n<div data-type="include" data-file="$1" data-attrs="$2"></div>\n++++\n')
// Convert AsciiDoc to HTML, showtitle: false to skip huge document header wrappers
let htmlContent = asciidoctor.convert(adoc, {
let htmlContent = asciidoctor.convert(preAdoc, {
attributes: { showtitle: false },
standalone: false
}) as string
@@ -63,11 +68,14 @@ export function toTipTap(adoc: string, slug?: string): JSONContent {
}
const extensions = [
StarterKit.configure({ codeBlock: false }),
StarterKit.configure({ codeBlock: false, heading: false }),
CustomHeading,
CustomCodeBlock,
Link,
Admonition,
AsciidocImage,
Footnote,
AsciidocInclude,
Table.configure({
resizable: true,
}),
@@ -132,6 +140,9 @@ class TipTapToAsciidoc {
case 'table':
output += this.convertTable(node)
break
case 'asciidocInclude':
output += `include::${node.attrs?.file || ''}[${node.attrs?.attrs || ''}]\n\n`
break
case 'horizontalRule':
output += "'''\n\n"
break
@@ -145,8 +156,16 @@ class TipTapToAsciidoc {
convertHeading(node: JSONContent): string {
const level = (node.attrs?.level as number) || 1
const id = node.attrs?.id as string | undefined
const prefix = '='.repeat(level)
const text = this.renderTextNodes(node.content || [])
// Om rubriken har ett faktiskt ID i Tiptap, lägg till som ett explicit Asciidoc-ankare
if (id) {
// Returnera explicit ankare före rubriken om det finns, fast Asciidoctor renderar ofta _rubrik automatiskt.
return `[[${id}]]\n${prefix} ${text}\n\n`
}
return `${prefix} ${text}\n\n`
}
@@ -239,6 +258,11 @@ class TipTapToAsciidoc {
}
})
}
} else if (t.type === 'footnote') {
textOut += `footnote:[${t.attrs?.content || ''}]`
}
if (t.type === 'text') {
let text = t.text || ''
textOut += text
}
if (t.type === 'hardBreak') {

View File

@@ -1,10 +1,86 @@
import { Node } from '@tiptap/core'
import { Node, mergeAttributes } from '@tiptap/core'
import Heading from '@tiptap/extension-heading'
import { CodeBlockLowlight, CodeBlockLowlightOptions } from '@tiptap/extension-code-block-lowlight'
import { createLowlight, all } from 'lowlight'
import Image from '@tiptap/extension-image'
const lowlight = createLowlight(all)
export const CustomHeading = Heading.extend({
addAttributes() {
return {
...this.parent?.(),
id: {
default: null,
parseHTML: element => element.getAttribute('id'),
renderHTML: attributes => {
if (!attributes.id) {
return {}
}
return { id: attributes.id }
},
},
}
},
})
export const Footnote = Node.create({
name: 'footnote',
group: 'inline',
inline: true,
atom: true,
addAttributes() {
return {
content: { default: '' }
}
},
parseHTML() {
return [
{ tag: 'span[data-type="footnote"]' }
]
},
renderHTML({ HTMLAttributes }: any) {
return ['span', mergeAttributes(HTMLAttributes, {
'data-type': 'footnote',
class: 'inline-flex items-center justify-center px-1.5 mx-0.5 rounded text-[10px] font-bold bg-indigo-100 text-indigo-700 cursor-pointer hover:bg-indigo-200 dark:bg-indigo-900/50 dark:text-indigo-300 dark:hover:bg-indigo-800 transition-colors',
title: HTMLAttributes.content
}), 'Fotnot']
}
})
export const AsciidocInclude = Node.create({
name: 'asciidocInclude',
group: 'block',
atom: true,
addAttributes() {
return {
file: { default: '' },
attrs: { default: '' }
}
},
parseHTML() {
return [
{ tag: 'div[data-type="include"]' }
]
},
renderHTML({ HTMLAttributes }: any) {
return ['div', mergeAttributes(HTMLAttributes, {
'data-type': 'include',
class: 'my-4 p-3 bg-slate-100 dark:bg-slate-800/80 border-l-4 border-indigo-400 dark:border-indigo-600 rounded text-sm font-mono text-slate-600 dark:text-slate-300 flex items-center gap-2 select-none'
}),
['span', {}, '🔗 include::'],
['span', { class: 'font-bold text-indigo-600 dark:text-indigo-400' }, HTMLAttributes.file || ''],
['span', {}, `[${HTMLAttributes.attrs || ''}]`]
]
}
})
export const AsciidocImage = Image.extend({
addAttributes() {
return {
@@ -49,14 +125,14 @@ export const Admonition = Node.create({
return [
{
tag: 'div[data-type="admonition"]',
getAttrs: (el) => {
getAttrs: (el: any) => {
if (typeof el === 'string') return {}
return { type: el.getAttribute('data-admonition-type') || 'note' }
},
},
]
},
renderHTML({ HTMLAttributes }) {
renderHTML({ HTMLAttributes }: any) {
return ['div', { 'data-type': 'admonition', 'data-admonition-type': HTMLAttributes.type, class: `admonition ${HTMLAttributes.type}` }, 0]
},
})

View File

@@ -0,0 +1,133 @@
<script setup lang="ts">
import { ref, watch, onMounted } from 'vue'
import { gql } from '@/lib/gql'
const props = defineProps<{
isOpen: boolean;
initialUrl?: string;
initialText?: string;
}>()
const emit = defineEmits<{
(e: 'close'): void;
(e: 'confirm', data: { url: string; text: string }): void;
}>()
const textInput = ref('')
const urlInput = ref('')
const documents = ref<{slug: string, title: string}[]>([])
const loadingDocs = ref(true)
const searchQuery = ref('')
const LinkType = ref<'url' | 'document' | 'anchor'>('document')
onMounted(async () => {
try {
const data = await gql<{ documents: {slug: string, title: string}[] }>(`{ documents { slug title } }`)
documents.value = data.documents ?? []
} catch (err) {
console.warn('Could not load documents')
} finally {
loadingDocs.value = false
}
})
watch(() => props.isOpen, (open) => {
if (open) {
textInput.value = props.initialText || ''
urlInput.value = props.initialUrl || ''
if (urlInput.value.startsWith('#')) {
LinkType.value = 'anchor'
urlInput.value = urlInput.value.substring(1) // strip #
} else if (urlInput.value.startsWith('http')) {
LinkType.value = 'url'
} else {
LinkType.value = 'document'
}
}
})
import { computed } from 'vue'
const filteredDocs = computed(() => {
return documents.value.filter(d =>
d.title.toLowerCase().includes(searchQuery.value.toLowerCase()) ||
d.slug.toLowerCase().includes(searchQuery.value.toLowerCase())
)
})
function confirm() {
let finalUrl = urlInput.value
if (LinkType.value === 'anchor' && !finalUrl.startsWith('#')) { // just in case
finalUrl = '#' + finalUrl
}
emit('confirm', { url: finalUrl, text: textInput.value })
emit('close')
}
function selectDoc(slug: string) {
urlInput.value = slug
confirm()
}
</script>
<template>
<div v-if="isOpen" class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div class="bg-white dark:bg-slate-800 rounded-lg shadow-xl max-w-sm w-full overflow-hidden">
<!-- Header -->
<div class="p-4 border-b dark:border-slate-700 flex justify-between items-center bg-slate-50 dark:bg-slate-900/50">
<h3 class="font-bold text-lg text-slate-800 dark:text-slate-100">Länk...</h3>
<button @click="emit('close')" class="text-slate-500 hover:text-slate-700">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path></svg>
</button>
</div>
<!-- Content -->
<div class="p-4 space-y-4">
<div>
<label class="block text-xs font-medium text-slate-500 mb-1">Text att visa</label>
<input v-model="textInput" type="text" class="w-full px-3 py-1.5 bg-white dark:bg-slate-900 border border-slate-300 dark:border-slate-700 rounded-md focus:ring-1 focus:ring-indigo-500 text-sm" placeholder="Text..." />
</div>
<div>
<div class="flex border-b border-slate-200 dark:border-slate-700 mb-2">
<button @click="LinkType = 'document'" :class="LinkType === 'document' ? 'border-b-2 border-indigo-500 text-indigo-600' : 'text-slate-500'" class="px-3 py-1 text-sm font-medium">Dokument</button>
<button @click="LinkType = 'anchor'" :class="LinkType === 'anchor' ? 'border-b-2 border-indigo-500 text-indigo-600' : 'text-slate-500'" class="px-3 py-1 text-sm font-medium">Bokmärke (#)</button>
<button @click="LinkType = 'url'" :class="LinkType === 'url' ? 'border-b-2 border-indigo-500 text-indigo-600' : 'text-slate-500'" class="px-3 py-1 text-sm font-medium">Extern URL</button>
</div>
<div v-if="LinkType === 'url'">
<label class="block text-xs font-medium text-slate-500 mb-1">Webbadress</label>
<input v-model="urlInput" type="url" class="w-full px-3 py-1.5 bg-white dark:bg-slate-900 border border-slate-300 dark:border-slate-700 rounded-md text-sm" placeholder="https://..." />
</div>
<div v-else-if="LinkType === 'anchor'">
<label class="block text-xs font-medium text-slate-500 mb-1">ID att hoppa till (utan #)</label>
<input v-model="urlInput" type="text" class="w-full px-3 py-1.5 bg-white dark:bg-slate-900 border border-slate-300 dark:border-slate-700 rounded-md text-sm" placeholder="avancerat" />
</div>
<div v-else-if="LinkType === 'document'" class="h-48 flex flex-col">
<input v-model="searchQuery" type="text" class="w-full px-3 py-1.5 bg-slate-50 dark:bg-slate-900 border border-slate-300 dark:border-slate-700 rounded-md text-sm mb-2" placeholder="Sök dokument..." />
<div class="flex-1 overflow-y-auto border border-slate-200 dark:border-slate-700 rounded-md">
<div v-if="loadingDocs" class="p-2 text-xs text-center text-slate-400">Laddar...</div>
<button
v-else
v-for="doc in filteredDocs"
:key="doc.slug"
@click="selectDoc(doc.slug)"
class="w-full text-left px-3 py-2 text-sm hover:bg-slate-100 dark:hover:bg-slate-700 border-b border-slate-100 dark:border-slate-800 last:border-0 truncate"
>
🎵 {{ doc.title || doc.slug }}
</button>
</div>
</div>
</div>
</div>
<!-- Footer -->
<div class="p-4 border-t dark:border-slate-700 flex justify-end gap-2 bg-slate-50 dark:bg-slate-900/50">
<button @click="emit('close')" class="px-4 py-2 text-sm text-slate-600 hover:text-slate-800 dark:hover:text-slate-200">Avbryt</button>
<button v-if="LinkType !== 'document'" @click="confirm" class="px-4 py-2 text-sm bg-indigo-600 text-white rounded-md hover:bg-indigo-700">Infoga länk</button>
</div>
</div>
</div>
</template>

View File

@@ -8,9 +8,10 @@ import { TableRow } from '@tiptap/extension-table-row'
import { TableCell } from '@tiptap/extension-table-cell'
import { TableHeader } from '@tiptap/extension-table-header'
import { toTipTap, fromTipTap } from '@/bridge/asciidoc-bridge'
import { Admonition, CustomCodeBlock, AsciidocImage } from '@/bridge/asciidoc-extensions'
import { Admonition, CustomCodeBlock, AsciidocImage, CustomHeading, Footnote, AsciidocInclude } from '@/bridge/asciidoc-extensions'
import ImagePickerDialog from './ImagePickerDialog.vue'
import ImageEditDialog from './ImageEditDialog.vue'
import LinkDialog from './LinkDialog.vue'
const model = defineModel<string>({ required: true })
const props = defineProps<{ slug?: string }>()
@@ -18,17 +19,38 @@ const props = defineProps<{ slug?: string }>()
const isEditing = ref(false)
const showImagePicker = ref(false)
const showImageEditor = ref(false)
const showLinkDialog = ref({ open: false, url: '', text: '' })
const contextMenu = ref({ show: false, x: 0, y: 0, type: '' as 'table' | 'image' | '' })
const activeImageData = ref<{ src: string; width: string; height: string; alt: string; title: string; 'data-original-src': string } | null>(null)
const tocItems = ref<{id: string, text: string, level: number, pos: number}[]>([])
function extractTOC() {
if (!editor.value) return
const items: {id: string, text: string, level: number, pos: number}[] = []
editor.value.state.doc.descendants((node, pos) => {
if (node.type.name === 'heading') {
let id = node.attrs.id
if (!id) {
id = '_' + node.textContent.toLowerCase().replace(/[^a-z0-9]+/g, '_')
}
items.push({ id, text: node.textContent, level: node.attrs.level, pos: pos + 1 })
}
})
tocItems.value = items
}
const editor = useEditor({
extensions: [
StarterKit.configure({ codeBlock: false }),
StarterKit.configure({ codeBlock: false, heading: false }),
CustomHeading,
CustomCodeBlock,
Link,
Admonition,
AsciidocImage,
Footnote,
AsciidocInclude,
Table.configure({
resizable: true,
HTMLAttributes: {
@@ -49,7 +71,11 @@ const editor = useEditor({
editable: false,
onUpdate({ editor }) {
model.value = fromTipTap(editor.getJSON())
extractTOC()
},
onCreate() {
extractTOC()
}
})
watch(isEditing, (editing) => {
@@ -164,6 +190,35 @@ function handleImageInsert(data: { src: string; alt: string; title: string, 'dat
}
}
function handleLinkInsert(data: { url: string; text: string }) {
if (!editor.value) return
if (data.text) {
editor.value.commands.insertContent(`<a href="${data.url}">${data.text}</a>`)
} else {
editor.value.commands.setLink({ href: data.url })
}
}
function openLinkDialog() {
if (!editor.value) return
const attrs = editor.value.getAttributes('link')
// Try to get selected text if no link currently active
const { from, to } = editor.value.state.selection
const text = editor.value.state.doc.textBetween(from, to, ' ')
showLinkDialog.value = {
open: true,
url: attrs.href || '',
text: text || ''
}
}
function unsetLink() {
if (!editor.value) return
editor.value.commands.unsetLink()
}
onBeforeUnmount(() => {
editor.value?.destroy()
document.removeEventListener('click', closeContextMenu)
@@ -179,22 +234,92 @@ onMounted(() => {
document.addEventListener('click', closeContextMenu)
})
function insertFootnote() {
const text = prompt('Ange fotnot-text:')
if (text && editor.value) {
editor.value.commands.insertContent({
type: 'footnote',
attrs: { content: text }
})
}
}
function insertInclude() {
const file = prompt('Ange fil att inkludera (ex. undermapp/fil.adoc):')
if (file && editor.value) {
const attrs = prompt('Extrainställningar ex: leveloffset=+1 (lämna tomt annars):') || ''
editor.value.commands.insertContent({
type: 'asciidocInclude',
attrs: { file, attrs }
})
}
}
function scrollToHeading(item: any) {
if (isEditing.value && editor.value) {
editor.value.commands.setTextSelection(item.pos)
editor.value.commands.scrollIntoView()
} else {
// In read mode, typical asciidoctor generates IDs like _heading_name
const els = document.querySelectorAll(`h${item.level}`)
let target: HTMLElement | null = document.getElementById(item.id)
if (!target) {
// Find by text content if ID fails to match asciidoctor's exact generation
for (const h of els) {
if (h.textContent?.includes(item.text)) {
target = h as HTMLElement
break
}
}
}
if (target) {
target.scrollIntoView({ behavior: 'smooth' })
}
}
}
function btnClass(active: boolean | undefined) {
return [
'px-2 py-0.5 rounded text-sm font-medium transition-colors select-none',
active ? 'bg-indigo-100 text-indigo-700 dark:bg-indigo-900/50 dark:text-indigo-400' : 'text-gray-600 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-slate-700',
]
}
function handleEditorClick(e: MouseEvent) {
if (isEditing.value) return
const target = e.target as HTMLElement
const closestLink = target.closest('a')
if (closestLink) {
const href = closestLink.getAttribute('href')
if (href && href.startsWith('#')) {
e.preventDefault()
const element = document.getElementById(href.slice(1))
if (element) {
element.scrollIntoView({ behavior: 'smooth' })
}
}
}
}
</script>
<template>
<div class="flex flex-col h-full bg-white dark:bg-slate-900 relative">
<!-- Toolbar -->
<div
v-if="editor"
v-if="editor && isEditing"
class="flex overflow-x-auto items-center gap-1 p-2 border-b border-gray-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-800/80 flex-shrink-0"
:class="{ 'opacity-50 pointer-events-none': !isEditing }"
>
<button
@click="editor.chain().focus().unsetAllMarks().run()"
class="px-2 py-0.5 rounded text-sm font-medium transition-colors select-none text-slate-500 hover:text-slate-800 hover:bg-slate-200"
title="Rensa textformatering"
>
T
</button>
<div class="w-px h-4 bg-gray-300 dark:bg-slate-600 mx-1"></div>
<button
@click="editor.chain().focus().toggleBold().run()"
:class="btnClass(editor.isActive('bold'))"
@@ -218,6 +343,25 @@ function btnClass(active: boolean | undefined) {
</button>
<div class="w-px h-4 bg-gray-300 dark:bg-slate-600 mx-1"></div>
<button
@click="openLinkDialog"
:class="btnClass(editor.isActive('link'))"
title="Länk"
>
Länk
</button>
<button
@click="unsetLink"
v-if="editor.isActive('link')"
class="text-xs px-1 text-red-500 hover:text-red-700 font-bold ml-1"
title="Ta bort länk"
>
×
</button>
<div class="w-px h-4 bg-gray-300 dark:bg-slate-600 mx-1"></div>
<!-- Rubriker -->
<button
@click="editor.chain().focus().toggleHeading({ level: 1 }).run()"
@@ -301,16 +445,86 @@ function btnClass(active: boolean | undefined) {
Bild
</button>
<button
@click="insertFootnote"
class="px-2 py-0.5 text-sm font-medium transition-colors select-none text-slate-600 hover:bg-slate-100 dark:text-slate-400 dark:hover:bg-slate-700 mx-1"
title="Infoga Fotnot"
>
<span class="text-xs sup bg-indigo-100 dark:bg-indigo-900 px-1 rounded">[#]</span>
</button>
<button
@click="insertInclude"
class="px-2 py-0.5 text-sm font-medium transition-colors select-none text-slate-600 hover:bg-slate-100 dark:text-slate-400 dark:hover:bg-slate-700"
title="Infoga deldokument"
>
<span class="text-xs font-mono">include::</span>
</button>
<div class="flex-grow"></div>
<!-- Finish editing -->
<button
v-if="isEditing"
@click="isEditing = false"
class="ml-auto px-3 py-1 bg-green-500 hover:bg-green-600 text-white rounded text-sm shadow-sm transition"
>
Klar
</button>
</div>
<!-- Edit Area -->
<div class="flex-grow overflow-y-auto p-4 w-full" @contextmenu="onContextMenu">
<div
class="max-w-2xl mx-auto prose prose-indigo dark:prose-invert"
@click="!isEditing && (isEditing = true)"
<!-- Banner when not editing -->
<div
v-if="!isEditing"
class="bg-indigo-50 dark:bg-slate-800/80 border-b border-indigo-100 dark:border-slate-700/60 flex items-center justify-between p-2 flex-shrink-0"
>
<span class="text-sm text-indigo-700 dark:text-indigo-300 ml-2">Läsläge</span>
<button
@click="isEditing = true"
class="px-4 py-1.5 bg-indigo-600 hover:bg-indigo-700 text-white rounded text-sm shadow transition font-medium"
>
<EditorContent :editor="editor" />
Börja redigera
</button>
</div>
<!-- Edit Area with Splitted TOC Sidebar -->
<div class="flex-grow flex overflow-hidden w-full relative">
<!-- Main Content -->
<div
class="flex-grow overflow-y-auto p-4 w-full"
@contextmenu="onContextMenu"
@click="handleEditorClick"
>
<div
class="max-w-2xl mx-auto prose prose-indigo dark:prose-invert"
>
<EditorContent :editor="editor" />
</div>
</div>
<!-- Right Sidebar (TOC) -->
<div
v-if="tocItems.length > 1"
class="hidden lg:block w-64 border-l border-slate-200 dark:border-slate-700 bg-slate-50/50 dark:bg-slate-800/10 p-4 overflow-y-auto select-none flex-shrink-0 relative"
>
<div class="sticky top-0">
<h4 class="text-xs font-bold text-slate-500 uppercase tracking-wider mb-4 border-b border-slate-200 dark:border-slate-700 pb-2">
Innehåll
</h4>
<ul class="space-y-1.5">
<li v-for="item in tocItems" :key="item.pos" :style="{ paddingLeft: `${(item.level - 1) * 0.75}rem` }">
<button
@click="scrollToHeading(item)"
class="group flex text-left w-full items-baseline space-x-1"
>
<div class="w-1.5 h-1.5 rounded-full bg-slate-300 dark:bg-slate-600 flex-shrink-0 group-hover:bg-indigo-500 transition-colors"></div>
<span class="text-sm font-medium text-slate-600 dark:text-slate-400 group-hover:text-indigo-600 dark:group-hover:text-indigo-400 truncate w-[calc(100%-8px)] transition-colors">
{{ item.text }}
</span>
</button>
</li>
</ul>
</div>
</div>
</div>
@@ -328,6 +542,14 @@ function btnClass(active: boolean | undefined) {
@update="handleImageUpdate"
/>
<LinkDialog
:is-open="showLinkDialog.open"
:initial-url="showLinkDialog.url"
:initial-text="showLinkDialog.text"
@close="showLinkDialog.open = false"
@confirm="handleLinkInsert"
/>
<!-- Context Menu -->
<div
v-if="contextMenu.show"

View File

@@ -0,0 +1,70 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
const props = defineProps<{
isOpen: boolean;
type: 'folder' | 'document';
}>()
const emit = defineEmits<{
(e: 'close'): void;
(e: 'confirm', name: string): void;
}>()
const inputName = ref('')
const inputRef = ref<HTMLInputElement | null>(null)
watch(() => props.isOpen, (open) => {
if (open) {
inputName.value = ''
setTimeout(() => inputRef.value?.focus(), 50)
}
})
function confirm() {
if (inputName.value.trim()) {
emit('confirm', inputName.value.trim())
emit('close')
}
}
</script>
<template>
<div v-if="isOpen" class="fixed inset-0 z-[60] flex items-center justify-center p-4 bg-slate-900/50 backdrop-blur-sm">
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-2xl max-w-sm w-full overflow-hidden border border-slate-200 dark:border-slate-700 transform transition-all">
<div class="p-5 flex items-center gap-3 border-b border-slate-100 dark:border-slate-700/50 bg-slate-50/50 dark:bg-slate-800/50">
<div class="p-2 rounded-lg bg-indigo-100 dark:bg-indigo-900/30 text-indigo-600 dark:text-indigo-400">
<svg v-if="type === 'folder'" class="w-6 h-6" 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"></path></svg>
<svg v-else class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414A1 1 0 0121 9.414V19a2 2 0 01-2 2z"></path></svg>
</div>
<h3 class="font-semibold text-lg text-slate-800 dark:text-slate-100">
{{ type === 'folder' ? 'Ny Mapp' : 'Nytt Dokument' }}
</h3>
</div>
<div class="p-5">
<label class="block text-sm font-medium text-slate-600 dark:text-slate-300 mb-1">
{{ type === 'folder' ? 'Välj ett namn för mappen' : 'Välj en titel för dokumentet' }}
</label>
<input
ref="inputRef"
v-model="inputName"
@keydown.enter="confirm"
@keydown.esc="emit('close')"
type="text"
class="w-full px-3 py-2 bg-white dark:bg-slate-900 border border-slate-300 dark:border-slate-600 focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 rounded-lg shadow-sm text-slate-800 dark:text-slate-100 transition-colors"
:placeholder="type === 'folder' ? 't.ex. Projekt X 📂' : 't.ex. Mötesanteckningar 📝'"
/>
</div>
<div class="px-5 py-4 border-t border-slate-100 dark:border-slate-700/50 bg-slate-50 dark:bg-slate-800/80 flex justify-end gap-3">
<button @click="emit('close')" class="px-4 py-2 text-sm font-medium text-slate-600 hover:text-slate-800 dark:text-slate-400 dark:hover:text-slate-200 transition-colors">
Avbryt
</button>
<button @click="confirm" :disabled="!inputName.trim()" class="px-5 py-2 text-sm font-medium bg-indigo-600 text-white rounded-lg shadow-sm hover:bg-indigo-700 focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed transition-all">
Skapa
</button>
</div>
</div>
</div>
</template>

View File

@@ -2,6 +2,7 @@
import { ref, reactive, onMounted, computed, watch } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { gql, createFolder, moveDocument } from '@/lib/gql'
import CreateItemDialog from './CreateItemDialog.vue'
const emit = defineEmits<{ close: [] }>()
@@ -70,41 +71,47 @@ function buildTree(docs: DocMeta[], folders: string[]): 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)
}
const createDialog = reactive({
isOpen: false,
type: 'document' as 'folder' | 'document',
parentPath: ''
})
function promptCreateRootFolder() {
createDialog.type = 'folder'
createDialog.parentPath = ''
createDialog.isOpen = true
}
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 promptCreateItem(type: 'folder' | 'document', parentPath: string) {
createDialog.type = type
createDialog.parentPath = parentPath
createDialog.isOpen = true
}
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, '-')
async function handleDialogConfirm(name: string) {
const { type, parentPath } = createDialog
// 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')
if (type === 'folder') {
try {
const newPath = parentPath ? `${parentPath}/${name}` : name
await createFolder(newPath)
const fData = await gql<{ folders?: string[] }>(`{ folders }`)
allFolders.value = fData.folders ?? []
if (parentPath) openFolders[parentPath] = true
} catch (err) {
console.error('Failed to create folder:', err)
}
} else {
// Document
const slug = parentPath
? `${parentPath}/${name.toLowerCase().replace(/\\s+/g, '-')}`
: name.toLowerCase().replace(/\\s+/g, '-')
sessionStorage.setItem('newDocSlug', slug)
router.push('/doc/new')
emit('close')
}
}
function handleDragStart(e: DragEvent, item: FlatItem) {
@@ -313,16 +320,16 @@ function isActive(slug: string) {
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"
@click.stop="promptCreateItem('folder', 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 flex items-center gap-2"
>
+ Folder
<span class="text-amber-500">📁</span> Mapp
</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"
@click.stop="promptCreateItem('document', 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 flex items-center gap-2"
>
+ Document
<span class="text-indigo-400">📄</span> Dokument
</button>
</div>
</div>
@@ -355,25 +362,20 @@ 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"
class="w-full btn-secondary text-center text-sm py-1.5 flex items-center justify-center gap-2"
@click="promptCreateRootFolder"
>
<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
<span class="text-amber-500">📁</span>
Ny mapp (rot)
</button>
<router-link
to="/doc/new"
class="btn-primary w-full text-center text-sm py-2 flex items-center justify-center gap-1.5"
@click="emit('close')"
<button
class="btn-primary w-full text-center text-sm py-2 flex items-center justify-center gap-2"
@click="promptCreateItem('document', '')"
>
<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 document
</router-link>
<span class="text-indigo-200">📝</span>
Nytt dokument (rot)
</button>
<router-link
to="/admin"
@@ -388,5 +390,12 @@ function isActive(slug: string) {
</router-link>
</div>
<!-- Create Dialog (Folder / Document) -->
<CreateItemDialog
:is-open="createDialog.isOpen"
:type="createDialog.type"
@close="createDialog.isOpen = false"
@confirm="handleDialogConfirm"
/>
</div>
</template>