feat: entry editor at /content/:key/new and /content/:key/:id

This commit is contained in:
Florian Sylvain committed 2026-10-21 00:12:34 +02:00
1 parent 91d916fb74
commit fba20f83c5
94 files changed
+7116 -423

No files matched your search

+132
View File
@@ -0,0 +1,132 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed, nextTick, ref } from 'vue'
import { renderMarkdown } from '@/lib/markdown'
import { MARKDOWN_ACTIONS, applyMarkdownAction, type MarkdownAction } from '@/lib/markdownEdit'
type Mode = 'write' | 'preview'
const props = defineProps<{
modelValue: string
disabled?: boolean
invalid?: boolean
required?: boolean
}>()
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
const mode = ref<Mode>('write')
const root = ref<HTMLElement | null>(null)
const tabs = computed(() => [
{ label: t('Write'), value: 'write' },
{ label: t('Preview'), value: 'preview' },
])
const actions = computed(() => ({
bold: { label: t('Bold'), icon: 'i-lucide-bold' },
italic: { label: t('Italic'), icon: 'i-lucide-italic' },
heading: { label: t('Heading'), icon: 'i-lucide-heading' },
link: { label: t('Link'), icon: 'i-lucide-link' },
list: { label: t('List'), icon: 'i-lucide-list' },
}))
const rendered = computed(() => renderMarkdown(props.modelValue))
const empty = computed(() => props.modelValue.trim() === '')
function textarea() {
return root.value?.querySelector<HTMLTextAreaElement>('textarea') ?? null
}
async function run(action: MarkdownAction) {
const field = textarea()
if (!field) {
return
}
const edit = applyMarkdownAction(
action,
props.modelValue,
field.selectionStart,
field.selectionEnd,
)
emit('update:modelValue', edit.value)
await nextTick()
field.focus()
field.setSelectionRange(edit.start, edit.end)
}
async function onMode(value: string | number) {
mode.value = value === 'preview' ? 'preview' : 'write'
if (mode.value === 'write') {
await nextTick()
textarea()?.focus()
}
}
</script>
<template>
<div
ref="root"
data-testid="markdown-field"
class="flex min-w-0 flex-col border bg-default"
:class="[
invalid ? 'border-error shadow-[inset_0_0_0_1px_var(--ui-error)]' : 'border-accented',
disabled ? 'bg-elevated' : '',
]"
>
<div class="flex items-center gap-0.5 overflow-hidden border-b border-default px-1.5">
<UTabs
:model-value="mode"
:items="tabs"
:content="false"
variant="link"
color="neutral"
:ui="{
root: 'mr-2 w-auto gap-0',
list: 'gap-3.5 px-1.5',
trigger: 'h-11 px-0 text-base md:h-8',
indicator: 'bg-primary',
}"
@update:model-value="onMode"
/>
<template v-if="mode === 'write'">
<UButton
v-for="action in MARKDOWN_ACTIONS"
:key="action"
type="button"
color="neutral"
variant="ghost"
:icon="actions[action].icon"
:aria-label="actions[action].label"
:disabled="disabled"
class="size-10 justify-center text-toned md:size-7"
:ui="{ leadingIcon: 'size-4' }"
@click="run(action)"
/>
</template>
</div>
<UTextarea
v-show="mode === 'write'"
:model-value="modelValue"
:placeholder="t('Write in Markdown')"
:disabled="disabled"
:aria-invalid="invalid ? 'true' : undefined"
:aria-required="required ? 'true' : undefined"
variant="none"
:rows="4"
autoresize
class="w-full"
:ui="{
base: 'min-h-30 px-2.5 py-2 font-mono text-lg/5.5 md:min-h-24 md:text-xs/4.75',
}"
@update:model-value="emit('update:modelValue', String($event))"
/>
<div
v-if="mode === 'preview'"
data-testid="markdown-preview"
class="flex min-h-30 flex-col gap-2 px-3 py-2.5 text-lg md:min-h-24 md:text-sm [&_a]:text-primary [&_a]:underline [&_h1]:font-semibold [&_h2]:font-semibold [&_h3]:font-semibold [&_ul]:list-[square] [&_ul]:ps-4.5"
>
<p v-if="empty" class="text-muted">{{ t('Nothing to preview yet.') }}</p>
<div v-else class="flex flex-col gap-2" v-html="rendered" />
</div>
</div>
</template>