Browse Source

feat(markdown): enhance Vditor integration and post preview functionality

- Updated the Vditor configuration to support real-time rendering and improved upload handling with error management.
- Introduced a new post preview draft feature, allowing users to preview posts in a new window before publishing.
- Enhanced the user interface for mobile responsiveness and added utility functions for managing post preview drafts.

These changes improve the markdown editing experience and streamline the post creation workflow.
main
npmrun 2 weeks ago
parent
commit
890db7c4f9
  1. 8
      app/components/AppShell.vue
  2. 96
      app/components/PostBodyMarkdownEditor.vue
  3. 60
      app/components/post-body-markdown-editor-vditor-config.test.ts
  4. 58
      app/components/post-body-markdown-editor-vditor-config.ts
  5. 28
      app/pages/me/posts/[id].vue
  6. 25
      app/pages/me/posts/new.vue
  7. 89
      app/pages/me/posts/preview/draft.vue
  8. 99
      app/utils/post-preview-draft.ts
  9. 5
      package.json
  10. BIN
      packages/drizzle-pkg/db.sqlite
  11. BIN
      public/upload/1777047996547-912573909-image.webp
  12. 17
      scripts/sync-vditor-assets.sh

8
app/components/AppShell.vue

@ -177,7 +177,7 @@ async function logout() {
</UDropdownMenu> </UDropdownMenu>
</div> </div>
<div class="flex shrink-0 items-center gap-2"> <div class="flex min-w-0 shrink-0 items-center gap-2">
<template v-if="!initialized"> <template v-if="!initialized">
<USkeleton class="h-9 w-24 rounded-md" /> <USkeleton class="h-9 w-24 rounded-md" />
</template> </template>
@ -214,18 +214,18 @@ async function logout() {
</div> </div>
<UDropdownMenu :items="accountMenuItems" :content="{ align: 'end' }"> <UDropdownMenu :items="accountMenuItems" :content="{ align: 'end' }">
<UButton color="neutral" variant="ghost" class="gap-2 px-2"> <UButton color="neutral" variant="ghost" class="max-w-[10.5rem] gap-2 px-2 sm:max-w-none">
<UAvatar <UAvatar
:src="user.avatar || undefined" :src="user.avatar || undefined"
:alt="displayName" :alt="displayName"
size="sm" size="sm"
class="ring-1 ring-default" class="ring-1 ring-default"
/> />
<span class="max-w-[8rem] truncate text-sm font-medium text-highlighted"> <span class="max-w-[8rem] truncate text-sm font-medium text-highlighted max-[420px]:hidden">
{{ displayName }} {{ displayName }}
</span> </span>
<span class="hidden text-xs text-muted lg:inline">@{{ user.username }}</span> <span class="hidden text-xs text-muted lg:inline">@{{ user.username }}</span>
<UIcon name="i-lucide-chevrons-up-down" class="size-4 text-muted" /> <UIcon name="i-lucide-chevrons-up-down" class="size-4 text-muted max-[420px]:hidden" />
</UButton> </UButton>
</UDropdownMenu> </UDropdownMenu>

96
app/components/PostBodyMarkdownEditor.vue

@ -13,10 +13,21 @@ const emit = defineEmits<{
'update:modelValue': [string] 'update:modelValue': [string]
}>() }>()
const { fetchData } = useClientApi()
const toast = useToast() const toast = useToast()
const mountEl = ref<HTMLElement | null>(null) const mountEl = ref<HTMLElement | null>(null)
const isMobileViewport = ref(false) const isMobileViewport = ref(false)
const editorContainerStyle = computed(() => {
if (isMobileViewport.value) {
return {
height: 'min(62vh, 560px)',
minHeight: '360px',
}
}
return {
height: 'min(78vh, 900px)',
minHeight: '520px',
}
})
let viewportMql: MediaQueryList | null = null let viewportMql: MediaQueryList | null = null
let onViewportChange: ((event: MediaQueryListEvent) => void) | null = null let onViewportChange: ((event: MediaQueryListEvent) => void) | null = null
@ -32,7 +43,9 @@ const bridge = createPostBodyMarkdownEditorBridge({
value, value,
isMobile: isMobileViewport.value, isMobile: isMobileViewport.value,
onInput, onInput,
uploadHandler: onUploadImg, onUploadError: () => {
toast.add({ title: '图片上传失败', color: 'warning' })
},
})) }))
}, },
}) })
@ -44,40 +57,6 @@ const vditorCtor = shallowRef<null | (new (element: HTMLElement, options: Record
})>(null) })>(null)
let unmounted = false let unmounted = false
async function onUploadImg(files: File[]): Promise<string> {
const form = new FormData()
for (const file of files) {
form.append('file', file)
}
try {
const { files: uploaded } = await fetchData<{ files: { url: string }[] }>('/api/file/upload', {
method: 'POST',
body: form,
})
const succMap = Object.fromEntries(uploaded.map((item) => [item.url, item.url]))
toast.add({ title: '图片已上传', color: 'success' })
return JSON.stringify({
msg: '',
code: 0,
data: {
errFiles: [] as string[],
succMap,
},
})
}
catch {
toast.add({ title: '图片上传失败', color: 'warning' })
return JSON.stringify({
msg: 'upload failed',
code: 1,
data: {
errFiles: [] as string[],
succMap: {} as Record<string, string>,
},
})
}
}
onMounted(async () => { onMounted(async () => {
if (!import.meta.client) { if (!import.meta.client) {
return return
@ -137,8 +116,8 @@ onBeforeUnmount(() => {
<ClientOnly> <ClientOnly>
<div <div
ref="mountEl" ref="mountEl"
:style="{ height: 'min(72vh, 720px)' }" :style="editorContainerStyle"
class="w-full rounded-lg overflow-hidden ring ring-default" class="w-full min-w-0 rounded-lg overflow-hidden ring ring-default"
/> />
<template #fallback> <template #fallback>
<div class="text-muted text-sm py-12 text-center border border-default rounded-lg"> <div class="text-muted text-sm py-12 text-center border border-default rounded-lg">
@ -147,3 +126,44 @@ onBeforeUnmount(() => {
</template> </template>
</ClientOnly> </ClientOnly>
</template> </template>
<style scoped>
@media (max-width: 767px) {
:deep(.vditor) {
min-width: 0;
width: 100%;
}
:deep(.vditor-toolbar) {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 2px;
padding: 4px 6px;
}
:deep(.vditor-toolbar__item) {
float: none;
flex: 0 0 auto;
padding: 0 !important;
}
:deep(.vditor-toolbar__item .vditor-tooltipped) {
width: 30px;
height: 30px;
min-width: 30px;
padding: 7px;
border-radius: 6px;
}
:deep(.vditor-toolbar__divider) {
display: none;
}
:deep(.vditor-content),
:deep(.vditor-ir),
:deep(.vditor-wysiwyg) {
min-width: 0;
}
}
</style>

60
app/components/post-body-markdown-editor-vditor-config.test.ts

@ -5,15 +5,21 @@ import {
} from './post-body-markdown-editor-vditor-config' } from './post-body-markdown-editor-vditor-config'
describe('PostBodyMarkdownEditor Vditor config', () => { describe('PostBodyMarkdownEditor Vditor config', () => {
test('桌面端使用完整工具栏与可预览模式', () => { test('桌面端使用完整工具栏与即时渲染模式', () => {
const options = buildPostBodyMarkdownEditorVditorOptions({ const options = buildPostBodyMarkdownEditorVditorOptions({
value: 'hello', value: 'hello',
isMobile: false, isMobile: false,
onInput: () => undefined, onInput: () => undefined,
uploadHandler: async () => '', onUploadError: () => undefined,
}) })
expect(options.mode).toBe('sv') expect(options.mode).toBe('ir')
expect(options.lang).toBe('zh_CN')
expect(options.cdn).toBe('/vditor')
expect(options.preview).toEqual({
mode: 'editor',
actions: [],
})
expect(options.toolbar).toEqual(postBodyMarkdownEditorToolbarPresets.desktop) expect(options.toolbar).toEqual(postBodyMarkdownEditorToolbarPresets.desktop)
expect(postBodyMarkdownEditorToolbarPresets.desktop.includes('preview')).toBe(false) expect(postBodyMarkdownEditorToolbarPresets.desktop.includes('preview')).toBe(false)
expect(postBodyMarkdownEditorToolbarPresets.desktop.length).toBeGreaterThan(postBodyMarkdownEditorToolbarPresets.mobile.length) expect(postBodyMarkdownEditorToolbarPresets.desktop.length).toBeGreaterThan(postBodyMarkdownEditorToolbarPresets.mobile.length)
@ -24,7 +30,7 @@ describe('PostBodyMarkdownEditor Vditor config', () => {
value: 'hello', value: 'hello',
isMobile: true, isMobile: true,
onInput: () => undefined, onInput: () => undefined,
uploadHandler: async () => '', onUploadError: () => undefined,
}) })
expect(options.mode).toBe('ir') expect(options.mode).toBe('ir')
@ -45,17 +51,49 @@ describe('PostBodyMarkdownEditor Vditor config', () => {
]) ])
}) })
test('上传处理器透传到 upload.handler', async () => { test('上传配置会将服务端响应转换为 succMap 结构', async () => {
const uploadHandler = async () => 'ok' const options = buildPostBodyMarkdownEditorVditorOptions({
value: '',
isMobile: false,
onInput: () => undefined,
onUploadError: () => undefined,
}) as { upload?: { format?: (files: File[], responseText: string) => string } }
const files = [{ name: 'image.webp' }] as File[]
const result = options.upload?.format?.(files, JSON.stringify({
code: 0,
data: {
files: [{ url: '/public/upload/abc.webp' }],
},
}))
expect(result).toBe(JSON.stringify({
msg: '',
code: 0,
data: {
errFiles: [],
succMap: {
'image.webp': '/public/upload/abc.webp',
},
},
}))
})
test('上传配置解析失败时返回错误结构', () => {
const options = buildPostBodyMarkdownEditorVditorOptions({ const options = buildPostBodyMarkdownEditorVditorOptions({
value: '', value: '',
isMobile: false, isMobile: false,
onInput: () => undefined, onInput: () => undefined,
uploadHandler, onUploadError: () => undefined,
}) as { upload?: { handler?: (files: File[]) => Promise<string> } } }) as { upload?: { format?: (files: File[], responseText: string) => string } }
expect(options.upload?.handler).toBeDefined() const result = options.upload?.format?.([] as File[], 'invalid json')
const result = await options.upload?.handler?.([] as File[]) expect(result).toBe(JSON.stringify({
expect(result).toBe('ok') msg: 'upload response parse failed',
code: 1,
data: {
errFiles: [],
succMap: {},
},
}))
}) })
}) })

58
app/components/post-body-markdown-editor-vditor-config.ts

@ -2,7 +2,7 @@ interface BuildVditorOptionsInput {
value: string value: string
isMobile: boolean isMobile: boolean
onInput: (value: string) => void onInput: (value: string) => void
uploadHandler: (files: File[]) => Promise<string> onUploadError: () => void
} }
const DESKTOP_TOOLBAR: ReadonlyArray<string> = [ const DESKTOP_TOOLBAR: ReadonlyArray<string> = [
@ -52,12 +52,64 @@ const MOBILE_TOOLBAR: ReadonlyArray<string> = [
export function buildPostBodyMarkdownEditorVditorOptions(input: BuildVditorOptionsInput): Record<string, unknown> { export function buildPostBodyMarkdownEditorVditorOptions(input: BuildVditorOptionsInput): Record<string, unknown> {
return { return {
value: input.value, value: input.value,
lang: 'zh_CN',
cdn: '/vditor',
cache: { enable: false }, cache: { enable: false },
mode: input.isMobile ? 'ir' : 'sv', mode: 'ir',
preview: {
mode: 'editor',
actions: [] as string[],
},
toolbar: input.isMobile ? MOBILE_TOOLBAR : DESKTOP_TOOLBAR, toolbar: input.isMobile ? MOBILE_TOOLBAR : DESKTOP_TOOLBAR,
upload: { upload: {
url: '/api/file/upload',
fieldName: 'file',
multiple: true,
accept: 'image/*', accept: 'image/*',
handler: input.uploadHandler, format(files: File[], responseText: string) {
try {
const parsed = JSON.parse(responseText) as {
files?: Array<{ url?: string }>
data?: { files?: Array<{ url?: string }> }
}
const nestedFiles = parsed?.data?.files
const rootFiles = parsed?.files
const uploaded = Array.isArray(nestedFiles)
? nestedFiles
: (Array.isArray(rootFiles) ? rootFiles : [])
const succMap: Record<string, string> = {}
uploaded.forEach((item, index) => {
const url = item?.url?.trim()
if (!url) {
return
}
const fallbackName = `upload-${index + 1}`
const sourceName = files[index]?.name?.trim() || fallbackName
succMap[sourceName] = url
})
return JSON.stringify({
msg: '',
code: 0,
data: {
errFiles: [] as string[],
succMap,
},
})
}
catch {
return JSON.stringify({
msg: 'upload response parse failed',
code: 1,
data: {
errFiles: [] as string[],
succMap: {} as Record<string, string>,
},
})
}
},
error() {
input.onUploadError()
},
}, },
input(value: string) { input(value: string) {
input.onInput(value) input.onInput(value)

28
app/pages/me/posts/[id].vue

@ -1,6 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { useAuthSession } from '../../../composables/useAuthSession' import { useAuthSession } from '../../../composables/useAuthSession'
import { generateRandomPostSlug } from '../../../utils/post-slug' import { generateRandomPostSlug } from '../../../utils/post-slug'
import { clearPostPreviewDrafts, createPostPreviewDraft } from '../../../utils/post-preview-draft'
const route = useRoute() const route = useRoute()
const id = computed(() => route.params.id as string) const id = computed(() => route.params.id as string)
@ -106,6 +107,9 @@ async function save() {
visibility: state.visibility, visibility: state.visibility,
}, },
}) })
if (import.meta.client) {
clearPostPreviewDrafts()
}
await load({ silent: true }) await load({ silent: true })
toast.add({ title: '文章已保存', color: 'success' }) toast.add({ title: '文章已保存', color: 'success' })
} finally { } finally {
@ -115,6 +119,9 @@ async function save() {
async function remove() { async function remove() {
await fetchData(`/api/me/posts/${id.value}`, { method: 'DELETE' }) await fetchData(`/api/me/posts/${id.value}`, { method: 'DELETE' })
if (import.meta.client) {
clearPostPreviewDrafts()
}
toast.add({ title: '文章已删除', color: 'success' }) toast.add({ title: '文章已删除', color: 'success' })
await navigateTo('/me/posts') await navigateTo('/me/posts')
} }
@ -141,6 +148,20 @@ async function copyShareUrl() {
toast.add({ title: '复制失败,请手动复制', color: 'warning' }) toast.add({ title: '复制失败,请手动复制', color: 'warning' })
} }
} }
function openPreviewInNewPage() {
if (!import.meta.client) {
return
}
const key = createPostPreviewDraft({
title: state.title,
excerpt: state.excerpt,
bodyMarkdown: state.bodyMarkdown,
visibility: state.visibility as 'private' | 'unlisted' | 'public',
})
const url = `/me/posts/preview/draft?key=${encodeURIComponent(key)}`
window.open(url, '_blank', 'noopener')
}
</script> </script>
<template> <template>
@ -170,10 +191,10 @@ async function copyShareUrl() {
<UForm <UForm
id="edit-post-form" id="edit-post-form"
:state="state" :state="state"
class="grid gap-6 xl:grid-cols-[minmax(0,1fr)_280px]" class="grid gap-6 min-w-0 xl:grid-cols-[minmax(0,1fr)_280px]"
@submit.prevent="save" @submit.prevent="save"
> >
<div class="space-y-6"> <div class="space-y-6 min-w-0">
<UCard :ui="{ body: 'p-4 sm:p-6 space-y-4' }"> <UCard :ui="{ body: 'p-4 sm:p-6 space-y-4' }">
<UFormField label="标题" name="title" required class="w-full"> <UFormField label="标题" name="title" required class="w-full">
<UInput <UInput
@ -263,6 +284,9 @@ async function copyShareUrl() {
<h2 class="text-base font-medium"> <h2 class="text-base font-medium">
操作 操作
</h2> </h2>
<UButton type="button" color="neutral" variant="soft" block @click="openPreviewInNewPage">
预览新窗口
</UButton>
<UButton type="submit" :loading="saving" block> <UButton type="submit" :loading="saving" block>
保存文章 保存文章
</UButton> </UButton>

25
app/pages/me/posts/new.vue

@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { generateRandomPostSlug } from '../../../utils/post-slug' import { generateRandomPostSlug } from '../../../utils/post-slug'
import { clearPostPreviewDrafts, createPostPreviewDraft } from '../../../utils/post-preview-draft'
usePageTitle('新建文章') usePageTitle('新建文章')
@ -55,12 +56,29 @@ async function submit() {
}, },
}) })
const id = post.id const id = post.id
if (import.meta.client) {
clearPostPreviewDrafts()
}
toast.add({ title: '文章已创建', color: 'success' }) toast.add({ title: '文章已创建', color: 'success' })
await navigateTo(`/me/posts/${id}`) await navigateTo(`/me/posts/${id}`)
} finally { } finally {
loading.value = false loading.value = false
} }
} }
function openPreviewInNewPage() {
if (!import.meta.client) {
return
}
const key = createPostPreviewDraft({
title: state.title,
excerpt: state.excerpt,
bodyMarkdown: state.bodyMarkdown,
visibility: state.visibility as 'private' | 'unlisted' | 'public',
})
const url = `/me/posts/preview/draft?key=${encodeURIComponent(key)}`
window.open(url, '_blank', 'noopener')
}
</script> </script>
<template> <template>
@ -79,10 +97,10 @@ async function submit() {
<UForm <UForm
id="new-post-form" id="new-post-form"
:state="state" :state="state"
class="grid gap-6 xl:grid-cols-[minmax(0,1fr)_280px]" class="grid gap-6 min-w-0 xl:grid-cols-[minmax(0,1fr)_280px]"
@submit.prevent="submit" @submit.prevent="submit"
> >
<div class="space-y-6"> <div class="space-y-6 min-w-0">
<UCard :ui="{ body: 'p-4 sm:p-6 space-y-4' }"> <UCard :ui="{ body: 'p-4 sm:p-6 space-y-4' }">
<UFormField label="标题" name="title" required class="w-full"> <UFormField label="标题" name="title" required class="w-full">
<UInput <UInput
@ -142,6 +160,9 @@ async function submit() {
<h2 class="text-base font-medium"> <h2 class="text-base font-medium">
操作 操作
</h2> </h2>
<UButton type="button" color="neutral" variant="soft" block @click="openPreviewInNewPage">
预览新窗口
</UButton>
<UButton type="submit" :loading="loading" block> <UButton type="submit" :loading="loading" block>
创建文章 创建文章
</UButton> </UButton>

89
app/pages/me/posts/preview/draft.vue

@ -0,0 +1,89 @@
<script setup lang="ts">
import { readPostPreviewDraft, type PostPreviewDraftPayload } from '../../../../utils/post-preview-draft'
import { renderSafeMarkdown } from '../../../../utils/render-markdown'
const route = useRoute()
const draft = ref<PostPreviewDraftPayload | null>(null)
const previewStatus = ref<'loading' | 'missing_key' | 'invalid' | 'ready'>('loading')
const visibilityLabel = computed(() => {
if (!draft.value) {
return ''
}
if (draft.value.visibility === 'private') {
return '私密预览'
}
if (draft.value.visibility === 'unlisted') {
return '仅链接预览'
}
return '公开预览'
})
const renderedBody = computed(() => {
if (!draft.value) {
return ''
}
return renderSafeMarkdown(draft.value.bodyMarkdown)
})
onMounted(() => {
const key = typeof route.query.key === 'string' ? route.query.key : ''
if (!key) {
previewStatus.value = 'missing_key'
return
}
const payload = readPostPreviewDraft(key)
if (!payload) {
previewStatus.value = 'invalid'
return
}
draft.value = payload
previewStatus.value = 'ready'
})
usePageTitle(() => {
const t = draft.value?.title?.trim()
return t ? [t, '预览'] : ['草稿预览']
})
</script>
<template>
<UContainer class="py-10 space-y-6">
<div v-if="previewStatus === 'loading'" class="text-sm text-muted">
预览加载中
</div>
<template v-if="draft">
<div class="flex flex-wrap items-center gap-2">
<UBadge color="neutral" variant="soft">
{{ visibilityLabel }}
</UBadge>
<UBadge color="primary" variant="outline">
草稿
</UBadge>
</div>
<h1 class="text-2xl font-semibold">
{{ draft.title || '未命名文章' }}
</h1>
<p v-if="draft.excerpt" class="text-muted">
{{ draft.excerpt }}
</p>
<article
class="prose prose-neutral dark:prose-invert max-w-none prose-a:text-primary prose-img:rounded-lg prose-headings:text-highlighted prose-p:text-default prose-strong:text-highlighted markdown-body green"
v-html="renderedBody"
/>
</template>
<UAlert
v-else-if="previewStatus === 'missing_key'"
color="neutral"
title="请从编辑页打开预览"
description="当前地址缺少预览参数,请返回新建/编辑页点击“预览(新窗口)”。"
/>
<UAlert
v-else-if="previewStatus === 'invalid'"
color="warning"
title="预览内容不存在或已失效"
description="请返回新建/编辑页,重新点击预览。"
/>
</UContainer>
</template>

99
app/utils/post-preview-draft.ts

@ -0,0 +1,99 @@
export type PostPreviewDraftPayload = {
title: string
excerpt: string
bodyMarkdown: string
visibility: 'private' | 'unlisted' | 'public'
}
const PREVIEW_DRAFT_KEY_PREFIX = 'post-preview-draft:'
const PREVIEW_DRAFT_TTL_MS = 10 * 60 * 1000
type StoredPostPreviewDraftPayload = PostPreviewDraftPayload & {
createdAt: number
}
function isPostPreviewDraftKey(key: string): boolean {
return key.startsWith(PREVIEW_DRAFT_KEY_PREFIX)
}
export function createPostPreviewDraft(payload: PostPreviewDraftPayload): string {
const uniqueId = typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
? crypto.randomUUID()
: `${Date.now()}-${Math.random().toString(16).slice(2)}`
const key = `${PREVIEW_DRAFT_KEY_PREFIX}${uniqueId}`
const storedPayload: StoredPostPreviewDraftPayload = {
...payload,
createdAt: Date.now(),
}
localStorage.setItem(key, JSON.stringify(storedPayload))
return uniqueId
}
export function readPostPreviewDraft(uniqueId: string): PostPreviewDraftPayload | null {
const key = `${PREVIEW_DRAFT_KEY_PREFIX}${uniqueId}`
const raw = localStorage.getItem(key)
if (!raw) {
return null
}
try {
const parsed = JSON.parse(raw) as Partial<StoredPostPreviewDraftPayload>
if (
typeof parsed.title !== 'string' ||
typeof parsed.excerpt !== 'string' ||
typeof parsed.bodyMarkdown !== 'string' ||
typeof parsed.createdAt !== 'number' ||
(parsed.visibility !== 'private' && parsed.visibility !== 'unlisted' && parsed.visibility !== 'public')
) {
localStorage.removeItem(key)
return null
}
if (Date.now() - parsed.createdAt > PREVIEW_DRAFT_TTL_MS) {
localStorage.removeItem(key)
return null
}
return {
title: parsed.title,
excerpt: parsed.excerpt,
bodyMarkdown: parsed.bodyMarkdown,
visibility: parsed.visibility,
}
} catch {
localStorage.removeItem(key)
return null
}
}
export function clearPostPreviewDrafts(options?: { onlyExpired?: boolean }) {
const onlyExpired = options?.onlyExpired === true
const now = Date.now()
for (let index = localStorage.length - 1; index >= 0; index -= 1) {
const key = localStorage.key(index)
if (!key || !isPostPreviewDraftKey(key)) {
continue
}
if (!onlyExpired) {
localStorage.removeItem(key)
continue
}
const raw = localStorage.getItem(key)
if (!raw) {
localStorage.removeItem(key)
continue
}
try {
const parsed = JSON.parse(raw) as Partial<StoredPostPreviewDraftPayload>
if (typeof parsed.createdAt !== 'number' || now - parsed.createdAt > PREVIEW_DRAFT_TTL_MS) {
localStorage.removeItem(key)
}
} catch {
localStorage.removeItem(key)
}
}
}

5
package.json

@ -7,8 +7,9 @@
], ],
"private": true, "private": true,
"scripts": { "scripts": {
"build": "nuxt build && bun run cp:db && bun --elide-lines=0 --filter drizzle-pkg build", "build": "bun run sync:vditor && nuxt build && bun run cp:db && bun --elide-lines=0 --filter drizzle-pkg build",
"dev": "nuxt dev", "dev": "bun run sync:vditor && nuxt dev",
"sync:vditor": "sh scripts/sync-vditor-assets.sh",
"cp:db": "cp build-files/run.sh .output/run.sh && cp .env.example .output/.env && cp -r build-files/migrate/* .output/server/ && cp build-files/seed.js .output/server/seed.js", "cp:db": "cp build-files/run.sh .output/run.sh && cp .env.example .output/.env && cp -r build-files/migrate/* .output/server/ && cp build-files/seed.js .output/server/seed.js",
"migrate:test": "sh scripts/migrate-test.sh", "migrate:test": "sh scripts/migrate-test.sh",
"db:migrate": "bun --elide-lines=0 --filter drizzle-pkg migrate", "db:migrate": "bun --elide-lines=0 --filter drizzle-pkg migrate",

BIN
packages/drizzle-pkg/db.sqlite

Binary file not shown.

BIN
public/upload/1777047996547-912573909-image.webp

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

17
scripts/sync-vditor-assets.sh

@ -0,0 +1,17 @@
#!/usr/bin/env sh
set -eu
ROOT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)"
SRC_DIR="$ROOT_DIR/node_modules/vditor/dist"
DST_DIR="$ROOT_DIR/public/vditor/dist"
if [ ! -d "$SRC_DIR" ]; then
echo "skip sync: $SRC_DIR not found"
exit 0
fi
mkdir -p "$DST_DIR"
rm -rf "$DST_DIR"
mkdir -p "$DST_DIR"
cp -R "$SRC_DIR"/. "$DST_DIR"/
echo "synced vditor assets to $DST_DIR"
Loading…
Cancel
Save