feat: add LinkDialog and CreateItemDialog components; enhance editor with footnote and include functionalities
This commit is contained in:
@@ -7,7 +7,7 @@ import { Table } from '@tiptap/extension-table'
|
|||||||
import { TableRow } from '@tiptap/extension-table-row'
|
import { TableRow } from '@tiptap/extension-table-row'
|
||||||
import { TableCell } from '@tiptap/extension-table-cell'
|
import { TableCell } from '@tiptap/extension-table-cell'
|
||||||
import { TableHeader } from '@tiptap/extension-table-header'
|
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()
|
const asciidoctor = Asciidoctor()
|
||||||
|
|
||||||
@@ -18,8 +18,13 @@ export function toTipTap(adoc: string, slug?: string): JSONContent {
|
|||||||
return { type: 'doc', content: [{ type: 'paragraph' }] }
|
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
|
// 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 },
|
attributes: { showtitle: false },
|
||||||
standalone: false
|
standalone: false
|
||||||
}) as string
|
}) as string
|
||||||
@@ -63,11 +68,14 @@ export function toTipTap(adoc: string, slug?: string): JSONContent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const extensions = [
|
const extensions = [
|
||||||
StarterKit.configure({ codeBlock: false }),
|
StarterKit.configure({ codeBlock: false, heading: false }),
|
||||||
|
CustomHeading,
|
||||||
CustomCodeBlock,
|
CustomCodeBlock,
|
||||||
Link,
|
Link,
|
||||||
Admonition,
|
Admonition,
|
||||||
AsciidocImage,
|
AsciidocImage,
|
||||||
|
Footnote,
|
||||||
|
AsciidocInclude,
|
||||||
Table.configure({
|
Table.configure({
|
||||||
resizable: true,
|
resizable: true,
|
||||||
}),
|
}),
|
||||||
@@ -132,6 +140,9 @@ class TipTapToAsciidoc {
|
|||||||
case 'table':
|
case 'table':
|
||||||
output += this.convertTable(node)
|
output += this.convertTable(node)
|
||||||
break
|
break
|
||||||
|
case 'asciidocInclude':
|
||||||
|
output += `include::${node.attrs?.file || ''}[${node.attrs?.attrs || ''}]\n\n`
|
||||||
|
break
|
||||||
case 'horizontalRule':
|
case 'horizontalRule':
|
||||||
output += "'''\n\n"
|
output += "'''\n\n"
|
||||||
break
|
break
|
||||||
@@ -145,8 +156,16 @@ class TipTapToAsciidoc {
|
|||||||
|
|
||||||
convertHeading(node: JSONContent): string {
|
convertHeading(node: JSONContent): string {
|
||||||
const level = (node.attrs?.level as number) || 1
|
const level = (node.attrs?.level as number) || 1
|
||||||
|
const id = node.attrs?.id as string | undefined
|
||||||
const prefix = '='.repeat(level)
|
const prefix = '='.repeat(level)
|
||||||
const text = this.renderTextNodes(node.content || [])
|
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`
|
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
|
textOut += text
|
||||||
}
|
}
|
||||||
if (t.type === 'hardBreak') {
|
if (t.type === 'hardBreak') {
|
||||||
|
|||||||
@@ -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 { CodeBlockLowlight, CodeBlockLowlightOptions } from '@tiptap/extension-code-block-lowlight'
|
||||||
import { createLowlight, all } from 'lowlight'
|
import { createLowlight, all } from 'lowlight'
|
||||||
import Image from '@tiptap/extension-image'
|
import Image from '@tiptap/extension-image'
|
||||||
|
|
||||||
const lowlight = createLowlight(all)
|
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({
|
export const AsciidocImage = Image.extend({
|
||||||
addAttributes() {
|
addAttributes() {
|
||||||
return {
|
return {
|
||||||
@@ -49,14 +125,14 @@ export const Admonition = Node.create({
|
|||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
tag: 'div[data-type="admonition"]',
|
tag: 'div[data-type="admonition"]',
|
||||||
getAttrs: (el) => {
|
getAttrs: (el: any) => {
|
||||||
if (typeof el === 'string') return {}
|
if (typeof el === 'string') return {}
|
||||||
return { type: el.getAttribute('data-admonition-type') || 'note' }
|
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]
|
return ['div', { 'data-type': 'admonition', 'data-admonition-type': HTMLAttributes.type, class: `admonition ${HTMLAttributes.type}` }, 0]
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
133
frontend/src/components/editor/LinkDialog.vue
Normal file
133
frontend/src/components/editor/LinkDialog.vue
Normal 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>
|
||||||
@@ -8,9 +8,10 @@ import { TableRow } from '@tiptap/extension-table-row'
|
|||||||
import { TableCell } from '@tiptap/extension-table-cell'
|
import { TableCell } from '@tiptap/extension-table-cell'
|
||||||
import { TableHeader } from '@tiptap/extension-table-header'
|
import { TableHeader } from '@tiptap/extension-table-header'
|
||||||
import { toTipTap, fromTipTap } from '@/bridge/asciidoc-bridge'
|
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 ImagePickerDialog from './ImagePickerDialog.vue'
|
||||||
import ImageEditDialog from './ImageEditDialog.vue'
|
import ImageEditDialog from './ImageEditDialog.vue'
|
||||||
|
import LinkDialog from './LinkDialog.vue'
|
||||||
|
|
||||||
const model = defineModel<string>({ required: true })
|
const model = defineModel<string>({ required: true })
|
||||||
const props = defineProps<{ slug?: string }>()
|
const props = defineProps<{ slug?: string }>()
|
||||||
@@ -18,17 +19,38 @@ const props = defineProps<{ slug?: string }>()
|
|||||||
const isEditing = ref(false)
|
const isEditing = ref(false)
|
||||||
const showImagePicker = ref(false)
|
const showImagePicker = ref(false)
|
||||||
const showImageEditor = 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 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 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({
|
const editor = useEditor({
|
||||||
extensions: [
|
extensions: [
|
||||||
StarterKit.configure({ codeBlock: false }),
|
StarterKit.configure({ codeBlock: false, heading: false }),
|
||||||
|
CustomHeading,
|
||||||
CustomCodeBlock,
|
CustomCodeBlock,
|
||||||
Link,
|
Link,
|
||||||
Admonition,
|
Admonition,
|
||||||
AsciidocImage,
|
AsciidocImage,
|
||||||
|
Footnote,
|
||||||
|
AsciidocInclude,
|
||||||
Table.configure({
|
Table.configure({
|
||||||
resizable: true,
|
resizable: true,
|
||||||
HTMLAttributes: {
|
HTMLAttributes: {
|
||||||
@@ -49,7 +71,11 @@ const editor = useEditor({
|
|||||||
editable: false,
|
editable: false,
|
||||||
onUpdate({ editor }) {
|
onUpdate({ editor }) {
|
||||||
model.value = fromTipTap(editor.getJSON())
|
model.value = fromTipTap(editor.getJSON())
|
||||||
|
extractTOC()
|
||||||
},
|
},
|
||||||
|
onCreate() {
|
||||||
|
extractTOC()
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(isEditing, (editing) => {
|
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(() => {
|
onBeforeUnmount(() => {
|
||||||
editor.value?.destroy()
|
editor.value?.destroy()
|
||||||
document.removeEventListener('click', closeContextMenu)
|
document.removeEventListener('click', closeContextMenu)
|
||||||
@@ -179,22 +234,92 @@ onMounted(() => {
|
|||||||
document.addEventListener('click', closeContextMenu)
|
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) {
|
function btnClass(active: boolean | undefined) {
|
||||||
return [
|
return [
|
||||||
'px-2 py-0.5 rounded text-sm font-medium transition-colors select-none',
|
'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',
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex flex-col h-full bg-white dark:bg-slate-900 relative">
|
<div class="flex flex-col h-full bg-white dark:bg-slate-900 relative">
|
||||||
<!-- Toolbar -->
|
<!-- Toolbar -->
|
||||||
<div
|
<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="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
|
<button
|
||||||
@click="editor.chain().focus().toggleBold().run()"
|
@click="editor.chain().focus().toggleBold().run()"
|
||||||
:class="btnClass(editor.isActive('bold'))"
|
:class="btnClass(editor.isActive('bold'))"
|
||||||
@@ -218,6 +343,25 @@ function btnClass(active: boolean | undefined) {
|
|||||||
</button>
|
</button>
|
||||||
<div class="w-px h-4 bg-gray-300 dark:bg-slate-600 mx-1"></div>
|
<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 -->
|
<!-- Rubriker -->
|
||||||
<button
|
<button
|
||||||
@click="editor.chain().focus().toggleHeading({ level: 1 }).run()"
|
@click="editor.chain().focus().toggleHeading({ level: 1 }).run()"
|
||||||
@@ -301,16 +445,86 @@ function btnClass(active: boolean | undefined) {
|
|||||||
Bild
|
Bild
|
||||||
</button>
|
</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>
|
<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>
|
</div>
|
||||||
|
|
||||||
<!-- Edit Area -->
|
<!-- Banner when not editing -->
|
||||||
<div class="flex-grow overflow-y-auto p-4 w-full" @contextmenu="onContextMenu">
|
<div
|
||||||
<div
|
v-if="!isEditing"
|
||||||
class="max-w-2xl mx-auto prose prose-indigo dark:prose-invert"
|
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"
|
||||||
@click="!isEditing && (isEditing = true)"
|
>
|
||||||
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -328,6 +542,14 @@ function btnClass(active: boolean | undefined) {
|
|||||||
@update="handleImageUpdate"
|
@update="handleImageUpdate"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<LinkDialog
|
||||||
|
:is-open="showLinkDialog.open"
|
||||||
|
:initial-url="showLinkDialog.url"
|
||||||
|
:initial-text="showLinkDialog.text"
|
||||||
|
@close="showLinkDialog.open = false"
|
||||||
|
@confirm="handleLinkInsert"
|
||||||
|
/>
|
||||||
|
|
||||||
<!-- Context Menu -->
|
<!-- Context Menu -->
|
||||||
<div
|
<div
|
||||||
v-if="contextMenu.show"
|
v-if="contextMenu.show"
|
||||||
|
|||||||
70
frontend/src/components/layout/CreateItemDialog.vue
Normal file
70
frontend/src/components/layout/CreateItemDialog.vue
Normal 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>
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
import { ref, reactive, onMounted, computed, watch } from 'vue'
|
import { ref, reactive, onMounted, computed, watch } from 'vue'
|
||||||
import { useRouter, useRoute } from 'vue-router'
|
import { useRouter, useRoute } from 'vue-router'
|
||||||
import { gql, createFolder, moveDocument } from '@/lib/gql'
|
import { gql, createFolder, moveDocument } from '@/lib/gql'
|
||||||
|
import CreateItemDialog from './CreateItemDialog.vue'
|
||||||
|
|
||||||
const emit = defineEmits<{ close: [] }>()
|
const emit = defineEmits<{ close: [] }>()
|
||||||
|
|
||||||
@@ -70,41 +71,47 @@ function buildTree(docs: DocMeta[], folders: string[]): TreeFolder {
|
|||||||
return root
|
return root
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleCreateRootFolder() {
|
const createDialog = reactive({
|
||||||
const name = prompt('Enter folder name:')
|
isOpen: false,
|
||||||
if (!name?.trim()) return
|
type: 'document' as 'folder' | 'document',
|
||||||
try {
|
parentPath: ''
|
||||||
await createFolder(name)
|
})
|
||||||
const fData = await gql<{ folders?: string[] }>(`{ folders }`)
|
|
||||||
allFolders.value = fData.folders ?? []
|
function promptCreateRootFolder() {
|
||||||
} catch (err) {
|
createDialog.type = 'folder'
|
||||||
console.error('Failed to create folder:', err)
|
createDialog.parentPath = ''
|
||||||
}
|
createDialog.isOpen = true
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleCreateFolder(parentPath: string) {
|
function promptCreateItem(type: 'folder' | 'document', parentPath: string) {
|
||||||
const name = prompt('Enter folder name:')
|
createDialog.type = type
|
||||||
if (!name?.trim()) return
|
createDialog.parentPath = parentPath
|
||||||
try {
|
createDialog.isOpen = true
|
||||||
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) {
|
async function handleDialogConfirm(name: string) {
|
||||||
const title = prompt('Enter document title:')
|
const { type, parentPath } = createDialog
|
||||||
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
|
if (type === 'folder') {
|
||||||
sessionStorage.setItem('newDocSlug', slug)
|
try {
|
||||||
router.push('/doc/new')
|
const newPath = parentPath ? `${parentPath}/${name}` : name
|
||||||
emit('close')
|
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) {
|
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"
|
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
|
<button
|
||||||
@click.stop="handleCreateFolder(item.path); showCreateMenu[item.path] = false"
|
@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"
|
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>
|
||||||
<button
|
<button
|
||||||
@click.stop="handleCreateDoc(item.path); showCreateMenu[item.path] = false"
|
@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"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -355,25 +362,20 @@ function isActive(slug: string) {
|
|||||||
<!-- Bottom actions -->
|
<!-- Bottom actions -->
|
||||||
<div class="p-3 border-t border-slate-200 dark:border-slate-700/60 flex flex-col gap-2">
|
<div class="p-3 border-t border-slate-200 dark:border-slate-700/60 flex flex-col gap-2">
|
||||||
<button
|
<button
|
||||||
class="w-full btn-secondary text-center text-sm py-1.5 flex items-center justify-center gap-1.5"
|
class="w-full btn-secondary text-center text-sm py-1.5 flex items-center justify-center gap-2"
|
||||||
@click="handleCreateRootFolder"
|
@click="promptCreateRootFolder"
|
||||||
>
|
>
|
||||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<span class="text-amber-500">📁</span>
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
|
Ny mapp (rot)
|
||||||
</svg>
|
|
||||||
New folder
|
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<router-link
|
<button
|
||||||
to="/doc/new"
|
class="btn-primary w-full text-center text-sm py-2 flex items-center justify-center gap-2"
|
||||||
class="btn-primary w-full text-center text-sm py-2 flex items-center justify-center gap-1.5"
|
@click="promptCreateItem('document', '')"
|
||||||
@click="emit('close')"
|
|
||||||
>
|
>
|
||||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<span class="text-indigo-200">📝</span>
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
|
Nytt dokument (rot)
|
||||||
</svg>
|
</button>
|
||||||
New document
|
|
||||||
</router-link>
|
|
||||||
|
|
||||||
<router-link
|
<router-link
|
||||||
to="/admin"
|
to="/admin"
|
||||||
@@ -388,5 +390,12 @@ function isActive(slug: string) {
|
|||||||
</router-link>
|
</router-link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Create Dialog (Folder / Document) -->
|
||||||
|
<CreateItemDialog
|
||||||
|
:is-open="createDialog.isOpen"
|
||||||
|
:type="createDialog.type"
|
||||||
|
@close="createDialog.isOpen = false"
|
||||||
|
@confirm="handleDialogConfirm"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
Reference in New Issue
Block a user