memi-board 0.19.3 → 0.22.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 (43) hide show
  1. package/README.md +19 -17
  2. package/dist/composables/fetchPublicSeo.d.ts +6 -6
  3. package/dist/composables/useMemiBoardComments.d.ts +3 -3
  4. package/dist/composables/useMemiBoardLikes.d.ts +1 -8
  5. package/dist/composables/useMemiBoardModeration.d.ts +1 -1
  6. package/dist/composables/useMemiBoardPosts.d.ts +8 -28
  7. package/dist/composables/useMemiBoardSettings.d.ts +27 -9
  8. package/dist/composables/useMemiBoardStorage.d.ts +2 -2
  9. package/dist/composables/useMemiBoardUserPosts.d.ts +18 -0
  10. package/dist/composables/useMemiBoardUsers.d.ts +2 -0
  11. package/dist/composables/useMemiBoardViews.d.ts +3 -7
  12. package/dist/config.d.ts +7 -23
  13. package/dist/heic2any-BXb_U1zK.js +977 -0
  14. package/dist/heic2any-BXb_U1zK.js.map +1 -0
  15. package/dist/index.d.ts +6 -3
  16. package/dist/index.js +887 -667
  17. package/dist/index.js.map +1 -1
  18. package/dist/rolldown-runtime-DtPi1Y-2.js +13 -0
  19. package/dist/runtime/components/Attachments.vue +2 -1
  20. package/dist/runtime/components/AuthorMenu.vue +47 -0
  21. package/dist/runtime/components/CategorySettings.vue +41 -7
  22. package/dist/runtime/components/CommentForm.vue +9 -5
  23. package/dist/runtime/components/CommentItem.vue +6 -1
  24. package/dist/runtime/components/CommentList.vue +7 -4
  25. package/dist/runtime/components/CommentThread.vue +16 -5
  26. package/dist/runtime/components/Detail.vue +97 -18
  27. package/dist/runtime/components/Editor.vue +14 -115
  28. package/dist/runtime/components/Footer.vue +9 -7
  29. package/dist/runtime/components/LikeButton.vue +2 -1
  30. package/dist/runtime/components/List.vue +14 -8
  31. package/dist/runtime/components/ListDefault.vue +52 -33
  32. package/dist/runtime/components/ListDense.vue +66 -50
  33. package/dist/runtime/components/ListImage.vue +10 -2
  34. package/dist/runtime/components/ListVideo.vue +62 -40
  35. package/dist/runtime/components/Settings.vue +1 -1
  36. package/dist/runtime/components/SwipeHint.vue +83 -0
  37. package/dist/runtime/components/UserPostList.vue +83 -0
  38. package/dist/types.d.ts +31 -17
  39. package/dist/utils/boardPaths.d.ts +26 -0
  40. package/docs/firebase-setup.md +1 -1
  41. package/docs/firestore.rules.example +119 -227
  42. package/docs/storage.rules.example +23 -25
  43. package/package.json +3 -2
@@ -0,0 +1,13 @@
1
+ //#region \0rolldown/runtime.js
2
+ var e = Object.create, t = Object.defineProperty, n = Object.getOwnPropertyDescriptor, r = Object.getOwnPropertyNames, i = Object.getPrototypeOf, a = Object.prototype.hasOwnProperty, o = (e, t) => () => (t || (e((t = { exports: {} }).exports, t), e = null), t.exports), s = (e, i, o, s) => {
3
+ if (i && typeof i == "object" || typeof i == "function") for (var c = r(i), l = 0, u = c.length, d; l < u; l++) d = c[l], !a.call(e, d) && d !== o && t(e, d, {
4
+ get: ((e) => i[e]).bind(null, d),
5
+ enumerable: !(s = n(i, d)) || s.enumerable
6
+ });
7
+ return e;
8
+ }, c = (n, r, o) => (o = n == null ? {} : e(i(n)), s(r || !n || !n.__esModule || !a.call(n, "default") ? t(o, "default", {
9
+ value: n,
10
+ enumerable: !0
11
+ }) : o, n));
12
+ //#endregion
13
+ export { c as n, o as t };
@@ -5,6 +5,7 @@ import { useMemiBoardStorage } from 'memi-board/runtime'
5
5
 
