memi-board 0.56.2 → 0.57.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +40 -8
- package/dist/{commentImages-DsW2yCqu.js → commentImages-mIfyNHgz.js} +143 -59
- package/dist/commentImages-mIfyNHgz.js.map +1 -0
- package/dist/composables/useMemiBoardModeration.d.ts +2 -0
- package/dist/composables/useMemiBoardPosts.d.ts +1 -1
- package/dist/composables/useMemiBoardUserDirectory.d.ts +20 -0
- package/dist/composables/useMemiBoardUserProfile.d.ts +1 -1
- package/dist/composables/useMemiBoardUsers.d.ts +1 -1
- package/dist/{en-BDkfivhc.js → en-Dc7zQOy-.js} +85 -1
- package/dist/en-Dc7zQOy-.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +921 -711
- package/dist/index.js.map +1 -1
- package/dist/module.mjs +4 -0
- package/dist/module.mjs.map +1 -1
- package/dist/runtime/components/AccountDeletion.vue +64 -0
- package/dist/runtime/components/AuthorMenu.vue +4 -4
- package/dist/runtime/components/CommentFeed.vue +17 -13
- package/dist/runtime/components/CommentForm.vue +58 -56
- package/dist/runtime/components/CommentImageField.vue +6 -3
- package/dist/runtime/components/CommentItem.vue +16 -3
- package/dist/runtime/components/CommentList.vue +10 -1
- package/dist/runtime/components/CommentThread.vue +16 -2
- package/dist/runtime/components/Detail.vue +7 -1
- package/dist/runtime/components/Editor.vue +1 -5
- package/dist/runtime/components/Profile.vue +14 -2
- package/dist/runtime/components/ProfileEditModal.vue +20 -3
- package/dist/runtime/components/UserListControls.vue +29 -0
- package/dist/runtime/components/UserListPagination.vue +17 -0
- package/dist/runtime/components/UserManagementEmail.vue +21 -0
- package/dist/runtime/components/UserRoleGuide.vue +38 -0
- package/dist/runtime/components/Users.vue +21 -59
- package/dist/runtime/components/UsersSettings.vue +119 -0
- package/dist/storage.js +1 -1
- package/dist/types.d.ts +2 -0
- package/dist/utils/postSlug.d.ts +12 -0
- package/dist/utils/profileNameModeration.d.ts +8 -0
- package/dist/utils/userDirectoryPage.d.ts +19 -0
- package/docs/account-deletion.md +60 -0
- package/docs/firestore.rules.example +61 -2
- package/docs/host-integration.md +72 -9
- package/docs/storage.rules.example +7 -2
- package/package.json +3 -2
- package/scripts/backfill-post-slugs.mjs +38 -0
- package/dist/commentImages-DsW2yCqu.js.map +0 -1
- package/dist/en-BDkfivhc.js.map +0 -1
package/dist/module.mjs
CHANGED
package/dist/module.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"module.mjs","names":[],"sources":["../src/module.ts"],"sourcesContent":["// @ts-nocheck — NuxtModule 반환 타입이 @nuxt/schema 경로에 묶여 d.ts 생성 시 불안정\nimport {\n defineNuxtModule,\n createResolver,\n addComponentsDir,\n addImports,\n addPlugin,\n addTemplate,\n} from '@nuxt/kit'\nimport { existsSync } from 'node:fs'\nimport type { NuxtModule } from '@nuxt/schema'\nimport type { MemiBoardConfig } from './config'\n\ntype MemiBoardModuleOptions = Partial<MemiBoardConfig>\n\n/**\n * Nuxt 전용 모듈 — 호스트 Vite 파이프라인에 게시판 SFC와 필수 설정을 올린다.\n *\n * 하지 않는 일:\n * - nuxt-vuefire / Firebase 설정\n * - 라우트·미들웨어 등록\n *\n * 하는 일:\n * - components 디렉터리 등록 → 호스트가 SFC 컴파일 → Nuxt UI auto-import 정상 동작\n * - composable / configure 를 auto-import\n * - memiBoard 옵션을 런타임 플러그인으로 전달해 configureMemiBoard 자동 호출\n * - transpile + optimizeDeps 설정으로 linked package와 CommonJS 의존성 정규화\n */\nconst memiBoardModule: NuxtModule<MemiBoardModuleOptions> = defineNuxtModule<MemiBoardModuleOptions>({\n meta: {\n name: 'memi-board',\n configKey: 'memiBoard',\n },\n setup(options, nuxt) {\n const { resolve } = createResolver(import.meta.url)\n\n // 우선순위:\n // 1) monorepo link 시 dist/module.mjs → ../src/components (소스 직접, 재빌드 없이 UI 수정)\n // 2) publish 패키지 dist/runtime/components\n // 3) src/module.ts 로 직접 로드할 때 ./components\n const candidates = [\n resolve('../src/components'),\n resolve('./runtime/components'),\n resolve('./components'),\n ]\n const componentsDir = candidates.find(p => existsSync(p))\n if (!componentsDir) {\n throw new Error('[memi-board] components directory not found')\n }\n nuxt.options.build.transpile.push('memi-board')\n\n nuxt.options.vite ||= {}\n\n // link:/file: 설치 시 memi-board 패키지 루트(dist/ 등)가 호스트의 node_modules\n // 밖에 있어서 Vite dev 서버의 fs.allow 기본값(root + node_modules)에 안 걸린다 —\n // /@fs/ 요청이 403(\"outside of Vite serving allow list\")으로 막히고, 그 403 HTML을\n // JS로 파싱하려다 알 수 없는 SyntaxError가 난다. 패키지 루트를 명시적으로 허용한다.\n const packageRoot = resolve('..')\n nuxt.options.vite.server ||= {}\n nuxt.options.vite.server.fs ||= {}\n const fsAllow = new Set([\n ...(nuxt.options.vite.server.fs.allow || []),\n packageRoot,\n ])\n nuxt.options.vite.server.fs.allow = [...fsAllow]\n\n nuxt.options.vite.resolve ||= {}\n // link:/file: 로 설치 시 패키지 쪽 node_modules 의 vuefire·firebase 가\n // 호스트와 이중으로 잡히면 useFirestore 가 깨져 permission-denied 가 난다.\n const dedupe = new Set([\n ...(nuxt.options.vite.resolve.dedupe || []),\n 'vue',\n 'vue-router',\n 'vuefire',\n 'firebase',\n 'nuxt',\n '@tiptap/core',\n '@tiptap/pm',\n '@tiptap/vue-3',\n ])\n nuxt.options.vite.resolve.dedupe = [...dedupe]\n\n nuxt.options.vite.optimizeDeps ||= {}\n const exclude = new Set(nuxt.options.vite.optimizeDeps.exclude || [])\n exclude.add('memi-board')\n nuxt.options.vite.optimizeDeps.exclude = [...exclude]\n\n // link:/file: 개발 중엔 memi-board의 실제 경로가 host의 node_modules 밖에 있어서\n // Nuxt auto-import(unimport)의 \"node_modules는 건드리지 않는다\" 체크(경로에\n // 'node_modules' 문자열이 있는지로 판단)를 그냥 통과해버린다 — 그러면 memi-board의\n // dist 청크를 \"프로젝트 소스\"로 스캔하다가, rollup이 공유 청크의 export를 붙일 때\n // 우연히 짧은 별칭(a, b, … h …)을 골랐는데 그게 Vue의 전역 auto-import `h`\n // (hyperscript)와 겹치면 `import { h } from 'vue'`를 그 청크에 주입해버려서\n // 이미 있는 `h` export 별칭과 충돌한다(\"Identifier 'h' has already been declared\").\n // 명시적으로 제외해 이 청크는 절대 auto-import 스캔 대상이 되지 않게 한다\n // (published npm 패키지 설치 시엔 이미 node_modules 안이라 원래도 제외 대상).\n nuxt.options.imports ||= {}\n nuxt.options.imports.transform ||= {}\n const transformExclude = new Set([\n ...(nuxt.options.imports.transform.exclude || []),\n /[\\\\/]memi-board[\\\\/]dist[\\\\/]/,\n ])\n nuxt.options.imports.transform.exclude = [...transformExclude]\n\n addComponentsDir({\n path: componentsDir,\n pathPrefix: false,\n prefix: 'MemiBoard',\n // List.vue → MemiBoardList\n })\n\n // SEO: 호스트 Nuxt 가 소스를 컴파일해야 #imports / useSeoMeta 가 앱 인스턴스를 탄다.\n // dist/index.js 에 넣으면 link 시 패키지 node_modules/nuxt 로 해석되어 깨진다.\n const seoCandidates = [\n resolve('../src/composables/useMemiBoardSeo.ts'),\n resolve('./runtime/composables/useMemiBoardSeo.ts'),\n resolve('./composables/useMemiBoardSeo.ts'),\n ]\n const seoFrom = seoCandidates.find(p => existsSync(p))\n if (!seoFrom) {\n throw new Error('[memi-board] useMemiBoardSeo runtime not found')\n }\n\n const from = 'memi-board/runtime'\n addImports([\n { name: 'configureMemiBoard', from },\n { name: 'useMemiBoardConfig', from },\n { name: 'useMemiBoardAuth', from },\n { name: 'useMemiBoardPosts', from },\n { name: 'useMemiBoardComments', from },\n { name: 'useMemiBoardViews', from },\n { name: 'useMemiBoardModeration', from },\n // 이미지 압축 등 브라우저 전용 — SSR 서버 번들에 안 섞이도록 별도 서브패스에서 해석\n { name: 'useMemiBoardStorage', from: 'memi-board/storage' },\n { name: 'useMemiBoardSettings', from },\n { name: 'useMemiBoardUsers', from },\n { name: 'useMemiBoardPostSeo', from: seoFrom },\n { name: 'useMemiBoardListSeo', from: seoFrom },\n { name: 'fetchPublicPostForSeo', from },\n { name: 'fetchPublicListForSeo', from },\n { name: 'resolvePublicSeoDb', from },\n { name: 'versionHistory', from },\n { name: 'useMemiBoardI18n', from },\n { name: 'MEMI_BOARD_LOCALES', from },\n { name: 'useMemiBoardAutoTranslate', from },\n ])\n\n const runtimeConfig = JSON.stringify(options).replace(/</g, '\\\\u003c')\n const configPlugin = addTemplate({\n filename: 'memi-board.config.mjs',\n getContents: () => `\nimport { computed } from 'vue'\nimport { getApp } from 'firebase/app'\nimport { doc, getFirestore, onSnapshot } from 'firebase/firestore'\nimport {\n configureMemiBoard,\n applyMemiBoardSiteSettings,\n useMemiBoardConfig,\n isMemiBoardI18nEnabled,\n memiBoardDefaultLocale,\n loadMemiBoardLocale,\n memiBoardHostLocaleKey,\n normalizeMemiBoardLocale,\n} from 'memi-board/runtime'\n\nexport default defineNuxtPlugin({\n name: 'memi-board:config',\n // @nuxtjs/i18n 플러그인이 $i18n 을 만든 뒤 실행되어야 한다.\n dependsOn: ['i18n:plugin'],\n async setup(nuxtApp) {\n configureMemiBoard(${runtimeConfig})\n // 관리자가 보드 전체 설정 화면에서 바꾼 값(AI·국제화·기본 언어)을 호스트 설정 위에 덧씌운다.\n // 브라우저에서만 구독한다. 서버 렌더링은 호스트 설정으로 그리고, 마운트 뒤에 값이 반영된다.\n if (import.meta.client) {\n nuxtApp.hook('app:mounted', () => {\n try {\n const db = getFirestore(getApp())\n const cfg = useMemiBoardConfig()\n onSnapshot(\n doc(db, cfg.settingsCollection, '__app'),\n snapshot => applyMemiBoardSiteSettings(snapshot.data()),\n () => {},\n )\n }\n catch {\n // Firebase 가 아직 없으면 호스트 설정을 그대로 쓴다.\n }\n })\n }\n if (!isMemiBoardI18nEnabled()) {\n const fixed = normalizeMemiBoardLocale(memiBoardDefaultLocale()) ?? 'ko'\n await loadMemiBoardLocale(fixed)\n return\n }\n // 호스트가 @nuxtjs/i18n 을 쓰면 그 언어를 게시판 기본 언어로 쓴다.\n // 요청(SSR)마다 앱 인스턴스가 따로라 provide 로 넘긴다.\n const i18n = nuxtApp.$i18n\n if (!i18n?.locale) return\n const hostLocale = computed(() => i18n.locale.value)\n nuxtApp.vueApp.provide(memiBoardHostLocaleKey, hostLocale)\n const initial = normalizeMemiBoardLocale(hostLocale.value)\n if (initial) await loadMemiBoardLocale(initial)\n },\n})\n`,\n })\n addPlugin(configPlugin.dst)\n },\n})\n\nexport default memiBoardModule\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"module.mjs","names":[],"sources":["../src/module.ts"],"sourcesContent":["// @ts-nocheck — NuxtModule 반환 타입이 @nuxt/schema 경로에 묶여 d.ts 생성 시 불안정\nimport {\n defineNuxtModule,\n createResolver,\n addComponentsDir,\n addImports,\n addPlugin,\n addTemplate,\n} from '@nuxt/kit'\nimport { existsSync } from 'node:fs'\nimport type { NuxtModule } from '@nuxt/schema'\nimport type { MemiBoardConfig } from './config'\n\ntype MemiBoardModuleOptions = Partial<MemiBoardConfig>\n\n/**\n * Nuxt 전용 모듈 — 호스트 Vite 파이프라인에 게시판 SFC와 필수 설정을 올린다.\n *\n * 하지 않는 일:\n * - nuxt-vuefire / Firebase 설정\n * - 라우트·미들웨어 등록\n *\n * 하는 일:\n * - components 디렉터리 등록 → 호스트가 SFC 컴파일 → Nuxt UI auto-import 정상 동작\n * - composable / configure 를 auto-import\n * - memiBoard 옵션을 런타임 플러그인으로 전달해 configureMemiBoard 자동 호출\n * - transpile + optimizeDeps 설정으로 linked package와 CommonJS 의존성 정규화\n */\nconst memiBoardModule: NuxtModule<MemiBoardModuleOptions> = defineNuxtModule<MemiBoardModuleOptions>({\n meta: {\n name: 'memi-board',\n configKey: 'memiBoard',\n },\n setup(options, nuxt) {\n const { resolve } = createResolver(import.meta.url)\n\n // 우선순위:\n // 1) monorepo link 시 dist/module.mjs → ../src/components (소스 직접, 재빌드 없이 UI 수정)\n // 2) publish 패키지 dist/runtime/components\n // 3) src/module.ts 로 직접 로드할 때 ./components\n const candidates = [\n resolve('../src/components'),\n resolve('./runtime/components'),\n resolve('./components'),\n ]\n const componentsDir = candidates.find(p => existsSync(p))\n if (!componentsDir) {\n throw new Error('[memi-board] components directory not found')\n }\n nuxt.options.build.transpile.push('memi-board')\n\n nuxt.options.vite ||= {}\n\n // link:/file: 설치 시 memi-board 패키지 루트(dist/ 등)가 호스트의 node_modules\n // 밖에 있어서 Vite dev 서버의 fs.allow 기본값(root + node_modules)에 안 걸린다 —\n // /@fs/ 요청이 403(\"outside of Vite serving allow list\")으로 막히고, 그 403 HTML을\n // JS로 파싱하려다 알 수 없는 SyntaxError가 난다. 패키지 루트를 명시적으로 허용한다.\n const packageRoot = resolve('..')\n nuxt.options.vite.server ||= {}\n nuxt.options.vite.server.fs ||= {}\n const fsAllow = new Set([\n ...(nuxt.options.vite.server.fs.allow || []),\n packageRoot,\n ])\n nuxt.options.vite.server.fs.allow = [...fsAllow]\n\n nuxt.options.vite.resolve ||= {}\n // link:/file: 로 설치 시 패키지 쪽 node_modules 의 vuefire·firebase 가\n // 호스트와 이중으로 잡히면 useFirestore 가 깨져 permission-denied 가 난다.\n const dedupe = new Set([\n ...(nuxt.options.vite.resolve.dedupe || []),\n 'vue',\n 'vue-router',\n 'vuefire',\n 'firebase',\n 'nuxt',\n '@tiptap/core',\n '@tiptap/pm',\n '@tiptap/vue-3',\n ])\n nuxt.options.vite.resolve.dedupe = [...dedupe]\n\n nuxt.options.vite.optimizeDeps ||= {}\n const exclude = new Set(nuxt.options.vite.optimizeDeps.exclude || [])\n exclude.add('memi-board')\n nuxt.options.vite.optimizeDeps.exclude = [...exclude]\n\n // link:/file: 개발 중엔 memi-board의 실제 경로가 host의 node_modules 밖에 있어서\n // Nuxt auto-import(unimport)의 \"node_modules는 건드리지 않는다\" 체크(경로에\n // 'node_modules' 문자열이 있는지로 판단)를 그냥 통과해버린다 — 그러면 memi-board의\n // dist 청크를 \"프로젝트 소스\"로 스캔하다가, rollup이 공유 청크의 export를 붙일 때\n // 우연히 짧은 별칭(a, b, … h …)을 골랐는데 그게 Vue의 전역 auto-import `h`\n // (hyperscript)와 겹치면 `import { h } from 'vue'`를 그 청크에 주입해버려서\n // 이미 있는 `h` export 별칭과 충돌한다(\"Identifier 'h' has already been declared\").\n // 명시적으로 제외해 이 청크는 절대 auto-import 스캔 대상이 되지 않게 한다\n // (published npm 패키지 설치 시엔 이미 node_modules 안이라 원래도 제외 대상).\n nuxt.options.imports ||= {}\n nuxt.options.imports.transform ||= {}\n const transformExclude = new Set([\n ...(nuxt.options.imports.transform.exclude || []),\n /[\\\\/]memi-board[\\\\/]dist[\\\\/]/,\n ])\n nuxt.options.imports.transform.exclude = [...transformExclude]\n\n addComponentsDir({\n path: componentsDir,\n pathPrefix: false,\n prefix: 'MemiBoard',\n // List.vue → MemiBoardList\n })\n\n // SEO: 호스트 Nuxt 가 소스를 컴파일해야 #imports / useSeoMeta 가 앱 인스턴스를 탄다.\n // dist/index.js 에 넣으면 link 시 패키지 node_modules/nuxt 로 해석되어 깨진다.\n const seoCandidates = [\n resolve('../src/composables/useMemiBoardSeo.ts'),\n resolve('./runtime/composables/useMemiBoardSeo.ts'),\n resolve('./composables/useMemiBoardSeo.ts'),\n ]\n const seoFrom = seoCandidates.find(p => existsSync(p))\n if (!seoFrom) {\n throw new Error('[memi-board] useMemiBoardSeo runtime not found')\n }\n\n const from = 'memi-board/runtime'\n addImports([\n { name: 'configureMemiBoard', from },\n { name: 'useMemiBoardConfig', from },\n { name: 'useMemiBoardAuth', from },\n { name: 'useMemiBoardPosts', from },\n { name: 'useMemiBoardComments', from },\n { name: 'useMemiBoardViews', from },\n { name: 'useMemiBoardModeration', from },\n // 이미지 압축 등 브라우저 전용 — SSR 서버 번들에 안 섞이도록 별도 서브패스에서 해석\n { name: 'useMemiBoardStorage', from: 'memi-board/storage' },\n { name: 'useMemiBoardSettings', from },\n { name: 'useMemiBoardUsers', from },\n { name: 'useMemiBoardUserDirectory', from },\n { name: 'useMemiBoardPostSeo', from: seoFrom },\n { name: 'useMemiBoardListSeo', from: seoFrom },\n { name: 'fetchPublicPostForSeo', from },\n { name: 'fetchPublicListForSeo', from },\n { name: 'resolvePublicSeoDb', from },\n { name: 'versionHistory', from },\n { name: 'useMemiBoardI18n', from },\n { name: 'MEMI_BOARD_LOCALES', from },\n { name: 'useMemiBoardAutoTranslate', from },\n ])\n\n const runtimeConfig = JSON.stringify(options).replace(/</g, '\\\\u003c')\n const configPlugin = addTemplate({\n filename: 'memi-board.config.mjs',\n getContents: () => `\nimport { computed } from 'vue'\nimport { getApp } from 'firebase/app'\nimport { doc, getFirestore, onSnapshot } from 'firebase/firestore'\nimport {\n configureMemiBoard,\n applyMemiBoardSiteSettings,\n useMemiBoardConfig,\n isMemiBoardI18nEnabled,\n memiBoardDefaultLocale,\n loadMemiBoardLocale,\n memiBoardHostLocaleKey,\n normalizeMemiBoardLocale,\n} from 'memi-board/runtime'\n\nexport default defineNuxtPlugin({\n name: 'memi-board:config',\n // @nuxtjs/i18n 플러그인이 $i18n 을 만든 뒤 실행되어야 한다.\n dependsOn: ['i18n:plugin'],\n async setup(nuxtApp) {\n configureMemiBoard(${runtimeConfig})\n // 관리자가 보드 전체 설정 화면에서 바꾼 값(AI·국제화·기본 언어)을 호스트 설정 위에 덧씌운다.\n // 브라우저에서만 구독한다. 서버 렌더링은 호스트 설정으로 그리고, 마운트 뒤에 값이 반영된다.\n if (import.meta.client) {\n nuxtApp.hook('app:mounted', () => {\n try {\n const db = getFirestore(getApp())\n const cfg = useMemiBoardConfig()\n onSnapshot(\n doc(db, cfg.settingsCollection, '__app'),\n snapshot => applyMemiBoardSiteSettings(snapshot.data()),\n () => {},\n )\n }\n catch {\n // Firebase 가 아직 없으면 호스트 설정을 그대로 쓴다.\n }\n })\n }\n if (!isMemiBoardI18nEnabled()) {\n const fixed = normalizeMemiBoardLocale(memiBoardDefaultLocale()) ?? 'ko'\n await loadMemiBoardLocale(fixed)\n return\n }\n // 호스트가 @nuxtjs/i18n 을 쓰면 그 언어를 게시판 기본 언어로 쓴다.\n // 요청(SSR)마다 앱 인스턴스가 따로라 provide 로 넘긴다.\n const i18n = nuxtApp.$i18n\n if (!i18n?.locale) return\n const hostLocale = computed(() => i18n.locale.value)\n nuxtApp.vueApp.provide(memiBoardHostLocaleKey, hostLocale)\n const initial = normalizeMemiBoardLocale(hostLocale.value)\n if (initial) await loadMemiBoardLocale(initial)\n },\n})\n`,\n })\n addPlugin(configPlugin.dst)\n },\n})\n\nexport default memiBoardModule\n"],"mappings":"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"}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, ref, watch } from 'vue'
|
|
3
|
+
import { useMemiBoardI18n } from 'memi-board/runtime'
|
|
4
|
+
|
|
5
|
+
const props = withDefaults(defineProps<{
|
|
6
|
+
state?: 'idle' | 'authenticating' | 'working' | 'error'
|
|
7
|
+
processed?: number
|
|
8
|
+
error?: string
|
|
9
|
+
isOwner?: boolean
|
|
10
|
+
accountLabel?: string | null
|
|
11
|
+
lockedPolicy?: boolean | null
|
|
12
|
+
}>(), { state: 'idle', processed: 0, error: '', isOwner: false, accountLabel: null, lockedPolicy: null })
|
|
13
|
+
const emit = defineEmits<{ confirm: [options: { deleteContent: boolean }] }>()
|
|
14
|
+
const { t, formatNumber } = useMemiBoardI18n()
|
|
15
|
+
const open = ref(false)
|
|
16
|
+
const deleteContent = ref(false)
|
|
17
|
+
const acknowledged = ref(false)
|
|
18
|
+
const confirmation = ref('')
|
|
19
|
+
const busy = computed(() => props.state === 'authenticating' || props.state === 'working')
|
|
20
|
+
const canConfirm = computed(() => !busy.value && !props.isOwner && acknowledged.value && confirmation.value === 'DELETE')
|
|
21
|
+
watch(() => props.lockedPolicy, value => { if (value !== null) deleteContent.value = value }, { immediate: true })
|
|
22
|
+
function showConfirmation() {
|
|
23
|
+
if (busy.value || props.isOwner) return
|
|
24
|
+
confirmation.value = ''
|
|
25
|
+
acknowledged.value = false
|
|
26
|
+
if (props.lockedPolicy === null) deleteContent.value = false
|
|
27
|
+
open.value = true
|
|
28
|
+
}
|
|
29
|
+
</script>
|
|
30
|
+
|
|
31
|
+
<template>
|
|
32
|
+
<section class="mt-6 rounded-xl border border-error/20 bg-error/3 p-4">
|
|
33
|
+
<h3 class="text-sm font-semibold text-highlighted">{{ t('accountDeletion.title') }}</h3>
|
|
34
|
+
<p class="mt-1 text-xs leading-5 text-muted">{{ t(isOwner ? 'accountDeletion.ownerBlocked' : 'accountDeletion.intro') }}</p>
|
|
35
|
+
<UButton class="mt-3" color="error" variant="ghost" size="sm" :disabled="isOwner || busy" :loading="busy" :label="t('accountDeletion.open')" @click="showConfirmation" />
|
|
36
|
+
</section>
|
|
37
|
+
<UModal v-model:open="open" :title="t('accountDeletion.title')" :description="t('accountDeletion.description')" :dismissible="!busy" :close="!busy" :ui="{ content: 'sm:max-w-lg' }">
|
|
38
|
+
<template #body>
|
|
39
|
+
<div class="space-y-5">
|
|
40
|
+
<p v-if="accountLabel" class="break-all rounded-lg bg-muted p-3 text-sm font-medium text-highlighted">{{ accountLabel }}</p>
|
|
41
|
+
<UAlert color="warning" variant="subtle" icon="i-lucide-triangle-alert" :description="t('accountDeletion.authWarning')" />
|
|
42
|
+
<UCheckbox v-model="deleteContent" :disabled="busy || lockedPolicy !== null" :label="t('accountDeletion.deleteContent')" :description="t('accountDeletion.contentWarning')" />
|
|
43
|
+
<p class="text-xs leading-5 text-muted">{{ t('accountDeletion.retention') }}</p>
|
|
44
|
+
<p v-if="lockedPolicy !== null" class="text-xs text-warning">{{ t('accountDeletion.resumePolicy') }}</p>
|
|
45
|
+
<UCheckbox v-model="acknowledged" :disabled="busy" :label="t('accountDeletion.acknowledge')" />
|
|
46
|
+
<UFormField :label="t('accountDeletion.typeConfirmation')">
|
|
47
|
+
<UInput v-model="confirmation" :disabled="busy" placeholder="DELETE" autocomplete="off" spellcheck="false" class="w-full" @keydown.enter.prevent="canConfirm && emit('confirm', { deleteContent })" />
|
|
48
|
+
</UFormField>
|
|
49
|
+
<div v-if="busy" role="status" aria-live="polite" class="space-y-2 rounded-lg border border-default p-4">
|
|
50
|
+
<p class="text-sm font-medium text-highlighted">{{ t(state === 'authenticating' ? 'accountDeletion.authenticating' : 'accountDeletion.working') }}</p>
|
|
51
|
+
<UProgress />
|
|
52
|
+
<p class="text-xs leading-5 text-muted">{{ t('accountDeletion.progress', { count: formatNumber(processed) }) }}</p>
|
|
53
|
+
</div>
|
|
54
|
+
<UAlert v-if="error" color="error" variant="subtle" :description="error" />
|
|
55
|
+
</div>
|
|
56
|
+
</template>
|
|
57
|
+
<template #footer>
|
|
58
|
+
<div class="flex w-full justify-end gap-2">
|
|
59
|
+
<UButton color="neutral" variant="outline" :disabled="busy" :label="t('common.action.cancel')" @click="open = false" />
|
|
60
|
+
<UButton color="error" :loading="busy" :disabled="!canConfirm" :label="t(lockedPolicy !== null ? 'accountDeletion.resume' : 'accountDeletion.confirm')" @click="emit('confirm', { deleteContent })" />
|
|
61
|
+
</div>
|
|
62
|
+
</template>
|
|
63
|
+
</UModal>
|
|
64
|
+
</template>
|
|
@@ -22,13 +22,13 @@ const props = withDefaults(defineProps<{
|
|
|
22
22
|
|
|
23
23
|
const { t } = useMemiBoardI18n()
|
|
24
24
|
|
|
25
|
-
const authorProfileLink = computed(() => props.authorProfileTo?.(props.authorUid))
|
|
26
|
-
const authorPostsLink = computed(() => props.authorPostsTo?.(props.authorUid))
|
|
27
|
-
const authorCommentsLink = computed(() => props.authorCommentsTo?.(props.authorUid))
|
|
25
|
+
const authorProfileLink = computed(() => props.authorUid ? props.authorProfileTo?.(props.authorUid) : undefined)
|
|
26
|
+
const authorPostsLink = computed(() => props.authorUid ? props.authorPostsTo?.(props.authorUid) : undefined)
|
|
27
|
+
const authorCommentsLink = computed(() => props.authorUid ? props.authorCommentsTo?.(props.authorUid) : undefined)
|
|
28
28
|
|
|
29
29
|
// 다른 작성자에 대해 나만 보는 메모 — 로그인 안 했거나 내 글이면 메뉴 항목 자체를 숨긴다.
|
|
30
30
|
const { user } = useMemiBoardAuth()
|
|
31
|
-
const showMemo = computed(() => !!user.value?.uid && user.value.uid !== props.authorUid)
|
|
31
|
+
const showMemo = computed(() => !!props.authorUid && !!user.value?.uid && user.value.uid !== props.authorUid)
|
|
32
32
|
const { text: memoText, sentiment: memoSentiment, loaded: memoLoaded, saving: memoSaving, error: memoError, load: loadMemo, save: saveMemo, remove: removeMemo } = useMemiBoardAuthorMemo(() => props.authorUid)
|
|
33
33
|
const menuOpen = ref(false)
|
|
34
34
|
const memoOpen = ref(false)
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import MemiBoardSurface from './BoardSurface.vue'
|
|
3
|
+
import MemiBoardAuthorMenu from './AuthorMenu.vue'
|
|
3
4
|
import { computed, ref, resolveComponent, toRef } from 'vue'
|
|
4
5
|
import { displayCommentBody, useMemiBoardCommentFeed, useMemiBoardI18n } from 'memi-board/runtime'
|
|
5
6
|
import type { CommentFeedSort, CommentModel } from 'memi-board/runtime'
|
|
@@ -11,6 +12,9 @@ const props = withDefaults(defineProps<{
|
|
|
11
12
|
pageSize?: number
|
|
12
13
|
getPostLink?: (comment: CommentModel) => string | undefined
|
|
13
14
|
getAuthorLink?: (uid: string) => string | undefined
|
|
15
|
+
authorProfileTo?: (authorUid: string) => string | undefined
|
|
16
|
+
authorPostsTo?: (authorUid: string) => string | undefined
|
|
17
|
+
authorCommentsTo?: (authorUid: string) => string | undefined
|
|
14
18
|
boardLabel?: (boardId: string) => string | undefined
|
|
15
19
|
}>(), {
|
|
16
20
|
sort: 'latest',
|
|
@@ -32,10 +36,6 @@ function commentLink(comment: CommentModel): string | undefined {
|
|
|
32
36
|
return props.getPostLink?.(comment)
|
|
33
37
|
}
|
|
34
38
|
|
|
35
|
-
function authorLink(comment: CommentModel): string | undefined {
|
|
36
|
-
return comment.authorUid ? props.getAuthorLink?.(comment.authorUid) : undefined
|
|
37
|
-
}
|
|
38
|
-
|
|
39
39
|
function boardName(comment: CommentModel): string {
|
|
40
40
|
if (!comment.boardId) return ''
|
|
41
41
|
return props.boardLabel?.(comment.boardId) || comment.boardId
|
|
@@ -81,14 +81,19 @@ const emptyMessage = computed(() =>
|
|
|
81
81
|
class="rounded-lg border border-default p-3 transition-colors hover:bg-elevated/60"
|
|
82
82
|
>
|
|
83
83
|
<div class="flex items-center gap-2 text-xs text-muted">
|
|
84
|
-
<
|
|
85
|
-
|
|
86
|
-
:
|
|
87
|
-
|
|
88
|
-
:
|
|
89
|
-
|
|
84
|
+
<MemiBoardAuthorMenu
|
|
85
|
+
v-if="comment.authorUid"
|
|
86
|
+
:author-uid="comment.authorUid"
|
|
87
|
+
:author-name="comment.authorName"
|
|
88
|
+
:author-photo="comment.authorPhoto"
|
|
89
|
+
:show-avatar="false"
|
|
90
|
+
:author-profile-to="authorProfileTo ?? getAuthorLink"
|
|
91
|
+
:author-posts-to="authorPostsTo"
|
|
92
|
+
:author-comments-to="authorCommentsTo"
|
|
93
|
+
/>
|
|
94
|
+
<span v-else class="truncate font-medium text-highlighted">
|
|
90
95
|
{{ comment.authorName || t('common.label.anonymous') }}
|
|
91
|
-
</
|
|
96
|
+
</span>
|
|
92
97
|
<span v-if="boardName(comment)">· {{ boardName(comment) }}</span>
|
|
93
98
|
<span>· {{ formatRelativeDate(comment.createdAt, now) }}</span>
|
|
94
99
|
<span v-if="comment.parentId || comment.isReply">· {{ t('common.action.reply') }}</span>
|
|
@@ -112,8 +117,7 @@ const emptyMessage = computed(() =>
|
|
|
112
117
|
<span
|
|
113
118
|
v-if="comment.replyToName"
|
|
114
119
|
class="mr-1 text-primary"
|
|
115
|
-
>@{{ comment.replyToName }}</span
|
|
116
|
-
{{ displayCommentBody(comment, locale) }}
|
|
120
|
+
>@{{ comment.replyToName }}</span><span v-text="displayCommentBody(comment, locale)" />
|
|
117
121
|
</p>
|
|
118
122
|
<p
|
|
119
123
|
v-if="(comment.likeCount ?? 0) > 0"
|
|
@@ -134,69 +134,71 @@ onUnmounted(() => {
|
|
|
134
134
|
<template>
|
|
135
135
|
<form
|
|
136
136
|
v-if="isSignedIn && canComment"
|
|
137
|
-
class="
|
|
137
|
+
class="overflow-hidden rounded-xl border border-default bg-default"
|
|
138
138
|
@submit.prevent="handleSubmit"
|
|
139
139
|
>
|
|
140
|
-
<
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
:comment-id="draftId"
|
|
157
|
-
:disabled="submitting || isWriteRestricted"
|
|
158
|
-
@busy="imageBusy = $event"
|
|
159
|
-
/>
|
|
160
|
-
<p
|
|
161
|
-
v-if="isWriteRestricted && restrictedMessage"
|
|
162
|
-
class="text-xs text-warning"
|
|
163
|
-
>
|
|
164
|
-
{{ restrictedMessage }}
|
|
165
|
-
</p>
|
|
166
|
-
<div class="flex justify-between items-center">
|
|
167
|
-
<span class="text-xs text-dimmed">{{ formatNumber(body.length) }} / {{ formatNumber(COMMENT_BODY_MAX_LENGTH) }}</span>
|
|
168
|
-
<p
|
|
169
|
-
v-if="error"
|
|
170
|
-
class="text-xs text-error"
|
|
171
|
-
>
|
|
140
|
+
<div class="flex flex-col gap-3 p-3 sm:p-4">
|
|
141
|
+
<UTextarea
|
|
142
|
+
v-model="body"
|
|
143
|
+
@paste="imageField?.handlePaste($event)"
|
|
144
|
+
:placeholder="parent ? t('commentForm.replyTo', { name: parent.authorName || t('common.label.user') }) : t('commentForm.placeholder')"
|
|
145
|
+
:rows="3"
|
|
146
|
+
autoresize
|
|
147
|
+
:maxrows="10"
|
|
148
|
+
:disabled="submitting || aiBusy || isWriteRestricted"
|
|
149
|
+
:maxlength="COMMENT_BODY_MAX_LENGTH"
|
|
150
|
+
class="w-full"
|
|
151
|
+
/>
|
|
152
|
+
<p v-if="isWriteRestricted && restrictedMessage" class="text-xs text-warning">
|
|
153
|
+
{{ restrictedMessage }}
|
|
154
|
+
</p>
|
|
155
|
+
<p v-if="error" class="text-xs text-error" role="alert">
|
|
172
156
|
{{ error }}
|
|
173
157
|
</p>
|
|
174
|
-
<p
|
|
175
|
-
v-else-if="notice"
|
|
176
|
-
class="text-xs text-warning"
|
|
177
|
-
>
|
|
158
|
+
<p v-else-if="notice" class="text-xs text-warning" role="status">
|
|
178
159
|
{{ notice }}
|
|
179
160
|
</p>
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
:
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
<UButton
|
|
192
|
-
type="submit"
|
|
193
|
-
@pointerdown="imeSafeSubmitPointerDown"
|
|
194
|
-
@click="imeSafeSubmitClick"
|
|
195
|
-
size="sm"
|
|
196
|
-
:label="parent ? t('commentForm.submitReply') : t('commentForm.submit')"
|
|
197
|
-
:loading="submitting"
|
|
198
|
-
:disabled="!body.trim() || isWriteRestricted || imageBusy || aiBusy"
|
|
161
|
+
</div>
|
|
162
|
+
<div class="flex flex-col gap-3 border-t border-default bg-muted/30 px-3 py-2.5 sm:flex-row sm:items-center sm:px-4">
|
|
163
|
+
<MemiBoardCommentImageField
|
|
164
|
+
ref="imageField"
|
|
165
|
+
v-model="image"
|
|
166
|
+
:post-id="postId"
|
|
167
|
+
:comment-id="draftId"
|
|
168
|
+
:disabled="submitting || isWriteRestricted"
|
|
169
|
+
compact
|
|
170
|
+
class="min-w-0 flex-1"
|
|
171
|
+
@busy="imageBusy = $event"
|
|
199
172
|
/>
|
|
173
|
+
<div class="ms-auto flex shrink-0 flex-wrap items-center justify-end gap-2 sm:gap-3">
|
|
174
|
+
<MemiBoardCommentAi
|
|
175
|
+
v-model="body"
|
|
176
|
+
v-model:translations="translations"
|
|
177
|
+
:board-id="boardId"
|
|
178
|
+
:disabled="submitting || imageBusy || isWriteRestricted"
|
|
179
|
+
@busy="aiBusy = $event"
|
|
180
|
+
/>
|
|
181
|
+
<span class="text-xs text-dimmed tabular-nums">{{ formatNumber(body.length) }} / {{ formatNumber(COMMENT_BODY_MAX_LENGTH) }}</span>
|
|
182
|
+
<UButton
|
|
183
|
+
v-if="parent"
|
|
184
|
+
type="button"
|
|
185
|
+
size="sm"
|
|
186
|
+
:label="t('common.action.cancel')"
|
|
187
|
+
color="neutral"
|
|
188
|
+
variant="ghost"
|
|
189
|
+
:disabled="imageBusy || submitting || aiBusy"
|
|
190
|
+
@click="handleCancel"
|
|
191
|
+
/>
|
|
192
|
+
<UButton
|
|
193
|
+
type="submit"
|
|
194
|
+
@pointerdown="imeSafeSubmitPointerDown"
|
|
195
|
+
@click="imeSafeSubmitClick"
|
|
196
|
+
size="sm"
|
|
197
|
+
:label="parent ? t('commentForm.submitReply') : t('commentForm.submit')"
|
|
198
|
+
:loading="submitting"
|
|
199
|
+
:disabled="!body.trim() || isWriteRestricted || imageBusy || aiBusy"
|
|
200
|
+
/>
|
|
201
|
+
</div>
|
|
200
202
|
</div>
|
|
201
203
|
</form>
|
|
202
204
|
<component
|
|
@@ -9,6 +9,7 @@ const props = defineProps<{
|
|
|
9
9
|
commentId: string
|
|
10
10
|
modelValue: Attachment | null
|
|
11
11
|
disabled?: boolean
|
|
12
|
+
compact?: boolean
|
|
12
13
|
}>()
|
|
13
14
|
|
|
14
15
|
const emit = defineEmits<{
|
|
@@ -180,8 +181,9 @@ onUnmounted(() => {
|
|
|
180
181
|
<button
|
|
181
182
|
v-else
|
|
182
183
|
type="button"
|
|
183
|
-
class="rounded-md border border-dashed px-3
|
|
184
|
-
:class="dragOver ? 'border-primary bg-elevated/60' : 'border-default hover:bg-elevated/40'"
|
|
184
|
+
class="flex items-center gap-2 rounded-md border border-dashed px-3 text-left text-xs text-muted transition-colors disabled:cursor-not-allowed disabled:opacity-60"
|
|
185
|
+
:class="[compact ? 'py-2.5' : 'py-4', dragOver ? 'border-primary bg-elevated/60' : 'border-default hover:bg-elevated/40']"
|
|
186
|
+
:title="compact ? t('commentForm.attachNote') : undefined"
|
|
185
187
|
:disabled="disabled || uploading"
|
|
186
188
|
@click="openPicker"
|
|
187
189
|
@dragenter.prevent="dragOver = true"
|
|
@@ -189,6 +191,7 @@ onUnmounted(() => {
|
|
|
189
191
|
@dragleave="onDragLeave"
|
|
190
192
|
@drop.prevent="onDrop"
|
|
191
193
|
>
|
|
194
|
+
<UIcon v-if="compact" name="i-lucide-image-plus" class="size-4 shrink-0" />
|
|
192
195
|
{{ uploading ? t('commentForm.uploading') : t('commentForm.attachHint') }}
|
|
193
196
|
</button>
|
|
194
197
|
<UProgress
|
|
@@ -196,7 +199,7 @@ onUnmounted(() => {
|
|
|
196
199
|
:model-value="Math.round(progress * 100)"
|
|
197
200
|
size="sm"
|
|
198
201
|
/>
|
|
199
|
-
<p class="text-xs text-dimmed">
|
|
202
|
+
<p v-if="!compact" class="text-xs text-dimmed">
|
|
200
203
|
{{ t('commentForm.attachNote') }}
|
|
201
204
|
</p>
|
|
202
205
|
<p
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
useMemiBoardI18n,
|
|
14
14
|
useMemiBoardModeration,
|
|
15
15
|
} from 'memi-board/runtime'
|
|
16
|
+
import MemiBoardAuthorMenu from './AuthorMenu.vue'
|
|
16
17
|
import MemiBoardCommentAi from './CommentAi.vue'
|
|
17
18
|
import MemiBoardCommentImageField from './CommentImageField.vue'
|
|
18
19
|
import MemiBoardImageLightbox from './ImageLightbox.vue'
|
|
@@ -23,6 +24,9 @@ const props = defineProps<{
|
|
|
23
24
|
postId: string
|
|
24
25
|
now: number
|
|
25
26
|
deleting?: boolean
|
|
27
|
+
authorProfileTo?: (authorUid: string) => string | undefined
|
|
28
|
+
authorPostsTo?: (authorUid: string) => string | undefined
|
|
29
|
+
authorCommentsTo?: (authorUid: string) => string | undefined
|
|
26
30
|
}>()
|
|
27
31
|
|
|
28
32
|
const emit = defineEmits<{
|
|
@@ -189,7 +193,17 @@ async function toggleLike() {
|
|
|
189
193
|
/>
|
|
190
194
|
<div class="min-w-0 flex-1">
|
|
191
195
|
<div class="flex items-center gap-2">
|
|
192
|
-
<
|
|
196
|
+
<MemiBoardAuthorMenu
|
|
197
|
+
v-if="comment.authorUid"
|
|
198
|
+
:author-uid="comment.authorUid"
|
|
199
|
+
:author-name="comment.authorName"
|
|
200
|
+
:author-photo="comment.authorPhoto"
|
|
201
|
+
:show-avatar="false"
|
|
202
|
+
:author-profile-to="authorProfileTo"
|
|
203
|
+
:author-posts-to="authorPostsTo"
|
|
204
|
+
:author-comments-to="authorCommentsTo"
|
|
205
|
+
/>
|
|
206
|
+
<span v-else class="text-sm font-medium">{{ comment.authorName ?? t('common.label.anonymous') }}</span>
|
|
193
207
|
<UPopover :content="{ side: 'top' }" :ui="{ content: 'h-auto w-max' }">
|
|
194
208
|
<time
|
|
195
209
|
:datetime="date?.toISOString()"
|
|
@@ -275,8 +289,7 @@ async function toggleLike() {
|
|
|
275
289
|
</div>
|
|
276
290
|
<div v-else>
|
|
277
291
|
<p class="whitespace-pre-wrap break-words text-sm">
|
|
278
|
-
<span v-if="comment.parentId && comment.replyToName" class="mr-1 text-primary">@{{ comment.replyToName }}</span
|
|
279
|
-
{{ displayedBody }}
|
|
292
|
+
<span v-if="comment.parentId && comment.replyToName" class="mr-1 text-primary">@{{ comment.replyToName }}</span><span v-text="displayedBody" />
|
|
280
293
|
</p>
|
|
281
294
|
<button
|
|
282
295
|
v-if="localImage"
|
|
@@ -9,7 +9,13 @@ import {
|
|
|
9
9
|
import MemiBoardCommentSkeleton from './CommentSkeleton.vue'
|
|
10
10
|
import MemiBoardCommentThread from './CommentThread.vue'
|
|
11
11
|
|
|
12
|
-
const props = defineProps<{
|
|
12
|
+
const props = defineProps<{
|
|
13
|
+
boardId: string
|
|
14
|
+
postId: string
|
|
15
|
+
authorProfileTo?: (authorUid: string) => string | undefined
|
|
16
|
+
authorPostsTo?: (authorUid: string) => string | undefined
|
|
17
|
+
authorCommentsTo?: (authorUid: string) => string | undefined
|
|
18
|
+
}>()
|
|
13
19
|
|
|
14
20
|
const { t } = useMemiBoardI18n()
|
|
15
21
|
|
|
@@ -99,6 +105,9 @@ onUnmounted(() => {
|
|
|
99
105
|
:board-id="boardId"
|
|
100
106
|
:post-id="postId"
|
|
101
107
|
:now="now"
|
|
108
|
+
:author-profile-to="authorProfileTo"
|
|
109
|
+
:author-posts-to="authorPostsTo"
|
|
110
|
+
:author-comments-to="authorCommentsTo"
|
|
102
111
|
/>
|
|
103
112
|
|
|
104
113
|
<div
|
|
@@ -6,7 +6,15 @@ import MemiBoardCommentForm from './CommentForm.vue'
|
|
|
6
6
|
import MemiBoardCommentItem from './CommentItem.vue'
|
|
7
7
|
import MemiBoardCommentSkeleton from './CommentSkeleton.vue'
|
|
8
8
|
|
|
9
|
-
const props = defineProps<{
|
|
9
|
+
const props = defineProps<{
|
|
10
|
+
boardId: string
|
|
11
|
+
postId: string
|
|
12
|
+
root: CommentModel
|
|
13
|
+
now: number
|
|
14
|
+
authorProfileTo?: (authorUid: string) => string | undefined
|
|
15
|
+
authorPostsTo?: (authorUid: string) => string | undefined
|
|
16
|
+
authorCommentsTo?: (authorUid: string) => string | undefined
|
|
17
|
+
}>()
|
|
10
18
|
|
|
11
19
|
const { t } = useMemiBoardI18n()
|
|
12
20
|
|
|
@@ -81,6 +89,9 @@ async function handleDelete(comment: CommentModel) {
|
|
|
81
89
|
:board-id="boardId"
|
|
82
90
|
:post-id="postId"
|
|
83
91
|
:now="now"
|
|
92
|
+
:author-profile-to="authorProfileTo"
|
|
93
|
+
:author-posts-to="authorPostsTo"
|
|
94
|
+
:author-comments-to="authorCommentsTo"
|
|
84
95
|
:deleting="deletingId === root.id"
|
|
85
96
|
@reply="startReply"
|
|
86
97
|
@delete="handleDelete(root)"
|
|
@@ -104,8 +115,11 @@ async function handleDelete(comment: CommentModel) {
|
|
|
104
115
|
:key="reply.id"
|
|
105
116
|
:comment="reply"
|
|
106
117
|
:board-id="boardId"
|
|
107
|
-
|
|
118
|
+
:post-id="postId"
|
|
108
119
|
:now="now"
|
|
120
|
+
:author-profile-to="authorProfileTo"
|
|
121
|
+
:author-posts-to="authorPostsTo"
|
|
122
|
+
:author-comments-to="authorCommentsTo"
|
|
109
123
|
:deleting="deletingId === reply.id"
|
|
110
124
|
:class="(reply.depth ?? 1) >= 2 ? 'ml-4 sm:ml-8' : ''"
|
|
111
125
|
@reply="startReply"
|
|
@@ -435,7 +435,13 @@ watch(() => props.postId, () => {
|
|
|
435
435
|
<h2 class="text-sm font-medium text-muted">
|
|
436
436
|
{{ t('detail.comments') }}
|
|
437
437
|
</h2>
|
|
438
|
-
<MemiBoardCommentList
|
|
438
|
+
<MemiBoardCommentList
|
|
439
|
+
:board-id="boardId"
|
|
440
|
+
:post-id="postId"
|
|
441
|
+
:author-profile-to="authorProfileTo"
|
|
442
|
+
:author-posts-to="authorPostsTo"
|
|
443
|
+
:author-comments-to="authorCommentsTo"
|
|
444
|
+
/>
|
|
439
445
|
<MemiBoardCommentForm :board-id="boardId" :post-id="postId" />
|
|
440
446
|
</section>
|
|
441
447
|
|
|
@@ -50,7 +50,7 @@ const emit = defineEmits<{ saved: [id: string], cancel: [] }>()
|
|
|
50
50
|
|
|
51
51
|
const { user, isSignedIn, isWriteRestricted, restrictedMessage, canManageBoard } = useMemiBoardAuth()
|
|
52
52
|
const resolvedBoardId = computed(() => props.boardId || props.fixedCategory || '')
|
|
53
|
-
const { createPostId, getPost, createPost, updatePost,
|
|
53
|
+
const { createPostId, getPost, createPost, updatePost, setPostPinned } = useMemiBoardPosts(resolvedBoardId)
|
|
54
54
|
const { getBoard, ensureSettings } = useMemiBoardSettings()
|
|
55
55
|
// 보드 설정 언어 → 호스트 언어 순.
|
|
56
56
|
const { t, dir, locale, translationEnabled, aiEnabled } = useMemiBoardI18n({ boardLocale: () => getBoard(resolvedBoardId.value)?.locale })
|
|
@@ -1161,9 +1161,6 @@ async function handleSubmit() {
|
|
|
1161
1161
|
|
|
1162
1162
|
saving.value = true
|
|
1163
1163
|
submitHint.value = t('editor.hint.reviewing')
|
|
1164
|
-
// 새 글 slug 중복 확인을 검열과 동시에 시작 — 검열에 걸리면 결과만 버린다.
|
|
1165
|
-
const slugPromise = !props.postId && !imageBoard ? resolveUniqueSlug(title.value) : null
|
|
1166
|
-
slugPromise?.catch(() => {})
|
|
1167
1164
|
try {
|
|
1168
1165
|
const plain = plainTextFromHtml(content.value)
|
|
1169
1166
|
const moderationText = `${title.value}\n${plain}`
|
|
@@ -1226,7 +1223,6 @@ async function handleSubmit() {
|
|
|
1226
1223
|
else {
|
|
1227
1224
|
const id = await createPost({
|
|
1228
1225
|
...payload,
|
|
1229
|
-
slug: (await slugPromise) ?? undefined,
|
|
1230
1226
|
postId: attachmentNamespace.value,
|
|
1231
1227
|
authorUid: user.value.uid,
|
|
1232
1228
|
authorName: user.value.displayName,
|
|
@@ -6,6 +6,8 @@ import MemiBoardProfileEditModal from './ProfileEditModal.vue'
|
|
|
6
6
|
|
|
7
7
|
const props = defineProps<{
|
|
8
8
|
uid: string
|
|
9
|
+
/** 본인 프로필의 수정 버튼 대신 표시할 호스트 계정 페이지 주소. */
|
|
10
|
+
accountTo?: string
|
|
9
11
|
}>()
|
|
10
12
|
|
|
11
13
|
const { profile, profilePending } = useMemiBoardUserProfile(() => props.uid)
|
|
@@ -84,7 +86,17 @@ const aiUsage = computed(() => {
|
|
|
84
86
|
</p>
|
|
85
87
|
</div>
|
|
86
88
|
<UButton
|
|
87
|
-
v-if="isOwnProfile"
|
|
89
|
+
v-if="isOwnProfile && accountTo"
|
|
90
|
+
:to="accountTo"
|
|
91
|
+
:label="t('profile.myAccount')"
|
|
92
|
+
icon="i-lucide-user-round"
|
|
93
|
+
color="neutral"
|
|
94
|
+
variant="outline"
|
|
95
|
+
size="sm"
|
|
96
|
+
class="shrink-0"
|
|
97
|
+
/>
|
|
98
|
+
<UButton
|
|
99
|
+
v-else-if="isOwnProfile"
|
|
88
100
|
:label="t('common.action.edit')"
|
|
89
101
|
icon="i-lucide-pencil"
|
|
90
102
|
color="neutral"
|
|
@@ -125,7 +137,7 @@ const aiUsage = computed(() => {
|
|
|
125
137
|
</div>
|
|
126
138
|
|
|
127
139
|
<MemiBoardProfileEditModal
|
|
128
|
-
v-if="isOwnProfile"
|
|
140
|
+
v-if="isOwnProfile && !accountTo"
|
|
129
141
|
v-model:open="editOpen"
|
|
130
142
|
:uid="uid"
|
|
131
143
|
:profile="profile"
|