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.
- package/README.md +19 -17
- package/dist/composables/fetchPublicSeo.d.ts +6 -6
- package/dist/composables/useMemiBoardComments.d.ts +3 -3
- package/dist/composables/useMemiBoardLikes.d.ts +1 -8
- package/dist/composables/useMemiBoardModeration.d.ts +1 -1
- package/dist/composables/useMemiBoardPosts.d.ts +8 -28
- package/dist/composables/useMemiBoardSettings.d.ts +27 -9
- package/dist/composables/useMemiBoardStorage.d.ts +2 -2
- package/dist/composables/useMemiBoardUserPosts.d.ts +18 -0
- package/dist/composables/useMemiBoardUsers.d.ts +2 -0
- package/dist/composables/useMemiBoardViews.d.ts +3 -7
- package/dist/config.d.ts +7 -23
- package/dist/heic2any-BXb_U1zK.js +977 -0
- package/dist/heic2any-BXb_U1zK.js.map +1 -0
- package/dist/index.d.ts +6 -3
- package/dist/index.js +887 -667
- package/dist/index.js.map +1 -1
- package/dist/rolldown-runtime-DtPi1Y-2.js +13 -0
- package/dist/runtime/components/Attachments.vue +2 -1
- package/dist/runtime/components/AuthorMenu.vue +47 -0
- package/dist/runtime/components/CategorySettings.vue +41 -7
- package/dist/runtime/components/CommentForm.vue +9 -5
- package/dist/runtime/components/CommentItem.vue +6 -1
- package/dist/runtime/components/CommentList.vue +7 -4
- package/dist/runtime/components/CommentThread.vue +16 -5
- package/dist/runtime/components/Detail.vue +97 -18
- package/dist/runtime/components/Editor.vue +14 -115
- package/dist/runtime/components/Footer.vue +9 -7
- package/dist/runtime/components/LikeButton.vue +2 -1
- package/dist/runtime/components/List.vue +14 -8
- package/dist/runtime/components/ListDefault.vue +52 -33
- package/dist/runtime/components/ListDense.vue +66 -50
- package/dist/runtime/components/ListImage.vue +10 -2
- package/dist/runtime/components/ListVideo.vue +62 -40
- package/dist/runtime/components/Settings.vue +1 -1
- package/dist/runtime/components/SwipeHint.vue +83 -0
- package/dist/runtime/components/UserPostList.vue +83 -0
- package/dist/types.d.ts +31 -17
- package/dist/utils/boardPaths.d.ts +26 -0
- package/docs/firebase-setup.md +1 -1
- package/docs/firestore.rules.example +119 -227
- package/docs/storage.rules.example +23 -25
- 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
|
-
//
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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<{
|
|
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(
|
|
14
|
+
const { addComment, addReply } = useMemiBoardComments(
|
|
15
|
+
props.boardId,
|
|
16
|
+
props.postId,
|
|
17
|
+
{ subscribe: false },
|
|
18
|
+
)
|
|
15
19
|
const { checkText } = useMemiBoardModeration()
|
|
16
|
-
const {
|
|
20
|
+
const { getBoard } = useMemiBoardSettings()
|
|
17
21
|
|
|
18
|
-
//
|
|
22
|
+
// 보드별 댓글쓰기 권한
|
|
19
23
|
const canComment = computed(() => {
|
|
20
24
|
if (!isSignedIn.value) return false
|
|
21
|
-
const required =
|
|
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(
|
|
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<{
|
|
7
|
+
const props = defineProps<{ boardId: string, postId: string }>()
|
|
8
8
|
|
|
9
|
-
const { comments, commentsPending, hasMore, loadingMore, loadMore } = useMemiBoardComments(
|
|
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<{
|
|
9
|
+
const props = defineProps<{ boardId: string, postId: string, root: CommentModel, now: number }>()
|
|
10
10
|
|
|
11
|
-
const { deleteComment } = useMemiBoardComments(
|
|
12
|
-
|
|
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
|
-
:
|
|
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
|
-
|
|
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<{
|
|
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 {
|
|
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="
|
|
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
|
-
<
|
|
172
|
-
:
|
|
173
|
-
:
|
|
174
|
-
|
|
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
|
-
<
|
|
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 :
|
|
258
|
-
<MemiBoardCommentForm :
|
|
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>
|