6
6
  const props = withDefaults(defineProps<{
7
7
  modelValue: Attachment[]
8
+ boardId: string
8
9
  /** 새 글 작성 중에도 미리 생성한 Firestore 자동 ID를 넘긴다. */
9
10
  postId: string
10
11
  editable?: boolean
@@ -43,7 +44,7 @@ async function onFileInputChange(e: Event) {
43
44
  const uploaded: Attachment[] = []
44
45
  for (const file of toUpload) {
45
46
  uploadProgress.value = 0
46
- const { promise } = uploadAttachment(file, props.postId, (ratio) => {
47
+ const { promise } = uploadAttachment(file, props.boardId, props.postId, (ratio) => {
47
48
  uploadProgress.value = ratio
48
49
  })
49
50
  uploaded.push(await promise)
@@ -0,0 +1,47 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue'
3
+
4
+ const props = withDefaults(defineProps<{
5
+ authorUid: string
6
+ authorName?: string | null
7
+ authorPhoto?: string | null
8
+ /** "작성글 보기" 링크. 없으면 항목이 비활성 상태로만 보인다. */
9
+ authorPostsTo?: (authorUid: string) => string | undefined
10
+ showAvatar?: boolean
11
+ avatarSize?: 'xs' | 'sm' | 'md'
12
+ /** 좁은 고정폭 칸에 넣을 때만 true로 — 기본은 줄이지 않고 이름을 그대로 보여준다. */
13
+ truncate?: boolean
14
+ }>(), {
15
+ showAvatar: true,
16
+ avatarSize: 'xs',
17
+ truncate: false,
18
+ })
19
+
20
+ const authorPostsLink = computed(() => props.authorPostsTo?.(props.authorUid))
21
+
22
+ /** 프로필·작성한 댓글 보기는 아직 갈 곳이 없어 비활성 — 작성글 보기만 우선 연결. */
23
+ const items = computed(() => [[
24
+ { label: '프로필 보기', icon: 'i-lucide-user-round', disabled: true },
25
+ { label: '작성글 보기', icon: 'i-lucide-notebook-text', to: authorPostsLink.value, disabled: !authorPostsLink.value },
26
+ { label: '작성한 댓글 보기', icon: 'i-lucide-message-square-text', disabled: true },
27
+ ]])
28
+ </script>
29
+
30
+ <template>
31
+ <UDropdownMenu :items="items" :content="{ align: 'start' }">
32
+ <button
33
+ type="button"
34
+ class="-mx-1 flex max-w-full items-center gap-1.5 rounded-md px-1 transition-colors hover:bg-elevated/60"
35
+ :class="truncate ? 'min-w-0' : ''"
36
+ >
37
+ <UAvatar
38
+ v-if="showAvatar"
39
+ :src="authorPhoto ?? undefined"
40
+ :alt="authorName ?? '익명'"
41
+ :size="avatarSize"
42
+ class="shrink-0"
43
+ />
44
+ <span :class="truncate ? 'min-w-0 truncate whitespace-nowrap' : 'whitespace-nowrap'">{{ authorName ?? '익명' }}</span>
45
+ </button>
46
+ </UDropdownMenu>
47
+ </template>
@@ -14,6 +14,11 @@ const props = withDefaults(defineProps<{
14
14
  * (규칙도 스태프가 이 필드 값을 바꾸는 건 별도로 막는다 — UI는 편의일 뿐 보안 경계가 아니다).
15
15
  */
16
16
  canManageStaff?: boolean
17
+ /**
18
+ * 존재하지 않는 카테고리 id로 열렸을 때 생성 폼을 보여줄지. 기본은 board-role
19
+ * 관리자만 — 스태프는 기존 카테고리 편집은 가능해도 새로 만들 수는 없다.
20
+ */
21
+ canCreate?: boolean
17
22
  }>(), { authorized: false })
18
23
 
19
24
  const emit = defineEmits<{ saved: [category: BoardCategory] }>()
@@ -37,7 +42,7 @@ const visibilityOptions: Array<{ label: string, value: BoardVisibility, descript
37
42
  const { isAdmin, rolePending } = useMemiBoardAuth()
38
43
  const { categories, settingsPending, saveCategory } = useMemiBoardSettings()
39
44
  const canManage = computed(() => isAdmin.value || props.authorized)
40
- // boardUsers 전체 목록은 rules상 board-role 관리자만 읽을 수 있다 — "허용 스태프" 지정 권한이
45
+ // users 전체 목록은 rules상 board-role 관리자만 읽을 수 있다 — "허용 스태프" 지정 권한이
41
46
  // 있을 때만(기본은 board-role 관리자) 쿼리를 시작한다. canManage(호스트 authorized 포함)로
42
47
  // 그냥 열면 스태프도 authorized=true라 여기서 다시 permission-denied가 난다.
43
48
  const canManageStaffAssignment = computed(() => props.canManageStaff ?? isAdmin.value)
@@ -46,11 +51,26 @@ const mounted = ref(false)
46
51
  onMounted(() => { mounted.value = true })
47
52
  const pending = computed(() => !mounted.value || rolePending.value || settingsPending.value)
48
53
  const source = computed(() => categories.value.find(item => item.id === props.categoryId))
54
+ const canCreate = computed(() => props.canCreate ?? isAdmin.value)
55
+ const isNew = computed(() => !source.value)
49
56
  const draft = ref<BoardCategory | null>(null)
50
57
  const saving = ref(false)
51
58
  const saved = ref(false)
52
59
  const error = ref('')
53
60
 
61
+ function emptyDraft(id: string): BoardCategory {
62
+ return {
63
+ id,
64
+ label: '',
65
+ description: '',
66
+ visibility: 'public',
67
+ listView: 'default',
68
+ writeRole: 'user',
69
+ commentWriteRole: 'user',
70
+ allowedStaffUids: [],
71
+ }
72
+ }
73
+
54
74
  // 이 카테고리에 글/댓글을 쓰려면 스태프 역할이 필요할 때만 "허용 스태프" 지정이 의미가 있다.
55
75
  const staffOptions = computed(() => users.value
56
76
  .filter(item => item.role === 'staff')
@@ -73,15 +93,19 @@ function toDraft(category: BoardCategory): BoardCategory {
73
93
 
74
94
  // 카테고리 전환·최초 로드 시에만 draft를 서버 값으로 맞춘다.
75
95
  // 저장 직후 onSnapshot 갱신으로 draft.description 이 비워지거나 입력 중이던 값이 덮이지 않게 한다.
76
- watch(() => props.categoryId, () => {
77
- draft.value = source.value ? toDraft(source.value) : null
96
+ watch(() => props.categoryId, (id) => {
97
+ if (source.value) draft.value = toDraft(source.value)
98
+ else if (canCreate.value) draft.value = emptyDraft(id)
99
+ else draft.value = null
78
100
  saved.value = false
79
101
  error.value = ''
80
102
  })
81
103
 
82
104
  watch(source, (category) => {
83
105
  if (!category) {
84
- draft.value = null
106
+ if (!draft.value || draft.value.id !== props.categoryId) {
107
+ draft.value = canCreate.value ? emptyDraft(props.categoryId) : null
108
+ }
85
109
  return
86
110
  }
87
111
  if (!draft.value || draft.value.id !== category.id) {
@@ -107,7 +131,9 @@ async function save() {
107
131
  error.value = ''
108
132
  const snapshot = { ...draft.value }
109
133
  try {
110
- await saveCategory(snapshot, snapshot.order ?? 0)
134
+ const order = isNew.value ? categories.value.length : (snapshot.order ?? 0)
135
+ await saveCategory(snapshot, order)
136
+ snapshot.order = order
111
137
  // 저장 성공 직후 draft 유지 (스냅샷 오기 전에 watch가 비우지 않도록)
112
138
  draft.value = toDraft(snapshot)
113
139
  saved.value = true
@@ -127,6 +153,14 @@ async function save() {
127
153
  <UAlert v-else-if="!canManage" color="neutral" variant="subtle" icon="i-lucide-lock" title="접근 권한이 없습니다" />
128
154
  <UAlert v-else-if="!draft" color="neutral" variant="subtle" icon="i-lucide-circle-help" title="없는 게시판입니다" description="게시판 주소를 다시 확인해 주세요." />
129
155
  <div v-else class="flex flex-col gap-5">
156
+ <UAlert
157
+ v-if="isNew"
158
+ color="neutral"
159
+ variant="subtle"
160
+ icon="i-lucide-circle-plus"
161
+ title="아직 없는 게시판입니다"
162
+ description="아래 정보를 입력하고 저장하면 이 ID로 새로 만들어집니다."
163
+ />
130
164
  <div class="grid gap-2 sm:grid-cols-[9rem_1fr] sm:items-center">
131
165
  <div><p class="text-sm font-medium">카테고리 ID</p><p class="text-xs text-muted">주소와 데이터 기준값</p></div>
132
166
  <UInput :model-value="draft.id" disabled class="font-mono" />
@@ -178,9 +212,9 @@ async function save() {
178
212
  <MemiBoardOptionCards v-model="draft.listView" :options="listViewOptions" @update:model-value="saved = false" />
179
213
  </div>
180
214
  <div class="flex justify-end border-t border-default pt-4">
181
- <UButton label="저장" icon="i-lucide-save" :loading="saving" @click="save" />
215
+ <UButton :label="isNew ? '만들기' : '저장'" :icon="isNew ? 'i-lucide-plus' : 'i-lucide-save'" :loading="saving" @click="save" />
182
216
  </div>
183
217
  <UAlert v-if="error" color="error" variant="subtle" :description="error" />
184
- <UAlert v-if="saved" color="success" variant="subtle" description="게시판 설정을 저장했습니다." />
218
+ <UAlert v-if="saved" color="success" variant="subtle" :description="isNew ? '게시판을 만들었습니다.' : '게시판 설정을 저장했습니다.'" />
185
219
  </div>
186
220
  </template>
@@ -6,19 +6,23 @@ import { useMemiBoardModeration } from 'memi-board/runtime'
6
6
  import { useMemiBoardSettings } from 'memi-board/runtime'
7
7
  import type { CommentModel } from 'memi-board/runtime'
8
8
 
9
- const props = defineProps<{ postId: string, category?: string, parent?: CommentModel | null }>()
9
+ const props = defineProps<{ boardId: string, postId: string, parent?: CommentModel | null }>()
10
10
  const emit = defineEmits<{ saved: [], cancel: [] }>()
11
11
 
12
12
  const { user, isSignedIn, isAdmin, isStaff, isWriteRestricted, restrictedMessage } = useMemiBoardAuth()
13
13
  // 목록 컴포넌트만 실시간 구독한다. 작성 폼은 mutation API만 사용한다.
14
- const { addComment, addReply } = useMemiBoardComments(props.postId, { subscribe: false })
14
+ const { addComment, addReply } = useMemiBoardComments(
15
+ props.boardId,
16
+ props.postId,
17
+ { subscribe: false },
18
+ )
15
19
  const { checkText } = useMemiBoardModeration()
16
- const { categories } = useMemiBoardSettings()
20
+ const { getBoard } = useMemiBoardSettings()
17
21
 
18
- // 카테고리별 댓글쓰기 권한 — 글쓰기 권한과 같은 등급 체계(user/staff/admin)를 그대로 재사용한다.
22
+ // 보드별 댓글쓰기 권한
19
23
  const canComment = computed(() => {
20
24
  if (!isSignedIn.value) return false
21
- const required = categories.value.find(item => item.id === props.category)?.commentWriteRole ?? 'user'
25
+ const required = getBoard(props.boardId)?.commentWriteRole ?? 'user'
22
26
  if (required === 'admin') return isAdmin.value
23
27
  if (required === 'staff') return isAdmin.value || isStaff.value
24
28
  return true
@@ -12,6 +12,7 @@ dayjs.locale('ko')
12
12
 
13
13
  const props = defineProps<{
14
14
  comment: CommentModel
15
+ boardId: string
15
16
  postId: string
16
17
  now: number
17
18
  deleting?: boolean
@@ -23,7 +24,11 @@ const emit = defineEmits<{
23
24
  }>()
24
25
 
25
26
  const { user, canEditComment, canDeleteComment, canManageContent, isWriteRestricted, restrictedMessage } = useMemiBoardAuth()
26
- const { updateComment, setCommentBlinded } = useMemiBoardComments(props.postId, { subscribe: false })
27
+ const { updateComment, setCommentBlinded } = useMemiBoardComments(
28
+ props.boardId,
29
+ props.postId,
30
+ { subscribe: false },
31
+ )
27
32
  const { checkText } = useMemiBoardModeration()
28
33
 
29
34
  const editing = ref(false)
@@ -1,12 +1,15 @@
1
1
  <script setup lang="ts">
2
- import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
2
+ import { computed, onMounted, onUnmounted, ref, toRef, watch } from 'vue'
3
3
  import { useMemiBoardComments } from 'memi-board/runtime'
4
4
  import MemiBoardCommentSkeleton from './CommentSkeleton.vue'
5
5
  import MemiBoardCommentThread from './CommentThread.vue'
6
6
 
7
- const props = defineProps<{ postId: string, category?: string }>()
7
+ const props = defineProps<{ boardId: string, postId: string }>()
8
8
 
9
- const { comments, commentsPending, hasMore, loadingMore, loadMore } = useMemiBoardComments(props.postId)
9
+ const { comments, commentsPending, hasMore, loadingMore, loadMore } = useMemiBoardComments(
10
+ toRef(props, 'boardId'),
11
+ toRef(props, 'postId'),
12
+ )
10
13
  const now = ref(Date.now())
11
14
  const loadMoreTrigger = ref<HTMLElement | null>(null)
12
15
  let relativeTimeTimer: ReturnType<typeof setInterval> | undefined
@@ -82,8 +85,8 @@ onUnmounted(() => {
82
85
  v-for="comment in comments"
83
86
  :key="comment.id"
84
87
  :root="comment"
88
+ :board-id="boardId"
85
89
  :post-id="postId"
86
- :category="category"
87
90
  :now="now"
88
91
  />
89
92
 
@@ -6,10 +6,18 @@ 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<{ postId: string, category?: string, root: CommentModel, now: number }>()
9
+ const props = defineProps<{ boardId: string, postId: string, root: CommentModel, now: number }>()
10
10
 
11
- const { deleteComment } = useMemiBoardComments(props.postId, { subscribe: false })
12
- const { replies, loading, loaded, hasMore, loadMore, refresh } = useMemiBoardReplies(props.postId, props.root.id!)
11
+ const { deleteComment } = useMemiBoardComments(
12
+ props.boardId,
13
+ props.postId,
14
+ { subscribe: false },
15
+ )
16
+ const { replies, loading, loaded, hasMore, loadMore, refresh } = useMemiBoardReplies(
17
+ props.boardId,
18
+ props.postId,
19
+ props.root.id!,
20
+ )
13
21
  const expanded = ref((props.root.replyCount ?? 0) > 0)
14
22
  const replyingTo = ref<CommentModel | null>(null)
15
23
  const deletingId = ref<string | null>(null)
@@ -68,6 +76,7 @@ async function handleDelete(comment: CommentModel) {
68
76
  <article class="flex flex-col gap-3">
69
77
  <MemiBoardCommentItem
70
78
  :comment="root"
79
+ :board-id="boardId"
71
80
  :post-id="postId"
72
81
  :now="now"
73
82
  :deleting="deletingId === root.id"
@@ -92,7 +101,8 @@ async function handleDelete(comment: CommentModel) {
92
101
  v-for="reply in replies"
93
102
  :key="reply.id"
94
103
  :comment="reply"
95
- :post-id="postId"
104
+ :board-id="boardId"
105
+ :post-id="postId"
96
106
  :now="now"
97
107
  :deleting="deletingId === reply.id"
98
108
  :class="(reply.depth ?? 1) >= 2 ? 'ml-4 sm:ml-8' : ''"
@@ -112,8 +122,9 @@ async function handleDelete(comment: CommentModel) {
112
122
 
113
123
  <MemiBoardCommentForm
114
124
  v-if="replyingTo"
125
+ :board-id="boardId"
115
126
  :post-id="postId"
116
- :category="category"
127
+
117
128
  :parent="replyingTo"
118
129
  class="ml-10"
119
130
  @saved="handleSaved"
@@ -11,8 +11,15 @@ import MemiBoardAttachments from './Attachments.vue'
11
11
  import MemiBoardCommentForm from './CommentForm.vue'
12
12
  import MemiBoardCommentList from './CommentList.vue'
13
13
  import MemiBoardLikeButton from './LikeButton.vue'
14
+ import MemiBoardSwipeHint from './SwipeHint.vue'
15
+ import MemiBoardAuthorMenu from './AuthorMenu.vue'
14
16
 
15
- const props = defineProps<{ postId: string }>()
17
+ const props = defineProps<{
18
+ boardId: string
19
+ postId: string
20
+ /** 작성자 메뉴의 "작성글 보기" 링크. 없으면 해당 항목이 비활성 상태로만 보인다. */
21
+ authorPostsTo?: (authorUid: string) => string | undefined
22
+ }>()
16
23
 
17
24
  const emit = defineEmits<{
18
25
  deleted: []
@@ -21,16 +28,18 @@ const emit = defineEmits<{
21
28
  navigate: [post: PostModel]
22
29
  }>()
23
30
 
24
- const { getAdjacentPosts, deletePost } = useMemiBoardPosts()
31
+ const { getAdjacentPosts, deletePost, publishPost } = useMemiBoardPosts(() => props.boardId)
25
32
  const { canEdit, canDelete } = useMemiBoardAuth()
26
- const { categoryLabel } = useMemiBoardSettings()
33
+ const { boardLabel } = useMemiBoardSettings()
27
34
  const { recordView } = useMemiBoardViews()
28
35
 
29
36
  // 실시간 구독 — 다른 사람의 좋아요·댓글 수 변경이 화면에 바로 반영된다.
30
37
  // 좋아요 토글도 이 구독이 그대로 비춰주므로 별도 로컬 낙관적 갱신이 필요 없다.
31
- const { post, pending: loading } = useMemiBoardPost(computed(() => props.postId))
38
+ const { post, pending: loading } = useMemiBoardPost(() => props.boardId, computed(() => props.postId))
32
39
  const notFound = computed(() => !loading.value && !post.value)
40
+ const isDraft = computed(() => post.value?.isPublished === false)
33
41
  const deleting = ref(false)
42
+ const publishing = ref(false)
34
43
  const previousPost = ref<PostModel | null>(null)
35
44
  const nextPost = ref<PostModel | null>(null)
36
45
  const now = ref(Date.now())
@@ -58,9 +67,9 @@ watch(post, async (current) => {
58
67
 
59
68
  // 상세 진입 시 조회수 +1 (로그인 불필요, 세션당 1회)
60
69
  watch(
61
- () => props.postId,
62
- (id) => {
63
- if (id) void recordView(id)
70
+ () => [props.boardId, props.postId] as const,
71
+ ([boardId, id]) => {
72
+ if (boardId && id) void recordView(boardId, id)
64
73
  },
65
74
  { immediate: true },
66
75
  )
@@ -72,6 +81,41 @@ onBeforeUnmount(() => {
72
81
  if (clock) clearInterval(clock)
73
82
  })
74
83
 
84
+ /** 모바일 터치 스와이프: 왼쪽→다음 글, 오른쪽→이전 글. */
85
+ const touchStartX = ref(0)
86
+ const touchStartY = ref(0)
87
+ const SWIPE_MIN_PX = 70
88
+
89
+ function onTouchStart(e: TouchEvent) {
90
+ const t = e.changedTouches[0]
91
+ if (!t) return
92
+ touchStartX.value = t.clientX
93
+ touchStartY.value = t.clientY
94
+ }
95
+
96
+ function onTouchEnd(e: TouchEvent) {
97
+ const t = e.changedTouches[0]
98
+ if (!t) return
99
+ const dx = t.clientX - touchStartX.value
100
+ const dy = t.clientY - touchStartY.value
101
+ if (Math.abs(dx) < SWIPE_MIN_PX) return
102
+ if (Math.abs(dy) > Math.abs(dx) * 0.65) return
103
+
104
+ if (dx < 0 && nextPost.value) emit('navigate', nextPost.value)
105
+ else if (dx > 0 && previousPost.value) emit('navigate', previousPost.value)
106
+ }
107
+
108
+ async function handlePublish() {
109
+ if (!post.value) return
110
+ publishing.value = true
111
+ try {
112
+ await publishPost(props.postId)
113
+ }
114
+ finally {
115
+ publishing.value = false
116
+ }
117
+ }
118
+
75
119
  async function handleDelete() {
76
120
  if (!post.value) return
77
121
  if (!window.confirm('이 게시글을 삭제하시겠습니까? 되돌릴 수 없습니다.')) return
@@ -96,6 +140,7 @@ const contentHtml = computed(() => {
96
140
  if (/<[a-z][\s\S]*>/i.test(raw)) return raw
97
141
  return renderMarkdownToHtml(raw)
98
142
  })
143
+
99
144
  </script>
100
145
 
101
146
  <template>
@@ -116,14 +161,35 @@ const contentHtml = computed(() => {
116
161
 
117
162
  <div
118
163
  v-else-if="post"
119
- class="flex flex-col gap-6"
164
+ class="flex flex-col gap-6 touch-pan-y"
165
+ @touchstart.passive="onTouchStart"
166
+ @touchend.passive="onTouchEnd"
120
167
  >
168
+ <div v-if="isDraft" class="flex flex-col gap-3 rounded-xl border border-warning/40 bg-warning/10 p-4 sm:flex-row sm:items-center sm:justify-between">
169
+ <div class="flex items-start gap-2">
170
+ <UIcon name="i-lucide-eye-off" class="mt-0.5 size-4 shrink-0 text-warning" />
171
+ <div>
172
+ <p class="text-sm font-medium text-highlighted">미공개 미리보기</p>
173
+ <p class="text-xs text-muted">아직 게시되지 않았습니다. 게시하기 전까지는 작성자만 볼 수 있어요.</p>
174
+ </div>
175
+ </div>
176
+ <UButton
177
+ v-if="canEdit(post)"
178
+ label="게시하기"
179
+ icon="i-lucide-send"
180
+ size="sm"
181
+ class="shrink-0"
182
+ :loading="publishing"
183
+ @click="handlePublish"
184
+ />
185
+ </div>
186
+
121
187
  <header class="flex flex-col gap-2">
122
188
  <div class="flex items-start justify-between gap-4">
123
189
  <div class="flex items-center gap-2 min-w-0">
124
190
  <UBadge
125
191
  v-if="post.category"
126
- :label="categoryLabel(post.category)"
192
+ :label="boardLabel(boardId)"
127
193
  variant="subtle"
128
194
  />
129
195
  <h1
@@ -133,6 +199,15 @@ const contentHtml = computed(() => {
133
199
  {{ post.title }}
134
200
  </h1>
135
201
  </div>
202
+ <UButton
203
+ label="목록"
204
+ icon="i-lucide-list"
205
+ color="neutral"
206
+ variant="ghost"
207
+ size="sm"
208
+ class="shrink-0"
209
+ @click="emit('list')"
210
+ />
136
211
  </div>
137
212
  <div
138
213
  v-if="post.tags?.length"
@@ -163,17 +238,18 @@ const contentHtml = computed(() => {
163
238
  <MemiBoardAttachments
164
239
  v-if="post.attachments?.length"
165
240
  :model-value="post.attachments"
241
+ :board-id="boardId"
166
242
  :post-id="postId"
167
243
  />
168
244
 
169
245
  <div class="flex flex-col items-end gap-2">
170
246
  <div class="flex items-center justify-end gap-3 text-sm text-muted">
171
- <UAvatar
172
- :src="post.authorPhoto ?? undefined"
173
- :alt="post.authorName ?? '익명'"
174
- size="xs"
247
+ <MemiBoardAuthorMenu
248
+ :author-uid="post.authorUid"
249
+ :author-name="post.authorName"
250
+ :author-photo="post.authorPhoto"
251
+ :author-posts-to="authorPostsTo"
175
252
  />
176
- <span>{{ post.authorName ?? '익명' }}</span>
177
253
  <span class="inline-flex items-center gap-1 tabular-nums">
178
254
  <UIcon name="i-lucide-eye" class="size-3.5" />
179
255
  {{ post.viewCount ?? 0 }}
@@ -214,8 +290,9 @@ const contentHtml = computed(() => {
214
290
  </div>
215
291
  </div>
216
292
 
217
- <div class="flex justify-center">
293
+ <div v-if="!isDraft" class="flex justify-center">
218
294
  <MemiBoardLikeButton
295
+ :board-id="boardId"
219
296
  :post-id="postId"
220
297
  :like-count="post.likeCount ?? 0"
221
298
  />
@@ -250,12 +327,14 @@ const contentHtml = computed(() => {
250
327
  />
251
328
  </nav>
252
329
 
253
- <section class="flex flex-col gap-4 border-t border-default pt-4">
330
+ <MemiBoardSwipeHint v-if="previousPost || nextPost" />
331
+
332
+ <section v-if="!isDraft" class="flex flex-col gap-4 border-t border-default pt-4">
254
333
  <h2 class="text-sm font-medium text-muted">
255
334
  댓글
256
335
  </h2>
257
- <MemiBoardCommentList :post-id="postId" :category="post.category" />
258
- <MemiBoardCommentForm :post-id="postId" :category="post.category" />
336
+ <MemiBoardCommentList :board-id="boardId" :post-id="postId" />
337
+ <MemiBoardCommentForm :board-id="boardId" :post-id="postId" />
259
338
  </section>
260
339
  </div>
261
340
  </template>