memi-board 0.32.0 → 0.37.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 +17 -2
- package/dist/composables/boardLookup.d.ts +8 -0
- package/dist/composables/useMemiBoardAuth.d.ts +4 -3
- package/dist/composables/useMemiBoardCommentFeed.d.ts +18 -0
- package/dist/composables/useMemiBoardComments.d.ts +2 -0
- package/dist/composables/useMemiBoardLikes.d.ts +9 -0
- package/dist/composables/useMemiBoardMiniPosts.d.ts +12 -0
- package/dist/composables/useMemiBoardReports.d.ts +114 -0
- package/dist/composables/useMemiBoardSections.d.ts +13 -0
- package/dist/composables/useMemiBoardSettings.d.ts +2 -0
- package/dist/config.d.ts +2 -0
- package/dist/{heic2any-BWRKffWN.js → heic2any-D2QKy06h.js} +2 -2
- package/dist/{heic2any-BWRKffWN.js.map → heic2any-D2QKy06h.js.map} +1 -1
- package/dist/index.d.ts +12 -3
- package/dist/index.js +1161 -647
- package/dist/index.js.map +1 -1
- package/dist/runtime/components/CategorySettings.vue +3 -5
- package/dist/runtime/components/CommentFeed.vue +141 -0
- package/dist/runtime/components/CommentForm.vue +2 -2
- package/dist/runtime/components/CommentItem.vue +55 -5
- package/dist/runtime/components/CommentList.vue +10 -2
- package/dist/runtime/components/Detail.vue +8 -0
- package/dist/runtime/components/ListDefault.vue +2 -2
- package/dist/runtime/components/ListDense.vue +2 -2
- package/dist/runtime/components/ListVideo.vue +2 -2
- package/dist/runtime/components/Mini.vue +37 -0
- package/dist/runtime/components/MiniCard.vue +51 -0
- package/dist/runtime/components/MiniComments.vue +75 -0
- package/dist/runtime/components/MiniList.vue +36 -0
- package/dist/runtime/components/MiniPicker.vue +131 -0
- package/dist/runtime/components/MiniPost.vue +52 -0
- package/dist/runtime/components/MiniPosts.vue +115 -0
- package/dist/runtime/components/ReportButton.vue +117 -0
- package/dist/runtime/components/ReportList.vue +124 -0
- package/dist/runtime/components/Section.vue +62 -0
- package/dist/runtime/components/Settings.vue +3 -4
- package/dist/runtime/components/UserCommentList.vue +3 -0
- package/dist/{slugify-B5XHr4SZ.js → slugify-Cyq-Gg8X.js} +47 -36
- package/dist/slugify-Cyq-Gg8X.js.map +1 -0
- package/dist/storage.js +11 -11
- package/dist/types.d.ts +54 -3
- package/dist/utils/boardAccess.d.ts +7 -0
- package/dist/utils/boardPaths.d.ts +10 -3
- package/dist/utils/deletePostCascade.d.ts +7 -1
- package/dist/utils/section.d.ts +31 -0
- package/docs/firestore.indexes.json.example +38 -0
- package/docs/firestore.rules.example +78 -22
- package/package.json +1 -1
- package/dist/slugify-B5XHr4SZ.js.map +0 -1
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, onMounted, ref } from 'vue'
|
|
3
|
+
import { getDocs, limit as fbLimit, orderBy, query, where } from 'firebase/firestore'
|
|
4
|
+
import { useFirestore } from 'vuefire'
|
|
5
|
+
import type { PostModel } from 'memi-board/runtime'
|
|
6
|
+
import { miniPostTitle, useBoardPathConfig, useMemiBoardSettings } from 'memi-board/runtime'
|
|
7
|
+
import { postsCol } from '../utils/boardPaths'
|
|
8
|
+
|
|
9
|
+
const props = defineProps<{
|
|
10
|
+
modelValue: string[]
|
|
11
|
+
max: number
|
|
12
|
+
}>()
|
|
13
|
+
|
|
14
|
+
const emit = defineEmits<{
|
|
15
|
+
'update:modelValue': [value: string[]]
|
|
16
|
+
}>()
|
|
17
|
+
|
|
18
|
+
const db = useFirestore()
|
|
19
|
+
const cfg = () => useBoardPathConfig()
|
|
20
|
+
const { categoryLabel } = useMemiBoardSettings()
|
|
21
|
+
const catalog = ref<PostModel[]>([])
|
|
22
|
+
const pending = ref(true)
|
|
23
|
+
const q = ref('')
|
|
24
|
+
|
|
25
|
+
const selected = computed(() => props.modelValue)
|
|
26
|
+
|
|
27
|
+
const filtered = computed(() => {
|
|
28
|
+
const needle = q.value.trim().toLowerCase()
|
|
29
|
+
const rows = catalog.value.filter(post => !selected.value.includes(post.id || ''))
|
|
30
|
+
if (!needle) return rows.slice(0, 20)
|
|
31
|
+
return rows.filter((post) => {
|
|
32
|
+
const title = miniPostTitle(post).toLowerCase()
|
|
33
|
+
return title.includes(needle) || (post.slug || '').toLowerCase().includes(needle)
|
|
34
|
+
}).slice(0, 20)
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
const picked = computed(() =>
|
|
38
|
+
selected.value
|
|
39
|
+
.map(id => catalog.value.find(post => post.id === id))
|
|
40
|
+
.filter((item): item is PostModel => !!item)
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
const full = computed(() => selected.value.length >= props.max)
|
|
44
|
+
|
|
45
|
+
onMounted(async () => {
|
|
46
|
+
try {
|
|
47
|
+
const snapshot = await getDocs(query(
|
|
48
|
+
postsCol(db, cfg()),
|
|
49
|
+
where('isPublished', '==', true),
|
|
50
|
+
where('listed', '==', true),
|
|
51
|
+
orderBy('createdAt', 'desc'),
|
|
52
|
+
fbLimit(80),
|
|
53
|
+
))
|
|
54
|
+
catalog.value = snapshot.docs.map(item => ({ id: item.id, ...item.data() }) as PostModel)
|
|
55
|
+
}
|
|
56
|
+
finally {
|
|
57
|
+
pending.value = false
|
|
58
|
+
}
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
function add(id: string) {
|
|
62
|
+
if (!id || selected.value.includes(id) || full.value) return
|
|
63
|
+
emit('update:modelValue', [...selected.value, id])
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function remove(id: string) {
|
|
67
|
+
emit('update:modelValue', selected.value.filter(item => item !== id))
|
|
68
|
+
}
|
|
69
|
+
</script>
|
|
70
|
+
|
|
71
|
+
<template>
|
|
72
|
+
<div class="flex flex-col gap-3 border-t border-default p-3">
|
|
73
|
+
<div class="flex flex-wrap items-center justify-between gap-2">
|
|
74
|
+
<p class="text-xs text-muted">
|
|
75
|
+
고른 글 {{ selected.length }} / {{ max }}
|
|
76
|
+
<span v-if="max >= 2"> · 2개부터 캐러셀, 4개는 그리드</span>
|
|
77
|
+
</p>
|
|
78
|
+
<UInput
|
|
79
|
+
v-model="q"
|
|
80
|
+
size="sm"
|
|
81
|
+
icon="i-lucide-search"
|
|
82
|
+
placeholder="제목 검색"
|
|
83
|
+
class="w-48"
|
|
84
|
+
/>
|
|
85
|
+
</div>
|
|
86
|
+
|
|
87
|
+
<div v-if="picked.length" class="flex flex-col gap-1">
|
|
88
|
+
<div
|
|
89
|
+
v-for="post in picked"
|
|
90
|
+
:key="post.id"
|
|
91
|
+
class="flex items-center gap-2 rounded-lg bg-elevated/50 px-2 py-1.5"
|
|
92
|
+
>
|
|
93
|
+
<p class="min-w-0 flex-1 truncate text-sm">
|
|
94
|
+
{{ miniPostTitle(post) }}
|
|
95
|
+
<span class="text-xs text-muted">
|
|
96
|
+
· {{ categoryLabel(post.category || post.boardId) }}
|
|
97
|
+
</span>
|
|
98
|
+
</p>
|
|
99
|
+
<UButton
|
|
100
|
+
icon="i-lucide-x"
|
|
101
|
+
size="xs"
|
|
102
|
+
color="neutral"
|
|
103
|
+
variant="ghost"
|
|
104
|
+
square
|
|
105
|
+
@click="remove(post.id!)"
|
|
106
|
+
/>
|
|
107
|
+
</div>
|
|
108
|
+
</div>
|
|
109
|
+
|
|
110
|
+
<p v-if="pending" class="text-xs text-muted">글을 불러오는 중…</p>
|
|
111
|
+
<ul v-else class="flex max-h-48 flex-col gap-0.5 overflow-y-auto">
|
|
112
|
+
<li v-for="post in filtered" :key="post.id">
|
|
113
|
+
<button
|
|
114
|
+
type="button"
|
|
115
|
+
class="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm hover:bg-elevated disabled:opacity-40"
|
|
116
|
+
:disabled="full"
|
|
117
|
+
@click="add(post.id!)"
|
|
118
|
+
>
|
|
119
|
+
<UIcon name="i-lucide-plus" class="size-3.5 shrink-0 text-muted" />
|
|
120
|
+
<span class="min-w-0 truncate">{{ miniPostTitle(post) }}</span>
|
|
121
|
+
<span class="shrink-0 text-[11px] text-muted">
|
|
122
|
+
{{ categoryLabel(post.category || post.boardId) }}
|
|
123
|
+
</span>
|
|
124
|
+
</button>
|
|
125
|
+
</li>
|
|
126
|
+
<li v-if="!filtered.length" class="px-2 py-3 text-center text-xs text-muted">
|
|
127
|
+
더 고를 글이 없습니다.
|
|
128
|
+
</li>
|
|
129
|
+
</ul>
|
|
130
|
+
</div>
|
|
131
|
+
</template>
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed } from 'vue'
|
|
3
|
+
import type { BoardSection, PostModel } from 'memi-board/runtime'
|
|
4
|
+
import { miniPostsView, miniViewGroup, useMemiBoardMiniPicked, useMemiBoardSettings } from 'memi-board/runtime'
|
|
5
|
+
import MemiBoardMiniCard from './MiniCard.vue'
|
|
6
|
+
import MemiBoardMiniPosts from './MiniPosts.vue'
|
|
7
|
+
|
|
8
|
+
const props = defineProps<{
|
|
9
|
+
section: BoardSection
|
|
10
|
+
getPostLink?: (post: PostModel) => string | undefined
|
|
11
|
+
}>()
|
|
12
|
+
|
|
13
|
+
const { posts, pending } = useMemiBoardMiniPicked(() => props.section.postIds)
|
|
14
|
+
const { getBoard } = useMemiBoardSettings()
|
|
15
|
+
const view = computed(() => miniPostsView(posts.value, getBoard))
|
|
16
|
+
const useCarousel = computed(() =>
|
|
17
|
+
miniViewGroup(view.value) === 'list' && posts.value.length >= 2 && posts.value.length !== 4,
|
|
18
|
+
)
|
|
19
|
+
</script>
|
|
20
|
+
|
|
21
|
+
<template>
|
|
22
|
+
<div class="flex min-h-0 flex-1 flex-col">
|
|
23
|
+
<div v-if="pending" class="grid grid-cols-2 gap-2 p-2">
|
|
24
|
+
<USkeleton v-for="i in Math.min(section.count, 4)" :key="i" class="aspect-[4/3] w-full" />
|
|
25
|
+
</div>
|
|
26
|
+
<p
|
|
27
|
+
v-else-if="!posts.length"
|
|
28
|
+
class="flex flex-1 items-center justify-center px-3 py-6 text-sm text-muted"
|
|
29
|
+
>
|
|
30
|
+
고른 게시물이 없습니다.
|
|
31
|
+
</p>
|
|
32
|
+
<UCarousel
|
|
33
|
+
v-else-if="useCarousel"
|
|
34
|
+
v-slot="{ item }"
|
|
35
|
+
:items="posts"
|
|
36
|
+
arrows
|
|
37
|
+
dots
|
|
38
|
+
loop
|
|
39
|
+
:ui="{ item: 'basis-full ps-0' }"
|
|
40
|
+
class="min-w-0 p-2"
|
|
41
|
+
>
|
|
42
|
+
<MemiBoardMiniCard :post="item" :get-post-link="getPostLink" />
|
|
43
|
+
</UCarousel>
|
|
44
|
+
<MemiBoardMiniPosts
|
|
45
|
+
v-else
|
|
46
|
+
:posts="posts"
|
|
47
|
+
:view="view"
|
|
48
|
+
show-board
|
|
49
|
+
:get-post-link="getPostLink"
|
|
50
|
+
/>
|
|
51
|
+
</div>
|
|
52
|
+
</template>
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, ref, resolveComponent } from 'vue'
|
|
3
|
+
import type { BoardListView, PostModel } from 'memi-board/runtime'
|
|
4
|
+
import {
|
|
5
|
+
formatRelativeDate,
|
|
6
|
+
miniPostImage,
|
|
7
|
+
miniPostLink,
|
|
8
|
+
miniPostTitle,
|
|
9
|
+
miniViewGroup,
|
|
10
|
+
useMemiBoardSettings,
|
|
11
|
+
videoListCoverUrl,
|
|
12
|
+
} from 'memi-board/runtime'
|
|
13
|
+
|
|
14
|
+
const props = defineProps<{
|
|
15
|
+
posts: PostModel[]
|
|
16
|
+
view: BoardListView
|
|
17
|
+
showBoard?: boolean
|
|
18
|
+
getPostLink?: (post: PostModel) => string | undefined
|
|
19
|
+
}>()
|
|
20
|
+
|
|
21
|
+
const NuxtLink = resolveComponent('NuxtLink')
|
|
22
|
+
const now = ref(Date.now())
|
|
23
|
+
const { categoryLabel } = useMemiBoardSettings()
|
|
24
|
+
const group = computed(() => miniViewGroup(props.view))
|
|
25
|
+
|
|
26
|
+
function cover(post: PostModel) {
|
|
27
|
+
return videoListCoverUrl(post) || miniPostImage(post)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function isVideo(post: PostModel) {
|
|
31
|
+
return props.view === 'video' || Boolean(post.videoUrl)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function postTo(post: PostModel) {
|
|
35
|
+
return props.getPostLink?.(post) ?? miniPostLink(post)
|
|
36
|
+
}
|
|
37
|
+
</script>
|
|
38
|
+
|
|
39
|
+
<template>
|
|
40
|
+
<div
|
|
41
|
+
v-if="group === 'media'"
|
|
42
|
+
class="grid grid-cols-2 gap-1.5 p-1.5"
|
|
43
|
+
>
|
|
44
|
+
<component
|
|
45
|
+
:is="postTo(post) ? NuxtLink : 'div'"
|
|
46
|
+
v-for="post in posts"
|
|
47
|
+
:key="post.id"
|
|
48
|
+
:to="postTo(post)"
|
|
49
|
+
class="group relative aspect-[4/3] overflow-hidden rounded-lg bg-elevated"
|
|
50
|
+
>
|
|
51
|
+
<img
|
|
52
|
+
v-if="cover(post)"
|
|
53
|
+
:src="cover(post)"
|
|
54
|
+
:alt="miniPostTitle(post)"
|
|
55
|
+
class="size-full object-cover transition duration-300 group-hover:scale-105"
|
|
56
|
+
>
|
|
57
|
+
<div v-else class="flex size-full items-center justify-center text-muted">
|
|
58
|
+
<UIcon :name="isVideo(post) ? 'i-lucide-video' : 'i-lucide-image'" class="size-7" />
|
|
59
|
+
</div>
|
|
60
|
+
<span
|
|
61
|
+
v-if="isVideo(post)"
|
|
62
|
+
class="absolute inset-0 flex items-center justify-center bg-black/10"
|
|
63
|
+
>
|
|
64
|
+
<span class="flex size-9 items-center justify-center rounded-full bg-black/70 text-white">
|
|
65
|
+
<UIcon name="i-lucide-play" class="ml-0.5 size-4 fill-current" />
|
|
66
|
+
</span>
|
|
67
|
+
</span>
|
|
68
|
+
<span class="absolute right-1.5 top-1.5 inline-flex items-center gap-0.5 rounded-full bg-black/55 px-1.5 py-px text-[11px] tabular-nums text-white">
|
|
69
|
+
<UIcon name="i-lucide-message-square" class="size-3" />
|
|
70
|
+
{{ post.commentCount ?? 0 }}
|
|
71
|
+
</span>
|
|
72
|
+
</component>
|
|
73
|
+
</div>
|
|
74
|
+
|
|
75
|
+
<ul
|
|
76
|
+
v-else
|
|
77
|
+
class="flex min-h-0 flex-1 flex-col divide-y divide-default"
|
|
78
|
+
>
|
|
79
|
+
<li v-for="post in posts" :key="post.id">
|
|
80
|
+
<component
|
|
81
|
+
:is="postTo(post) ? NuxtLink : 'div'"
|
|
82
|
+
:to="postTo(post)"
|
|
83
|
+
class="memi-board-post-link flex items-center gap-2 px-3 py-2 text-highlighted transition-colors hover:bg-elevated/60"
|
|
84
|
+
>
|
|
85
|
+
<div class="min-w-0 flex-1">
|
|
86
|
+
<div class="flex min-w-0 items-start gap-1.5">
|
|
87
|
+
<p class="min-w-0 flex-1 truncate text-sm font-medium text-inherit">
|
|
88
|
+
{{ miniPostTitle(post) }}
|
|
89
|
+
</p>
|
|
90
|
+
<span class="mt-0.5 inline-flex shrink-0 items-center gap-0.5 text-[11px] tabular-nums text-muted">
|
|
91
|
+
<UIcon name="i-lucide-message-square" class="size-3" />
|
|
92
|
+
{{ post.commentCount ?? 0 }}
|
|
93
|
+
</span>
|
|
94
|
+
</div>
|
|
95
|
+
<p class="mt-0.5 flex flex-wrap items-center gap-x-1.5 text-[11px] text-muted">
|
|
96
|
+
<span v-if="showBoard && (post.boardId || post.category)">
|
|
97
|
+
{{ categoryLabel(post.category || post.boardId) }}
|
|
98
|
+
</span>
|
|
99
|
+
<time>{{ formatRelativeDate(post.createdAt, now) }}</time>
|
|
100
|
+
</p>
|
|
101
|
+
</div>
|
|
102
|
+
<div
|
|
103
|
+
v-if="miniPostImage(post)"
|
|
104
|
+
class="size-11 shrink-0 overflow-hidden rounded-md bg-elevated"
|
|
105
|
+
>
|
|
106
|
+
<img
|
|
107
|
+
:src="miniPostImage(post)!"
|
|
108
|
+
:alt="miniPostTitle(post)"
|
|
109
|
+
class="size-full object-cover"
|
|
110
|
+
>
|
|
111
|
+
</div>
|
|
112
|
+
</component>
|
|
113
|
+
</li>
|
|
114
|
+
</ul>
|
|
115
|
+
</template>
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, ref } from 'vue'
|
|
3
|
+
import {
|
|
4
|
+
REPORT_DETAIL_MAX_LENGTH,
|
|
5
|
+
REPORT_REASONS,
|
|
6
|
+
useMemiBoardAuth,
|
|
7
|
+
useMemiBoardReports,
|
|
8
|
+
} from 'memi-board/runtime'
|
|
9
|
+
import type { BoardReportReason } from 'memi-board/runtime'
|
|
10
|
+
|
|
11
|
+
const props = defineProps<{
|
|
12
|
+
boardId: string
|
|
13
|
+
postId: string
|
|
14
|
+
authorUid: string
|
|
15
|
+
authorName?: string | null
|
|
16
|
+
postTitle?: string | null
|
|
17
|
+
}>()
|
|
18
|
+
|
|
19
|
+
const { isSignedIn, user } = useMemiBoardAuth()
|
|
20
|
+
const { hasReported, pending, submitReport } = useMemiBoardReports(props.boardId, props.postId)
|
|
21
|
+
|
|
22
|
+
const open = ref(false)
|
|
23
|
+
const reason = ref<BoardReportReason>('spam')
|
|
24
|
+
const detail = ref('')
|
|
25
|
+
const error = ref('')
|
|
26
|
+
const done = ref(false)
|
|
27
|
+
|
|
28
|
+
const isOwnPost = computed(() => !!user.value && user.value.uid === props.authorUid)
|
|
29
|
+
const canReport = computed(() => isSignedIn.value && !isOwnPost.value)
|
|
30
|
+
|
|
31
|
+
function openModal() {
|
|
32
|
+
if (hasReported.value || pending.value) return
|
|
33
|
+
reason.value = 'spam'
|
|
34
|
+
detail.value = ''
|
|
35
|
+
error.value = ''
|
|
36
|
+
done.value = false
|
|
37
|
+
open.value = true
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function submit() {
|
|
41
|
+
error.value = ''
|
|
42
|
+
try {
|
|
43
|
+
await submitReport({
|
|
44
|
+
reason: reason.value,
|
|
45
|
+
detail: detail.value,
|
|
46
|
+
postTitle: props.postTitle || '',
|
|
47
|
+
authorUid: props.authorUid,
|
|
48
|
+
authorName: props.authorName ?? null,
|
|
49
|
+
})
|
|
50
|
+
done.value = true
|
|
51
|
+
}
|
|
52
|
+
catch (cause) {
|
|
53
|
+
error.value = cause instanceof Error ? cause.message : '신고하지 못했습니다.'
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
</script>
|
|
57
|
+
|
|
58
|
+
<template>
|
|
59
|
+
<template v-if="canReport">
|
|
60
|
+
<UButton
|
|
61
|
+
icon="i-lucide-flag"
|
|
62
|
+
size="sm"
|
|
63
|
+
variant="ghost"
|
|
64
|
+
:color="hasReported ? 'warning' : 'neutral'"
|
|
65
|
+
:label="hasReported ? '신고함' : '신고'"
|
|
66
|
+
:disabled="hasReported"
|
|
67
|
+
@click="openModal"
|
|
68
|
+
/>
|
|
69
|
+
<UModal v-model:open="open" title="게시물 신고">
|
|
70
|
+
<template #body>
|
|
71
|
+
<div v-if="done" class="py-4 text-sm text-muted">
|
|
72
|
+
신고를 접수했습니다. 스태프가 내용을 확인합니다.
|
|
73
|
+
</div>
|
|
74
|
+
<form
|
|
75
|
+
v-else
|
|
76
|
+
class="flex flex-col gap-3"
|
|
77
|
+
@submit.prevent="submit"
|
|
78
|
+
>
|
|
79
|
+
<URadioGroup
|
|
80
|
+
v-model="reason"
|
|
81
|
+
:items="REPORT_REASONS"
|
|
82
|
+
value-key="value"
|
|
83
|
+
legend="사유"
|
|
84
|
+
/>
|
|
85
|
+
<UTextarea
|
|
86
|
+
v-model="detail"
|
|
87
|
+
:rows="3"
|
|
88
|
+
:maxlength="REPORT_DETAIL_MAX_LENGTH"
|
|
89
|
+
placeholder="자세한 내용 (선택)"
|
|
90
|
+
/>
|
|
91
|
+
<p class="text-right text-xs text-dimmed">
|
|
92
|
+
{{ detail.length.toLocaleString() }} / {{ REPORT_DETAIL_MAX_LENGTH.toLocaleString() }}
|
|
93
|
+
</p>
|
|
94
|
+
<p v-if="error" class="text-xs text-error">
|
|
95
|
+
{{ error }}
|
|
96
|
+
</p>
|
|
97
|
+
<div class="flex justify-end gap-2">
|
|
98
|
+
<UButton
|
|
99
|
+
type="button"
|
|
100
|
+
color="neutral"
|
|
101
|
+
variant="ghost"
|
|
102
|
+
label="취소"
|
|
103
|
+
:disabled="pending"
|
|
104
|
+
@click="open = false"
|
|
105
|
+
/>
|
|
106
|
+
<UButton
|
|
107
|
+
type="submit"
|
|
108
|
+
color="warning"
|
|
109
|
+
label="신고하기"
|
|
110
|
+
:loading="pending"
|
|
111
|
+
/>
|
|
112
|
+
</div>
|
|
113
|
+
</form>
|
|
114
|
+
</template>
|
|
115
|
+
</UModal>
|
|
116
|
+
</template>
|
|
117
|
+
</template>
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { ref, resolveComponent } from 'vue'
|
|
3
|
+
import {
|
|
4
|
+
formatRelativeDate,
|
|
5
|
+
reportReasonLabel,
|
|
6
|
+
useMemiBoardAuth,
|
|
7
|
+
useMemiBoardReportQueue,
|
|
8
|
+
} from 'memi-board/runtime'
|
|
9
|
+
import type { BoardReportModel } from 'memi-board/runtime'
|
|
10
|
+
|
|
11
|
+
const props = defineProps<{
|
|
12
|
+
boardId?: string
|
|
13
|
+
getPostLink?: (report: BoardReportModel) => string | undefined
|
|
14
|
+
}>()
|
|
15
|
+
|
|
16
|
+
const NuxtLink = resolveComponent('NuxtLink')
|
|
17
|
+
const { canManageContent } = useMemiBoardAuth()
|
|
18
|
+
const { reports, pending, loadingMore, hasMore, loadError, loadMore, dismissReport, actionReport } = useMemiBoardReportQueue({
|
|
19
|
+
boardId: () => props.boardId || '',
|
|
20
|
+
})
|
|
21
|
+
const now = ref(Date.now())
|
|
22
|
+
const actingId = ref<string | null>(null)
|
|
23
|
+
|
|
24
|
+
function postLink(report: BoardReportModel) {
|
|
25
|
+
return props.getPostLink?.(report)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function handle(report: BoardReportModel, action: 'dismiss' | 'action') {
|
|
29
|
+
if (!report.id) return
|
|
30
|
+
actingId.value = report.id
|
|
31
|
+
try {
|
|
32
|
+
if (action === 'dismiss') await dismissReport(report)
|
|
33
|
+
else await actionReport(report)
|
|
34
|
+
}
|
|
35
|
+
finally {
|
|
36
|
+
actingId.value = null
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
</script>
|
|
40
|
+
|
|
41
|
+
<template>
|
|
42
|
+
<div class="flex flex-col gap-3">
|
|
43
|
+
<p
|
|
44
|
+
v-if="!canManageContent"
|
|
45
|
+
class="py-8 text-center text-sm text-muted"
|
|
46
|
+
>
|
|
47
|
+
스태프만 신고 목록을 볼 수 있습니다.
|
|
48
|
+
</p>
|
|
49
|
+
<template v-else-if="pending">
|
|
50
|
+
<USkeleton v-for="i in 4" :key="i" class="h-16 w-full" />
|
|
51
|
+
</template>
|
|
52
|
+
<p
|
|
53
|
+
v-else-if="loadError"
|
|
54
|
+
class="py-8 text-center text-sm text-error"
|
|
55
|
+
>
|
|
56
|
+
목록을 불러오지 못했습니다: {{ loadError }}
|
|
57
|
+
</p>
|
|
58
|
+
<p
|
|
59
|
+
v-else-if="!reports.length"
|
|
60
|
+
class="py-8 text-center text-sm text-muted"
|
|
61
|
+
>
|
|
62
|
+
대기 중인 신고가 없습니다.
|
|
63
|
+
</p>
|
|
64
|
+
<ul
|
|
65
|
+
v-else
|
|
66
|
+
class="flex flex-col divide-y divide-default overflow-hidden rounded-lg border border-default"
|
|
67
|
+
>
|
|
68
|
+
<li
|
|
69
|
+
v-for="report in reports"
|
|
70
|
+
:key="report.id"
|
|
71
|
+
class="flex flex-col gap-2 px-3 py-3 sm:flex-row sm:items-start sm:justify-between"
|
|
72
|
+
>
|
|
73
|
+
<div class="min-w-0 flex-1">
|
|
74
|
+
<component
|
|
75
|
+
:is="postLink(report) ? NuxtLink : 'p'"
|
|
76
|
+
:to="postLink(report)"
|
|
77
|
+
class="truncate text-sm font-medium text-highlighted"
|
|
78
|
+
:class="postLink(report) ? 'hover:underline' : ''"
|
|
79
|
+
>
|
|
80
|
+
{{ report.postTitle || '제목 없는 글' }}
|
|
81
|
+
</component>
|
|
82
|
+
<p class="mt-0.5 text-xs text-muted">
|
|
83
|
+
{{ reportReasonLabel(report.reason) }}
|
|
84
|
+
<span v-if="report.authorName"> · 작성 {{ report.authorName }}</span>
|
|
85
|
+
· {{ formatRelativeDate(report.createdAt, now) }}
|
|
86
|
+
</p>
|
|
87
|
+
<p
|
|
88
|
+
v-if="report.detail"
|
|
89
|
+
class="mt-1 line-clamp-3 text-sm text-toned"
|
|
90
|
+
>
|
|
91
|
+
{{ report.detail }}
|
|
92
|
+
</p>
|
|
93
|
+
</div>
|
|
94
|
+
<div class="flex shrink-0 items-center gap-1">
|
|
95
|
+
<UButton
|
|
96
|
+
size="xs"
|
|
97
|
+
color="neutral"
|
|
98
|
+
variant="soft"
|
|
99
|
+
label="기각"
|
|
100
|
+
:loading="actingId === report.id"
|
|
101
|
+
@click="handle(report, 'dismiss')"
|
|
102
|
+
/>
|
|
103
|
+
<UButton
|
|
104
|
+
size="xs"
|
|
105
|
+
color="warning"
|
|
106
|
+
variant="soft"
|
|
107
|
+
label="확인"
|
|
108
|
+
:loading="actingId === report.id"
|
|
109
|
+
@click="handle(report, 'action')"
|
|
110
|
+
/>
|
|
111
|
+
</div>
|
|
112
|
+
</li>
|
|
113
|
+
</ul>
|
|
114
|
+
<UButton
|
|
115
|
+
v-if="canManageContent && !pending && hasMore"
|
|
116
|
+
variant="outline"
|
|
117
|
+
color="neutral"
|
|
118
|
+
label="더 보기"
|
|
119
|
+
block
|
|
120
|
+
:loading="loadingMore"
|
|
121
|
+
@click="loadMore"
|
|
122
|
+
/>
|
|
123
|
+
</div>
|
|
124
|
+
</template>
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed } from 'vue'
|
|
3
|
+
import type { BoardSection, CommentModel, PostModel } from 'memi-board/runtime'
|
|
4
|
+
import { miniMoreLink, sectionColClass, sectionMinHeight } from 'memi-board/runtime'
|
|
5
|
+
import MemiBoardMiniComments from './MiniComments.vue'
|
|
6
|
+
import MemiBoardMiniList from './MiniList.vue'
|
|
7
|
+
import MemiBoardMiniPost from './MiniPost.vue'
|
|
8
|
+
|
|
9
|
+
const props = defineProps<{
|
|
10
|
+
section: BoardSection
|
|
11
|
+
getPostLink?: (post: PostModel) => string | undefined
|
|
12
|
+
getCommentLink?: (comment: CommentModel) => string | undefined
|
|
13
|
+
getMoreLink?: (section: BoardSection) => string | undefined
|
|
14
|
+
}>()
|
|
15
|
+
|
|
16
|
+
const moreTo = computed(() => props.getMoreLink?.(props.section) ?? miniMoreLink(props.section))
|
|
17
|
+
</script>
|
|
18
|
+
|
|
19
|
+
<template>
|
|
20
|
+
<article
|
|
21
|
+
class="flex flex-col overflow-hidden rounded-xl border border-default bg-default"
|
|
22
|
+
:class="[sectionColClass(section.cols), sectionMinHeight(section.height)]"
|
|
23
|
+
>
|
|
24
|
+
<header
|
|
25
|
+
v-if="section.showTitle || section.kind === 'list' || section.kind === 'comments'"
|
|
26
|
+
class="flex items-center justify-between gap-2 border-b border-default px-3 py-2"
|
|
27
|
+
>
|
|
28
|
+
<h2
|
|
29
|
+
v-if="section.showTitle"
|
|
30
|
+
class="min-w-0 truncate text-sm font-semibold text-highlighted"
|
|
31
|
+
>
|
|
32
|
+
{{ section.title }}
|
|
33
|
+
</h2>
|
|
34
|
+
<span v-else class="min-w-0" />
|
|
35
|
+
<UButton
|
|
36
|
+
v-if="section.kind === 'list' || section.kind === 'comments'"
|
|
37
|
+
:to="moreTo"
|
|
38
|
+
variant="link"
|
|
39
|
+
color="neutral"
|
|
40
|
+
size="xs"
|
|
41
|
+
trailing-icon="i-lucide-chevron-right"
|
|
42
|
+
class="shrink-0 px-0"
|
|
43
|
+
label="더 보기"
|
|
44
|
+
/>
|
|
45
|
+
</header>
|
|
46
|
+
<MemiBoardMiniList
|
|
47
|
+
v-if="section.kind === 'list'"
|
|
48
|
+
:section="section"
|
|
49
|
+
:get-post-link="getPostLink"
|
|
50
|
+
/>
|
|
51
|
+
<MemiBoardMiniComments
|
|
52
|
+
v-else-if="section.kind === 'comments'"
|
|
53
|
+
:section="section"
|
|
54
|
+
:get-comment-link="getCommentLink"
|
|
55
|
+
/>
|
|
56
|
+
<MemiBoardMiniPost
|
|
57
|
+
v-else
|
|
58
|
+
:section="section"
|
|
59
|
+
:get-post-link="getPostLink"
|
|
60
|
+
/>
|
|
61
|
+
</article>
|
|
62
|
+
</template>
|
|
@@ -56,8 +56,7 @@ const staffOptions = computed(() => users.value
|
|
|
56
56
|
.filter(item => item.role === 'staff')
|
|
57
57
|
.map(item => ({ label: item.displayName || item.id, value: item.id })))
|
|
58
58
|
function needsStaffPicker(category: BoardCategory) {
|
|
59
|
-
return
|
|
60
|
-
&& canManageStaffAssignment.value
|
|
59
|
+
return canManageStaffAssignment.value
|
|
61
60
|
}
|
|
62
61
|
// Firebase의 SSR pending 값과 클라이언트 첫 값이 달라질 수 있다. 최초 hydration은
|
|
63
62
|
// 양쪽 모두 로딩 화면으로 맞춘 뒤 mounted 이후 실제 상태를 렌더링한다.
|
|
@@ -247,14 +246,14 @@ async function runDeleteAll() {
|
|
|
247
246
|
/>
|
|
248
247
|
</div>
|
|
249
248
|
<div v-if="needsStaffPicker(category)" class="grid gap-2 sm:grid-cols-[9rem_1fr] sm:items-center">
|
|
250
|
-
<div><p class="text-sm font-medium"
|
|
249
|
+
<div><p class="text-sm font-medium">담당 스태프</p><p class="text-xs text-muted">지정한 스태프만 이 보드를 설정·관리합니다. 비우면 관리자만</p></div>
|
|
251
250
|
<USelectMenu
|
|
252
251
|
v-model="category.allowedStaffUids"
|
|
253
252
|
:items="staffOptions"
|
|
254
253
|
value-key="value"
|
|
255
254
|
label-key="label"
|
|
256
255
|
multiple
|
|
257
|
-
placeholder="스태프
|
|
256
|
+
placeholder="관리자만 (스태프 미지정)"
|
|
258
257
|
@update:model-value="savedId = null"
|
|
259
258
|
/>
|
|
260
259
|
</div>
|
|
@@ -85,6 +85,9 @@ function commentLink(comment: CommentModel): string | undefined {
|
|
|
85
85
|
</p>
|
|
86
86
|
<p class="mt-1 text-xs text-muted">
|
|
87
87
|
{{ formatRelativeDate(comment.createdAt, now) }}
|
|
88
|
+
<template v-if="(comment.likeCount ?? 0) > 0">
|
|
89
|
+
· 좋아요 {{ comment.likeCount }}
|
|
90
|
+
</template>
|
|
88
91
|
</p>
|
|
89
92
|
</component>
|
|
90
93
|
</li>
|