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.
Files changed (46) hide show
  1. package/README.md +40 -8
  2. package/dist/{commentImages-DsW2yCqu.js → commentImages-mIfyNHgz.js} +143 -59
  3. package/dist/commentImages-mIfyNHgz.js.map +1 -0
  4. package/dist/composables/useMemiBoardModeration.d.ts +2 -0
  5. package/dist/composables/useMemiBoardPosts.d.ts +1 -1
  6. package/dist/composables/useMemiBoardUserDirectory.d.ts +20 -0
  7. package/dist/composables/useMemiBoardUserProfile.d.ts +1 -1
  8. package/dist/composables/useMemiBoardUsers.d.ts +1 -1
  9. package/dist/{en-BDkfivhc.js → en-Dc7zQOy-.js} +85 -1
  10. package/dist/en-Dc7zQOy-.js.map +1 -0
  11. package/dist/index.d.ts +2 -0
  12. package/dist/index.js +921 -711
  13. package/dist/index.js.map +1 -1
  14. package/dist/module.mjs +4 -0
  15. package/dist/module.mjs.map +1 -1
  16. package/dist/runtime/components/AccountDeletion.vue +64 -0
  17. package/dist/runtime/components/AuthorMenu.vue +4 -4
  18. package/dist/runtime/components/CommentFeed.vue +17 -13
  19. package/dist/runtime/components/CommentForm.vue +58 -56
  20. package/dist/runtime/components/CommentImageField.vue +6 -3
  21. package/dist/runtime/components/CommentItem.vue +16 -3
  22. package/dist/runtime/components/CommentList.vue +10 -1
  23. package/dist/runtime/components/CommentThread.vue +16 -2
  24. package/dist/runtime/components/Detail.vue +7 -1
  25. package/dist/runtime/components/Editor.vue +1 -5
  26. package/dist/runtime/components/Profile.vue +14 -2
  27. package/dist/runtime/components/ProfileEditModal.vue +20 -3
  28. package/dist/runtime/components/UserListControls.vue +29 -0
  29. package/dist/runtime/components/UserListPagination.vue +17 -0
  30. package/dist/runtime/components/UserManagementEmail.vue +21 -0
  31. package/dist/runtime/components/UserRoleGuide.vue +38 -0
  32. package/dist/runtime/components/Users.vue +21 -59
  33. package/dist/runtime/components/UsersSettings.vue +119 -0
  34. package/dist/storage.js +1 -1
  35. package/dist/types.d.ts +2 -0
  36. package/dist/utils/postSlug.d.ts +12 -0
  37. package/dist/utils/profileNameModeration.d.ts +8 -0
  38. package/dist/utils/userDirectoryPage.d.ts +19 -0
  39. package/docs/account-deletion.md +60 -0
  40. package/docs/firestore.rules.example +61 -2
  41. package/docs/host-integration.md +72 -9
  42. package/docs/storage.rules.example +7 -2
  43. package/package.json +3 -2
  44. package/scripts/backfill-post-slugs.mjs +38 -0
  45. package/dist/commentImages-DsW2yCqu.js.map +0 -1
  46. package/dist/en-BDkfivhc.js.map +0 -1
package/dist/module.mjs CHANGED
@@ -86,6 +86,10 @@ var s = a({
86
86
  name: "useMemiBoardUsers",
87
87
  from: g
88
88
  },
89
+ {
90
+ name: "useMemiBoardUserDirectory",
91
+ from: g
92
+ },
89
93
  {
90
94
  name: "useMemiBoardPostSeo",
91
95
  from: h
@@ -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
- <component
85
- :is="authorLink(comment) ? NuxtLink : 'span'"
86
- :to="authorLink(comment)"
87
- class="truncate font-medium text-highlighted"
88
- :class="authorLink(comment) ? 'hover:underline' : ''"
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
- </component>
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="flex flex-col gap-2"
137
+ class="overflow-hidden rounded-xl border border-default bg-default"
138
138
  @submit.prevent="handleSubmit"
139
139
  >
140
- <UTextarea
141
- v-model="body"
142
- @paste="imageField?.handlePaste($event)"
143
- :placeholder="parent ? t('commentForm.replyTo', { name: parent.authorName || t('common.label.user') }) : t('commentForm.placeholder')"
144
- :rows="2"
145
- autoresize
146
- :maxrows="10"
147
- :disabled="submitting || aiBusy || isWriteRestricted"
148
- :maxlength="COMMENT_BODY_MAX_LENGTH"
149
- />
150
- <MemiBoardCommentAi v-model="body" v-model:translations="translations" :board-id="boardId"
151
- :disabled="submitting || imageBusy || isWriteRestricted" @busy="aiBusy = $event" />
152
- <MemiBoardCommentImageField
153
- ref="imageField"
154
- v-model="image"
155
- :post-id="postId"
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
- <div class="flex-1" />
181
- <UButton
182
- v-if="parent"
183
- type="button"
184
- size="sm"
185
- :label="t('common.action.cancel')"
186
- color="neutral"
187
- variant="ghost"
188
- :disabled="imageBusy || submitting || aiBusy"
189
- @click="handleCancel"
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 py-4 text-left text-xs text-muted transition-colors disabled:cursor-not-allowed disabled:opacity-60"
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
- <span class="text-sm font-medium">{{ comment.authorName ?? t('common.label.anonymous') }}</span>
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<{ boardId: string, postId: string }>()
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<{ boardId: string, postId: string, root: CommentModel, now: number }>()
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
- :post-id="postId"
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 :board-id="boardId" :post-id="postId" />
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, resolveUniqueSlug, setPostPinned } = useMemiBoardPosts(resolvedBoardId)
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"