mirror of
https://github.com/ngthanhvu/kr-phim.git
synced 2026-08-10 14:27:47 +00:00
feat: enhance comment section with reply functionality and pinning feature
- Added ability to pin comments for admin users. - Implemented nested replies with a recursive component for better organization. - Improved comment loading with time-ago formatting for replies. - Refactored comment fetching logic to include replies in a structured format. - Updated UI to reflect changes in comment state (pinned, anonymous). - Added loading indicators for comment submission and reply actions. - Enhanced error handling for comment operations.
This commit is contained in:
@@ -0,0 +1,143 @@
|
||||
<script setup lang="ts">
|
||||
import { CornerDownLeft, Crown, Loader2, ThumbsDown, ThumbsUp, Trash2, User } from 'lucide-vue-next'
|
||||
|
||||
const props = defineProps<{
|
||||
replies: any[]
|
||||
depth?: number
|
||||
user: any
|
||||
parentId: number
|
||||
isReplyingTo: number | null
|
||||
replyContent: Record<number, string>
|
||||
isReplyAnonymous: Record<number, boolean>
|
||||
isSubmittingReply?: Record<number, boolean>
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'start-reply': [id: number]
|
||||
'cancel-reply': [id: number]
|
||||
'submit-reply': [id: number]
|
||||
'vote': [commentId: number, vote: number, isReply: boolean, parentId: number]
|
||||
'delete': [id: number]
|
||||
}>()
|
||||
|
||||
const depth = props.depth ?? 0
|
||||
const maxDepth = 10
|
||||
|
||||
function handleVote(replyId: number, vote: number) {
|
||||
emit('vote', replyId, vote, true, props.parentId)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TransitionGroup name="reply-list" tag="div" :style="{ marginLeft: depth > 0 ? '12px' : '0' }">
|
||||
<div v-for="reply in replies" :key="reply.id" class="flex items-start gap-3 py-3">
|
||||
<div class="shrink-0 relative">
|
||||
<div class="size-8 rounded-full bg-white/10 flex items-center justify-center"
|
||||
:class="reply.userRole === 'admin' && !reply.anonymous ? 'ring-2 ring-yellow-400 ring-offset-1 ring-offset-[#0d0f17]' : ''">
|
||||
<img v-if="reply.userAvatar && !reply.anonymous" :src="reply.userAvatar"
|
||||
class="size-8 rounded-full object-cover" alt="" />
|
||||
<User v-else class="size-3.5 text-slate-500" />
|
||||
</div>
|
||||
<div v-if="reply.userRole === 'admin' && !reply.anonymous"
|
||||
class="absolute -top-0.5 -left-0.5 size-3.5 bg-cinek-500 rounded-full flex items-center justify-center">
|
||||
<Crown class="size-2 text-slate-950" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex flex-wrap items-center gap-2 mb-1">
|
||||
<span v-if="reply.userRole === 'admin' && !reply.anonymous"
|
||||
class="px-1.5 py-0.5 rounded text-[9px] font-black bg-yellow-400 text-slate-900">ADMIN</span>
|
||||
<span class="text-xs font-semibold text-white">{{ reply.userName }}</span>
|
||||
<span v-if="reply.anonymous"
|
||||
class="px-1 py-0.5 rounded text-[9px] font-medium bg-white/10 text-slate-400">Ẩn
|
||||
danh</span>
|
||||
<span class="text-xs text-slate-600">{{ reply.timeAgo || '' }}</span>
|
||||
</div>
|
||||
<p class="text-xs text-slate-400 whitespace-pre-wrap wrap-break-word leading-relaxed">{{ reply.content }}</p>
|
||||
<div class="flex items-center gap-3 mt-2">
|
||||
<button type="button" @click="handleVote(reply.id, reply.userVote === 1 ? 0 : 1)"
|
||||
class="flex items-center gap-1 text-xs"
|
||||
:class="reply.userVote === 1 ? 'text-cinek-400' : 'text-slate-600 hover:text-slate-400'">
|
||||
<ThumbsUp class="size-3" /><span>{{ reply.likeCount || 0 }}</span>
|
||||
</button>
|
||||
<button type="button" @click="handleVote(reply.id, reply.userVote === -1 ? 0 : -1)"
|
||||
class="flex items-center gap-1 text-xs"
|
||||
:class="reply.userVote === -1 ? 'text-red-400' : 'text-slate-600 hover:text-slate-400'">
|
||||
<ThumbsDown class="size-3" /><span>{{ reply.dislikeCount || 0 }}</span>
|
||||
</button>
|
||||
<button v-if="depth < maxDepth" type="button" @click="emit('start-reply', reply.id)"
|
||||
class="flex items-center gap-1 text-xs text-slate-600 hover:text-slate-400">
|
||||
<CornerDownLeft class="size-3" /><span>Trả lời</span>
|
||||
</button>
|
||||
<button v-if="user && (user.id === reply.userId || user.role === 'admin')" type="button"
|
||||
@click="emit('delete', reply.id)" class="text-xs text-slate-600 hover:text-red-400 disabled:opacity-50"
|
||||
:disabled="props.isDeleting?.has(reply.id)">
|
||||
<Loader2 v-if="props.isDeleting?.has(reply.id)" class="size-3 animate-spin" />
|
||||
<Trash2 v-else class="size-3" />
|
||||
</button>
|
||||
</div>
|
||||
<!-- Reply form -->
|
||||
<div v-if="isReplyingTo === reply.id" class="mt-2 flex items-start gap-2">
|
||||
<div class="size-5 rounded-full bg-white/10 flex items-center justify-center shrink-0">
|
||||
<User class="size-2.5 text-slate-500" />
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<textarea v-model="replyContent[reply.id]" rows="2" maxlength="1000"
|
||||
class="w-full rounded-lg bg-[#0d0f17] border border-white/10 px-2 py-1.5 text-xs text-white placeholder:text-slate-600 focus:border-cinek-500/50 focus:outline-none resize-none transition"
|
||||
placeholder="Viết trả lời..." />
|
||||
<div class="flex items-center gap-2 mt-1.5">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="text-[10px] text-slate-500">Công khai</span>
|
||||
<button type="button" @click="isReplyAnonymous[reply.id] = !isReplyAnonymous[reply.id]"
|
||||
class="relative w-7 h-3.5 rounded-full transition-colors"
|
||||
:class="(isReplyAnonymous[reply.id] ?? false) ? 'bg-cinek-500' : 'bg-white/20'">
|
||||
<span class="absolute top-0.5 left-0.5 size-2.5 rounded-full bg-white shadow transition-transform"
|
||||
:class="(isReplyAnonymous[reply.id] ?? false) ? 'translate-x-3.5' : 'translate-x-0'" />
|
||||
</button>
|
||||
<span class="text-[10px]"
|
||||
:class="(isReplyAnonymous[reply.id] ?? false) ? 'text-cinek-400' : 'text-slate-500'">Ẩn danh</span>
|
||||
</div>
|
||||
<button type="button" @click="emit('submit-reply', reply.id)"
|
||||
class="px-2 py-0.5 rounded bg-cinek-500 text-[10px] font-bold text-slate-950 hover:bg-cinek-400 transition disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
:disabled="props.isSubmittingReply?.[reply.id]">
|
||||
<Loader2 v-if="props.isSubmittingReply?.[reply.id]" class="size-3 animate-spin inline" />
|
||||
<span v-else>Trả lời</span>
|
||||
</button>
|
||||
<button type="button" @click="emit('cancel-reply', reply.id)"
|
||||
class="px-2 py-0.5 rounded text-[10px] text-slate-500 hover:text-white transition">Hủy</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Nested replies (recursive) -->
|
||||
<div v-if="reply.replies?.length" class="mt-3 pl-4 border-l-2 border-white/10">
|
||||
<CommentReplies :replies="reply.replies" :depth="depth + 1" :user="user" :parent-id="reply.id"
|
||||
:is-replying-to="isReplyingTo" :reply-content="replyContent" :is-reply-anonymous="isReplyAnonymous"
|
||||
@start-reply="emit('start-reply', $event)" @cancel-reply="emit('cancel-reply', $event)"
|
||||
@submit-reply="emit('submit-reply', $event)" @vote="emit('vote', $event)" @delete="emit('delete', $event)" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TransitionGroup>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.reply-list-move,
|
||||
.reply-list-enter-active,
|
||||
.reply-list-leave-active {
|
||||
transition: all 0.35s ease;
|
||||
}
|
||||
.reply-list-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
.reply-list-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(-30px);
|
||||
max-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.reply-list-leave-active {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
+277
-114
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { CornerDownLeft, Crown, Loader2, MessageSquare, Send, ThumbsDown, ThumbsUp, Trash2, User } from 'lucide-vue-next'
|
||||
import { ChevronDown, ChevronUp, CornerDownLeft, Crown, Loader2, MessageSquare, Pin, Send, ThumbsDown, ThumbsUp, Trash2, User } from 'lucide-vue-next'
|
||||
|
||||
const props = defineProps<{
|
||||
source: string
|
||||
@@ -8,7 +8,7 @@ const props = defineProps<{
|
||||
}>()
|
||||
|
||||
const { user } = useAuth()
|
||||
const { fetchComments, postComment, deleteComment, voteComment } = useComments()
|
||||
const { fetchComments, postComment, deleteComment, voteComment, togglePinComment } = useComments()
|
||||
const { getDraft, saveDraft, deleteDraft } = useCommentDraft()
|
||||
|
||||
const commentContent = ref('')
|
||||
@@ -21,13 +21,27 @@ const comments = ref<any[]>([])
|
||||
const isCommentsLoading = ref(false)
|
||||
const replyContent = ref<Record<number, string>>({})
|
||||
const isReplyingTo = ref<number | null>(null)
|
||||
const isReplyAnonymous = ref<Record<number, boolean>>({})
|
||||
const expandedComments = ref<Set<number>>(new Set())
|
||||
|
||||
function addTimeAgoToReplies(replies: any[]): any[] {
|
||||
return replies?.map(r => ({
|
||||
...r,
|
||||
timeAgo: timeAgo(r.createdAt),
|
||||
replies: r.replies ? addTimeAgoToReplies(r.replies) : []
|
||||
})) || []
|
||||
}
|
||||
|
||||
async function loadComments() {
|
||||
if (!props.slug) return
|
||||
isCommentsLoading.value = true
|
||||
try {
|
||||
comments.value = await fetchComments(props.source, props.slug, user.value?.id)
|
||||
const data = await fetchComments(props.source, props.slug, user.value?.id)
|
||||
comments.value = data.map(c => ({
|
||||
...c,
|
||||
timeAgo: timeAgo(c.createdAt),
|
||||
replies: addTimeAgoToReplies(c.replies || [])
|
||||
}))
|
||||
} finally {
|
||||
isCommentsLoading.value = false
|
||||
}
|
||||
@@ -61,12 +75,19 @@ async function handleSubmitComment() {
|
||||
if (!commentContent.value.trim() || isCommentSubmitting.value || !user.value) return
|
||||
isCommentSubmitting.value = true
|
||||
try {
|
||||
await postComment(props.source, props.slug, commentContent.value.trim(), props.movieName, undefined, false, isAnonymous.value)
|
||||
const newComment = await postComment(props.source, props.slug, commentContent.value.trim(), props.movieName, undefined, false, false)
|
||||
deleteDraft(props.slug, props.source)
|
||||
commentContent.value = ''
|
||||
hasDraft.value = false
|
||||
isAnonymous.value = false
|
||||
await loadComments()
|
||||
comments.value.unshift({
|
||||
...newComment,
|
||||
userName: user.value.name || 'Ẩn danh',
|
||||
userAvatar: user.value.avatar,
|
||||
userRole: user.value.role,
|
||||
replies: [],
|
||||
timeAgo: 'Vừa xong'
|
||||
})
|
||||
} catch (e: any) {
|
||||
console.error('Lỗi:', e)
|
||||
} finally {
|
||||
@@ -77,43 +98,155 @@ async function handleSubmitComment() {
|
||||
async function handleDeleteComment(id: number) {
|
||||
try {
|
||||
await deleteComment(id)
|
||||
await loadComments()
|
||||
const idx = comments.value.findIndex((c: any) => c.id === id)
|
||||
if (idx !== -1) {
|
||||
comments.value.splice(idx, 1)
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.error('Lỗi:', e)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTogglePin(id: number) {
|
||||
if (!user.value || user.value.role !== 'admin') return
|
||||
const idx = comments.value.findIndex((c: any) => c.id === id)
|
||||
if (idx === -1) return
|
||||
const comment = comments.value[idx]
|
||||
const newPinned = !comment.pinned
|
||||
comments.value.splice(idx, 1)
|
||||
comment.pinned = newPinned
|
||||
if (newPinned) {
|
||||
comments.value.unshift(comment)
|
||||
} else {
|
||||
comments.value.push(comment)
|
||||
}
|
||||
comments.value = [...comments.value]
|
||||
try {
|
||||
await togglePinComment(id)
|
||||
} catch (e: any) {
|
||||
const currentIdx = comments.value.findIndex((c: any) => c.id === id)
|
||||
comments.value.splice(currentIdx, 1)
|
||||
comment.pinned = !newPinned
|
||||
if (comment.pinned) {
|
||||
comments.value.unshift(comment)
|
||||
} else {
|
||||
comments.value.splice(idx, 0, comment)
|
||||
}
|
||||
comments.value = [...comments.value]
|
||||
console.error('Lỗi:', e)
|
||||
}
|
||||
}
|
||||
|
||||
function startReply(commentId: number) {
|
||||
if (!user.value) return
|
||||
isReplyingTo.value = commentId
|
||||
replyContent.value[commentId] = ''
|
||||
isReplyAnonymous.value[commentId] = false
|
||||
}
|
||||
|
||||
function cancelReply() {
|
||||
function cancelReply(commentId?: number) {
|
||||
const targetId = commentId ?? isReplyingTo.value
|
||||
if (targetId !== null) {
|
||||
replyContent.value[targetId] = ''
|
||||
isReplyAnonymous.value[targetId] = false
|
||||
}
|
||||
isReplyingTo.value = null
|
||||
}
|
||||
|
||||
async function submitReply(commentId: number) {
|
||||
const content = replyContent.value[commentId]?.trim()
|
||||
if (!content || !user.value) return
|
||||
try {
|
||||
await postComment(props.source, props.slug, content, props.movieName, commentId, false, isAnonymous.value)
|
||||
replyContent.value[commentId] = ''
|
||||
isReplyingTo.value = null
|
||||
isAnonymous.value = false
|
||||
await loadComments()
|
||||
} catch (e: any) {
|
||||
console.error('Lỗi:', e)
|
||||
function findTopLevelCommentContainingId(id: number): any | null {
|
||||
for (const c of comments.value) {
|
||||
if (c.id === id) return c
|
||||
const findInReplies = (replies: any[]): boolean => {
|
||||
if (!replies) return false
|
||||
for (const r of replies) {
|
||||
if (r.id === id) return true
|
||||
if (findInReplies(r.replies)) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
if (findInReplies(c.replies)) return c
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function findItemById(id: number): any | null {
|
||||
for (const c of comments.value) {
|
||||
if (c.id === id) return c
|
||||
const findInReplies = (replies: any[]): any | null => {
|
||||
if (!replies) return null
|
||||
for (const r of replies) {
|
||||
if (r.id === id) return r
|
||||
const found = findInReplies(r.replies)
|
||||
if (found) return found
|
||||
}
|
||||
return null
|
||||
}
|
||||
const found = findInReplies(c.replies)
|
||||
if (found) return found
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const isSubmittingReply = ref<Record<number, boolean>>({})
|
||||
|
||||
async function submitReply(parentId: number) {
|
||||
const content = replyContent.value[parentId]?.trim()
|
||||
if (!content || !user.value || isSubmittingReply.value[parentId]) return
|
||||
const isAnon = isReplyAnonymous.value[parentId] ?? false
|
||||
isSubmittingReply.value[parentId] = true
|
||||
try {
|
||||
const savedReply = await postComment(props.source, props.slug, content, props.movieName, parentId, false, isAnon)
|
||||
const targetItem = findItemById(parentId)
|
||||
if (targetItem) {
|
||||
if (!targetItem.replies) targetItem.replies = []
|
||||
targetItem.replies.push({
|
||||
...savedReply,
|
||||
userName: user.value.name || 'Ẩn danh',
|
||||
userAvatar: user.value.avatar,
|
||||
userRole: user.value.role,
|
||||
timeAgo: 'Vừa xong',
|
||||
anonymous: isAnon,
|
||||
replies: []
|
||||
})
|
||||
const topLevelComment = findTopLevelCommentContainingId(parentId)
|
||||
if (topLevelComment) expandedComments.value.add(topLevelComment.id)
|
||||
}
|
||||
replyContent.value[parentId] = ''
|
||||
isReplyingTo.value = null
|
||||
isReplyAnonymous.value[parentId] = false
|
||||
comments.value = [...comments.value]
|
||||
} catch (e: any) {
|
||||
const msg = e?.data?.message || e?.message || String(e)
|
||||
console.error('Lỗi reply:', msg)
|
||||
alert('Lỗi: ' + msg)
|
||||
} finally {
|
||||
isSubmittingReply.value[parentId] = false
|
||||
}
|
||||
}
|
||||
|
||||
function findReplyInTree(commentId: number): any | null {
|
||||
for (const c of comments.value) {
|
||||
const findInReplies = (replies: any[]): any | null => {
|
||||
if (!replies) return null
|
||||
for (const r of replies) {
|
||||
if (r.id === commentId) return r
|
||||
const found = findInReplies(r.replies)
|
||||
if (found) return found
|
||||
}
|
||||
return null
|
||||
}
|
||||
const found = findInReplies(c.replies)
|
||||
if (found) return found
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
async function handleVote(commentId: number, vote: number, isReply = false, parentId?: number) {
|
||||
if (!user.value) return
|
||||
try {
|
||||
const result = await voteComment(commentId, vote) as any
|
||||
if (isReply && parentId) {
|
||||
const comment = comments.value.find((c: any) => c.id === parentId)
|
||||
const reply = comment?.replies?.find((r: any) => r.id === commentId)
|
||||
if (isReply) {
|
||||
const reply = findReplyInTree(commentId)
|
||||
if (reply) {
|
||||
reply.userVote = result.vote
|
||||
reply.likeCount = result.likeCount
|
||||
@@ -132,6 +265,14 @@ async function handleVote(commentId: number, vote: number, isReply = false, pare
|
||||
}
|
||||
}
|
||||
|
||||
function countAllReplies(replies: any[]): number {
|
||||
let count = replies?.length || 0
|
||||
for (const r of replies || []) {
|
||||
count += countAllReplies(r.replies)
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
function toggleExpand(commentId: number) {
|
||||
const s = new Set(expandedComments.value)
|
||||
if (s.has(commentId)) s.delete(commentId)
|
||||
@@ -183,38 +324,25 @@ watch(() => props.slug, () => {
|
||||
<User v-else class="size-5 text-slate-500" />
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<textarea
|
||||
v-model="commentContent"
|
||||
@input="triggerAutoSaveDraft"
|
||||
placeholder="Viết bình luận..."
|
||||
rows="3"
|
||||
<textarea v-model="commentContent" @input="triggerAutoSaveDraft" placeholder="Viết bình luận..." rows="3"
|
||||
maxlength="1000"
|
||||
class="w-full rounded-lg bg-[#0d0f17] border border-white/10 px-3 py-2.5 text-sm text-white placeholder:text-slate-600 focus:border-cinek-500/50 focus:outline-none resize-none transition"
|
||||
/>
|
||||
class="w-full rounded-lg bg-[#0d0f17] border border-white/10 px-3 py-2.5 text-sm text-white placeholder:text-slate-600 focus:border-cinek-500/50 focus:outline-none resize-none transition" />
|
||||
<div class="flex items-center justify-between mt-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs text-slate-500">Công khai</span>
|
||||
<button
|
||||
type="button"
|
||||
@click="isAnonymous = !isAnonymous"
|
||||
<button type="button" @click="isAnonymous = !isAnonymous"
|
||||
class="relative w-9 h-5 rounded-full transition-colors"
|
||||
:class="isAnonymous ? 'bg-cinek-500' : 'bg-white/20'"
|
||||
>
|
||||
<span
|
||||
class="absolute top-0.5 left-0.5 size-4 rounded-full bg-white shadow transition-transform"
|
||||
:class="isAnonymous ? 'translate-x-4' : 'translate-x-0'"
|
||||
/>
|
||||
:class="isAnonymous ? 'bg-cinek-500' : 'bg-white/20'">
|
||||
<span class="absolute top-0.5 left-0.5 size-4 rounded-full bg-white shadow transition-transform"
|
||||
:class="isAnonymous ? 'translate-x-4' : 'translate-x-0'" />
|
||||
</button>
|
||||
<span class="text-xs" :class="isAnonymous ? 'text-cinek-400' : 'text-slate-500'">Ẩn danh</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-xs text-slate-600">{{ commentContent.length }} / 1000</span>
|
||||
<button
|
||||
type="button"
|
||||
:disabled="!commentContent.trim() || isCommentSubmitting"
|
||||
<button type="button" :disabled="!commentContent.trim() || isCommentSubmitting"
|
||||
@click="handleSubmitComment"
|
||||
class="flex items-center gap-1.5 rounded-lg bg-cinek-500 px-4 py-1.5 text-xs font-bold text-slate-950 hover:bg-cinek-400 disabled:cursor-not-allowed disabled:opacity-40 transition"
|
||||
>
|
||||
class="flex items-center gap-1.5 rounded-lg bg-cinek-500 px-4 py-1.5 text-xs font-bold text-slate-950 hover:bg-cinek-400 disabled:cursor-not-allowed disabled:opacity-40 transition">
|
||||
<Loader2 v-if="isCommentSubmitting" class="size-3.5 animate-spin" />
|
||||
<Send class="size-3.5" v-else />
|
||||
<span>Gửi</span>
|
||||
@@ -226,7 +354,8 @@ watch(() => props.slug, () => {
|
||||
<span class="size-1.5 rounded-full bg-yellow-400/50 inline-block" />
|
||||
Có nháp đã lưu
|
||||
</span>
|
||||
<button type="button" @click="handleDeleteDraft" class="text-xs text-slate-500 hover:text-red-400 transition">Xóa nháp</button>
|
||||
<button type="button" @click="handleDeleteDraft"
|
||||
class="text-xs text-slate-500 hover:text-red-400 transition">Xóa nháp</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -243,110 +372,108 @@ watch(() => props.slug, () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="comments.length" class="space-y-1">
|
||||
<div v-for="comment in comments" :key="comment.id">
|
||||
<div v-if="comment.pinned" class="flex items-center gap-1.5 mb-2 px-2">
|
||||
<span class="text-xs font-bold text-cinek-400">📌 Ghim bởi Admin</span>
|
||||
</div>
|
||||
<div class="rounded-xl p-4 border-t border-white/5" :class="comment.pinned ? 'bg-yellow-500/10 border border-yellow-500/20' : ''">
|
||||
<TransitionGroup v-else-if="comments.length" name="comment-list" tag="div" class="space-y-1">
|
||||
<div v-for="comment in comments" :key="comment.id" class="mb-2">
|
||||
<div class="rounded-xl p-4 border border-white/5 transition"
|
||||
:class="comment.pinned ? 'bg-yellow-500/10 border-yellow-500/30' : 'bg-[#13151f]'">
|
||||
<div class="flex gap-3">
|
||||
<div class="shrink-0 relative">
|
||||
<div class="size-10 rounded-full bg-white/10 flex items-center justify-center" :class="comment.userRole === 'admin' && !comment.anonymous ? 'ring-2 ring-yellow-400 ring-offset-2 ring-offset-[#0d0f17]' : ''">
|
||||
<img v-if="comment.userAvatar && !comment.anonymous" :src="comment.userAvatar" class="size-10 rounded-full object-cover" alt="" />
|
||||
<div class="size-10 rounded-full bg-white/10 flex items-center justify-center"
|
||||
:class="comment.userRole === 'admin' && !comment.anonymous ? 'ring-2 ring-yellow-400 ring-offset-2 ring-offset-[#0d0f17]' : ''">
|
||||
<img v-if="comment.userAvatar && !comment.anonymous" :src="comment.userAvatar"
|
||||
class="size-10 rounded-full object-cover" alt="" />
|
||||
<User v-else class="size-5 text-slate-500" />
|
||||
</div>
|
||||
<div v-if="comment.userRole === 'admin' && !comment.anonymous" class="absolute -top-1 -left-1 size-4 bg-cinek-500 rounded-full flex items-center justify-center">
|
||||
<div v-if="comment.userRole === 'admin' && !comment.anonymous"
|
||||
class="absolute -top-1 -left-1 size-4 bg-cinek-500 rounded-full flex items-center justify-center">
|
||||
<Crown class="size-2.5 text-slate-950" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex flex-wrap items-center gap-2 mb-1">
|
||||
<span v-if="comment.userRole === 'admin' && !comment.anonymous" class="px-2 py-0.5 rounded text-[10px] font-black bg-yellow-400 text-slate-900 shadow-sm">ADMIN</span>
|
||||
<span v-if="comment.pinned"
|
||||
class="flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] font-medium bg-cinek-500/20 text-cinek-400">
|
||||
<Pin class="size-3" />Đã ghim
|
||||
</span>
|
||||
<span v-if="comment.userRole === 'admin' && !comment.anonymous"
|
||||
class="px-2 py-0.5 rounded text-[10px] font-black bg-yellow-400 text-slate-900 shadow-sm">ADMIN</span>
|
||||
<span class="text-sm font-semibold text-white">{{ comment.userName }}</span>
|
||||
<span v-if="comment.anonymous" class="px-1.5 py-0.5 rounded text-[10px] font-medium bg-white/10 text-slate-400">Ẩn danh</span>
|
||||
<span v-if="comment.anonymous"
|
||||
class="px-1.5 py-0.5 rounded text-[10px] font-medium bg-white/10 text-slate-400">Ẩn danh</span>
|
||||
<span class="text-xs text-slate-600">{{ timeAgo(comment.createdAt) }}</span>
|
||||
</div>
|
||||
<p class="text-sm text-slate-300 whitespace-pre-wrap break-words">{{ comment.content }}</p>
|
||||
<p class="text-sm text-slate-300 whitespace-pre-wrap wrap-break-word">{{ comment.content }}</p>
|
||||
<div class="flex items-center gap-3 mt-2.5">
|
||||
<button type="button" @click="handleVote(comment.id, comment.userVote === 1 ? 0 : 1)" class="flex items-center gap-1 text-xs" :class="comment.userVote === 1 ? 'text-cinek-400' : 'text-slate-500 hover:text-slate-300'">
|
||||
<button type="button" @click="handleVote(comment.id, comment.userVote === 1 ? 0 : 1)"
|
||||
class="flex items-center gap-1 text-xs"
|
||||
:class="comment.userVote === 1 ? 'text-cinek-400' : 'text-slate-500 hover:text-slate-300'">
|
||||
<ThumbsUp class="size-3.5" /><span>{{ comment.likeCount || 0 }}</span>
|
||||
</button>
|
||||
<button type="button" @click="handleVote(comment.id, comment.userVote === -1 ? 0 : -1)" class="flex items-center gap-1 text-xs" :class="comment.userVote === -1 ? 'text-red-400' : 'text-slate-500 hover:text-slate-300'">
|
||||
<button type="button" @click="handleVote(comment.id, comment.userVote === -1 ? 0 : -1)"
|
||||
class="flex items-center gap-1 text-xs"
|
||||
:class="comment.userVote === -1 ? 'text-red-400' : 'text-slate-500 hover:text-slate-300'">
|
||||
<ThumbsDown class="size-3.5" /><span>{{ comment.dislikeCount || 0 }}</span>
|
||||
</button>
|
||||
<button type="button" @click="startReply(comment.id)" class="flex items-center gap-1.5 text-xs text-slate-500 hover:text-slate-300">
|
||||
<button type="button" @click="startReply(comment.id)"
|
||||
class="flex items-center gap-1.5 text-xs text-slate-500 hover:text-slate-300">
|
||||
<CornerDownLeft class="size-3.5" /><span>Trả lời</span>
|
||||
</button>
|
||||
<button v-if="user && (user.id === comment.userId || user.role === 'admin')" type="button" @click="handleDeleteComment(comment.id)" class="flex items-center gap-1.5 text-xs text-slate-600 hover:text-red-400">
|
||||
<button v-if="user && user.role === 'admin'" type="button"
|
||||
@click="handleTogglePin(comment.id)"
|
||||
class="flex items-center gap-1.5 text-xs"
|
||||
:class="comment.pinned ? 'text-cinek-400 hover:text-cinek-300' : 'text-slate-600 hover:text-slate-400'">
|
||||
<Pin class="size-3.5" /><span>{{ comment.pinned ? 'Bỏ ghim' : 'Ghim' }}</span>
|
||||
</button>
|
||||
<button v-if="user && (user.id === comment.userId || user.role === 'admin')" type="button"
|
||||
@click="handleDeleteComment(comment.id)"
|
||||
class="flex items-center gap-1.5 text-xs text-slate-600 hover:text-red-400">
|
||||
<Trash2 class="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="isReplyingTo === comment.id" class="mt-3 flex items-start gap-2">
|
||||
<div class="size-7 rounded-full bg-white/10 flex items-center justify-center shrink-0"><User class="size-3.5 text-slate-500" /></div>
|
||||
<div class="size-7 rounded-full bg-white/10 flex items-center justify-center shrink-0">
|
||||
<User class="size-3.5 text-slate-500" />
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<textarea v-model="replyContent[comment.id]" rows="2" maxlength="1000" class="w-full rounded-lg bg-[#0d0f17] border border-white/10 px-3 py-2 text-sm text-white placeholder:text-slate-600 focus:border-cinek-500/50 focus:outline-none resize-none transition" placeholder="Viết trả lời..." />
|
||||
<textarea v-model="replyContent[comment.id]" rows="2" maxlength="1000"
|
||||
class="w-full rounded-lg bg-[#0d0f17] border border-white/10 px-3 py-2 text-sm text-white placeholder:text-slate-600 focus:border-cinek-500/50 focus:outline-none resize-none transition"
|
||||
placeholder="Viết trả lời..." />
|
||||
<div class="flex items-center gap-2 mt-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs text-slate-500">Công khai</span>
|
||||
<button type="button" @click="isAnonymous = !isAnonymous" class="relative w-8 h-4 rounded-full transition-colors" :class="isAnonymous ? 'bg-cinek-500' : 'bg-white/20'">
|
||||
<span class="absolute top-0.5 left-0.5 size-3 rounded-full bg-white shadow transition-transform" :class="isAnonymous ? 'translate-x-4' : 'translate-x-0'" />
|
||||
<button type="button" @click="isReplyAnonymous[comment.id] = !isReplyAnonymous[comment.id]"
|
||||
class="relative w-8 h-4 rounded-full transition-colors"
|
||||
:class="(isReplyAnonymous[comment.id] ?? false) ? 'bg-cinek-500' : 'bg-white/20'">
|
||||
<span class="absolute top-0.5 left-0.5 size-3 rounded-full bg-white shadow transition-transform"
|
||||
:class="(isReplyAnonymous[comment.id] ?? false) ? 'translate-x-4' : 'translate-x-0'" />
|
||||
</button>
|
||||
<span class="text-xs" :class="isAnonymous ? 'text-cinek-400' : 'text-slate-500'">Ẩn danh</span>
|
||||
<span class="text-xs"
|
||||
:class="(isReplyAnonymous[comment.id] ?? false) ? 'text-cinek-400' : 'text-slate-500'">Ẩn
|
||||
danh</span>
|
||||
</div>
|
||||
<button type="button" @click="submitReply(comment.id)" class="px-3 py-1 rounded-lg bg-cinek-500 text-xs font-bold text-slate-950 hover:bg-cinek-400 transition">Trả lời</button>
|
||||
<button type="button" @click="cancelReply" class="px-3 py-1 rounded-lg text-xs text-slate-500 hover:text-white transition">Hủy</button>
|
||||
<button type="button" @click="submitReply(comment.id)"
|
||||
class="px-3 py-1 rounded-lg bg-cinek-500 text-xs font-bold text-slate-950 hover:bg-cinek-400 transition">Trả
|
||||
lời</button>
|
||||
<button type="button" @click="cancelReply(comment.id)"
|
||||
class="px-3 py-1 rounded-lg text-xs text-slate-500 hover:text-white transition">Hủy</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="comment.replies?.length" class="mt-4">
|
||||
<button @click="toggleExpand(comment.id)" class="flex items-center gap-1.5 text-xs text-cinek-400 hover:text-cinek-300 transition mb-2">
|
||||
<span>{{ expandedComments.has(comment.id) ? '▲' : '▼' }}</span>
|
||||
<span>{{ expandedComments.has(comment.id) ? 'Ẩn phản hồi' : `Hiển thị ${comment.replies.length} phản hồi` }}</span>
|
||||
<button @click="toggleExpand(comment.id)"
|
||||
class="flex items-center gap-1.5 text-xs text-cinek-400 hover:text-cinek-300 transition mb-2">
|
||||
<ChevronUp v-if="expandedComments.has(comment.id)" class="size-3" />
|
||||
<ChevronDown v-else class="size-3" />
|
||||
<span>{{ expandedComments.has(comment.id) ? 'Ẩn phản hồi' : `Hiển thị ${countAllReplies(comment.replies)} phản hồi` }}</span>
|
||||
</button>
|
||||
<div class="space-y-3 pl-3 border-r-2 border-white/10" :class="!expandedComments.has(comment.id) ? 'max-h-0 overflow-hidden' : ''">
|
||||
<div v-for="reply in comment.replies" :key="reply.id" class="flex items-start gap-2">
|
||||
<div class="shrink-0 relative">
|
||||
<div class="size-7 rounded-full bg-white/10 flex items-center justify-center" :class="reply.userRole === 'admin' && !reply.anonymous ? 'ring-2 ring-yellow-400 ring-offset-1 ring-offset-[#0d0f17]' : ''">
|
||||
<img v-if="reply.userAvatar && !reply.anonymous" :src="reply.userAvatar" class="size-7 rounded-full object-cover" alt="" />
|
||||
<User v-else class="size-3.5 text-slate-500" />
|
||||
</div>
|
||||
<div v-if="reply.userRole === 'admin' && !reply.anonymous" class="absolute -top-0.5 -left-0.5 size-3.5 bg-cinek-500 rounded-full flex items-center justify-center">
|
||||
<Crown class="size-2 text-slate-950" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex flex-wrap items-center gap-2 mb-0.5">
|
||||
<span v-if="reply.userRole === 'admin' && !reply.anonymous" class="px-1.5 py-0.5 rounded text-[9px] font-black bg-yellow-400 text-slate-900">ADMIN</span>
|
||||
<span class="text-xs font-semibold text-white">{{ reply.userName }}</span>
|
||||
<span v-if="reply.anonymous" class="px-1 py-0.5 rounded text-[9px] font-medium bg-white/10 text-slate-400">Ẩn danh</span>
|
||||
<span class="text-xs text-slate-600">{{ timeAgo(reply.createdAt) }}</span>
|
||||
</div>
|
||||
<p class="text-xs text-slate-400 whitespace-pre-wrap break-words">{{ reply.content }}</p>
|
||||
<div class="flex items-center gap-3 mt-1.5">
|
||||
<button type="button" @click="handleVote(reply.id, reply.userVote === 1 ? 0 : 1, true, comment.id)" class="flex items-center gap-1 text-xs" :class="reply.userVote === 1 ? 'text-cinek-400' : 'text-slate-600 hover:text-slate-400'">
|
||||
<ThumbsUp class="size-3" /><span>{{ reply.likeCount || 0 }}</span>
|
||||
</button>
|
||||
<button type="button" @click="handleVote(reply.id, reply.userVote === -1 ? 0 : -1, true, comment.id)" class="flex items-center gap-1 text-xs" :class="reply.userVote === -1 ? 'text-red-400' : 'text-slate-600 hover:text-slate-400'">
|
||||
<ThumbsDown class="size-3" /><span>{{ reply.dislikeCount || 0 }}</span>
|
||||
</button>
|
||||
<button type="button" @click="startReply(reply.id)" class="flex items-center gap-1 text-xs text-slate-600 hover:text-slate-400">
|
||||
<CornerDownLeft class="size-3" /><span>Trả lời</span>
|
||||
</button>
|
||||
<button v-if="user && (user.id === reply.userId || user.role === 'admin')" type="button" @click="handleDeleteComment(reply.id)" class="text-xs text-slate-600 hover:text-red-400">
|
||||
<Trash2 class="size-3" />
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="isReplyingTo === reply.id" class="mt-2 flex items-start gap-2">
|
||||
<div class="size-6 rounded-full bg-white/10 flex items-center justify-center shrink-0"><User class="size-3 text-slate-500" /></div>
|
||||
<div class="flex-1">
|
||||
<textarea v-model="replyContent[reply.id]" rows="2" maxlength="1000" class="w-full rounded-lg bg-[#0d0f17] border border-white/10 px-2 py-1.5 text-xs text-white placeholder:text-slate-600 focus:border-cinek-500/50 focus:outline-none resize-none transition" placeholder="Viết trả lời..." />
|
||||
<div class="flex items-center gap-2 mt-1.5">
|
||||
<button type="button" @click="submitReply(reply.id)" class="px-2 py-0.5 rounded bg-cinek-500 text-[10px] font-bold text-slate-950 hover:bg-cinek-400 transition">Trả lời</button>
|
||||
<button type="button" @click="cancelReply" class="px-2 py-0.5 rounded text-[10px] text-slate-500 hover:text-white transition">Hủy</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="replies-container pl-3 border-l-2 border-white/10"
|
||||
:class="expandedComments.has(comment.id) ? 'expanded' : 'collapsed'">
|
||||
<div class="space-y-3">
|
||||
<CommentReplies :replies="comment.replies" :depth="0" :user="user" :parent-id="comment.id"
|
||||
:is-replying-to="isReplyingTo" :reply-content="replyContent" :is-reply-anonymous="isReplyAnonymous"
|
||||
:is-submitting-reply="isSubmittingReply"
|
||||
@start-reply="startReply" @cancel-reply="cancelReply" @submit-reply="submitReply"
|
||||
@vote="handleVote" @delete="handleDeleteComment" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -354,7 +481,7 @@ watch(() => props.slug, () => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TransitionGroup>
|
||||
|
||||
<div v-else class="flex flex-col items-center justify-center py-12 text-center">
|
||||
<MessageSquare class="size-10 text-white/10 mb-3" />
|
||||
@@ -362,3 +489,39 @@ watch(() => props.slug, () => {
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.replies-container {
|
||||
overflow: hidden;
|
||||
transition: max-height 0.35s ease, opacity 0.3s ease;
|
||||
}
|
||||
.replies-container.collapsed {
|
||||
max-height: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
.replies-container.expanded {
|
||||
max-height: 5000px;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.comment-list-move,
|
||||
.comment-list-enter-active,
|
||||
.comment-list-leave-active {
|
||||
transition: all 0.4s ease;
|
||||
}
|
||||
.comment-list-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateY(-20px);
|
||||
}
|
||||
.comment-list-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(-50px);
|
||||
max-height: 0;
|
||||
margin-bottom: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.comment-list-leave-active {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -59,9 +59,29 @@ export default defineEventHandler(async (event) => {
|
||||
userVotes = Object.fromEntries(votes.map(v => [v.commentId, v.vote]))
|
||||
}
|
||||
|
||||
let replies: any[] = []
|
||||
if (commentIds.length > 0) {
|
||||
replies = await db
|
||||
function mapReply(rep: any) {
|
||||
return {
|
||||
id: rep.id,
|
||||
userId: rep.userId,
|
||||
userName: rep.anonymous ? 'Ẩn danh' : (rep.userName || 'Ẩn danh'),
|
||||
userAvatar: rep.anonymous ? null : rep.userAvatar,
|
||||
userRole: rep.userRole,
|
||||
parentId: rep.parentId,
|
||||
content: rep.content,
|
||||
pinned: rep.pinned || false,
|
||||
spoiler: rep.spoiler || false,
|
||||
anonymous: rep.anonymous || false,
|
||||
likeCount: rep.likeCount,
|
||||
dislikeCount: rep.dislikeCount,
|
||||
createdAt: rep.createdAt,
|
||||
userVote: userVotes[rep.id] || 0,
|
||||
replies: [],
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchRepliesByParentIds(parentIds: number[]): Promise<any[]> {
|
||||
if (parentIds.length === 0) return []
|
||||
const fetched = await db
|
||||
.select({
|
||||
id: comments.id,
|
||||
userId: comments.userId,
|
||||
@@ -81,55 +101,54 @@ export default defineEventHandler(async (event) => {
|
||||
.leftJoin(users, eq(comments.userId, users.id))
|
||||
.where(
|
||||
and(
|
||||
or(...commentIds.map(id => eq(comments.parentId, id)))
|
||||
or(...parentIds.map(id => eq(comments.parentId, id)))
|
||||
)
|
||||
)
|
||||
.orderBy(comments.createdAt)
|
||||
return fetched
|
||||
}
|
||||
|
||||
const repliesByParent: Record<number, typeof replies> = {}
|
||||
for (const reply of replies) {
|
||||
let allReplies: any[] = []
|
||||
if (commentIds.length > 0) {
|
||||
let currentParentIds = [...commentIds]
|
||||
const maxDepth = 10
|
||||
let depth = 0
|
||||
while (currentParentIds.length > 0 && depth < maxDepth) {
|
||||
const replies = await fetchRepliesByParentIds(currentParentIds)
|
||||
if (replies.length === 0) break
|
||||
allReplies = allReplies.concat(replies)
|
||||
currentParentIds = replies.map(r => r.id)
|
||||
depth++
|
||||
}
|
||||
}
|
||||
|
||||
const repliesByParent: Record<number, any[]> = {}
|
||||
for (const reply of allReplies) {
|
||||
const parentId = reply.parentId!
|
||||
if (!repliesByParent[parentId]) {
|
||||
repliesByParent[parentId] = []
|
||||
}
|
||||
repliesByParent[parentId].push(reply)
|
||||
repliesByParent[parentId].push(mapReply(reply))
|
||||
}
|
||||
|
||||
function attachChildren(mappedReply: any): any {
|
||||
const children = repliesByParent[mappedReply.id] || []
|
||||
return {
|
||||
...mappedReply,
|
||||
replies: children.map(attachChildren),
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
items: results.map(r => ({
|
||||
id: r.id,
|
||||
userId: r.userId,
|
||||
...r,
|
||||
userName: r.anonymous ? 'Ẩn danh' : (r.userName || 'Ẩn danh'),
|
||||
userAvatar: r.anonymous ? null : r.userAvatar,
|
||||
userRole: r.userRole,
|
||||
source: r.source,
|
||||
slug: r.slug,
|
||||
movieName: r.movieName,
|
||||
content: r.content,
|
||||
pinned: r.pinned || false,
|
||||
spoiler: r.spoiler || false,
|
||||
anonymous: r.anonymous || false,
|
||||
likeCount: r.likeCount,
|
||||
dislikeCount: r.dislikeCount,
|
||||
createdAt: r.createdAt,
|
||||
userVote: userVotes[r.id] || 0,
|
||||
replies: (repliesByParent[r.id] || []).map(rep => ({
|
||||
id: rep.id,
|
||||
userId: rep.userId,
|
||||
userName: rep.anonymous ? 'Ẩn danh' : (rep.userName || 'Ẩn danh'),
|
||||
userAvatar: rep.anonymous ? null : rep.userAvatar,
|
||||
userRole: rep.userRole,
|
||||
parentId: rep.parentId,
|
||||
content: rep.content,
|
||||
pinned: rep.pinned || false,
|
||||
spoiler: rep.spoiler || false,
|
||||
anonymous: rep.anonymous || false,
|
||||
likeCount: rep.likeCount,
|
||||
dislikeCount: rep.dislikeCount,
|
||||
createdAt: rep.createdAt,
|
||||
userVote: userVotes[rep.id] || 0,
|
||||
})),
|
||||
replies: (repliesByParent[r.id] || []).map(attachChildren),
|
||||
})),
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { eq } from 'drizzle-orm'
|
||||
import mysql from 'mysql2/promise'
|
||||
import { comments } from '../../database/schema'
|
||||
import { getTokenFromEvent, verifyToken } from '../../utils/auth'
|
||||
|
||||
@@ -28,30 +29,35 @@ export default defineEventHandler(async (event) => {
|
||||
}
|
||||
}
|
||||
|
||||
const db = useDb()
|
||||
const dbUrl = process.env.DATABASE_URL || 'mysql://cinek:cinekpassword@localhost:3306/cinek'
|
||||
const conn = await mysql.createConnection(dbUrl)
|
||||
|
||||
const result = await db.insert(comments).values({
|
||||
userId: payload.id,
|
||||
source: source || '',
|
||||
slug,
|
||||
movieName: movieName || null,
|
||||
content: content.trim(),
|
||||
parentId: parentId ? Number(parentId) : null,
|
||||
spoiler: spoiler ? true : false,
|
||||
anonymous: anonymous ? true : false,
|
||||
})
|
||||
try {
|
||||
const [result] = await conn.execute(
|
||||
'INSERT INTO comments (user_id, source, slug, movie_name, content, parent_id, spoiler, anonymous) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
[payload.id, source || '', slug, movieName || null, content.trim(), parentId ? Number(parentId) : null, spoiler ? 1 : 0, anonymous ? 1 : 0]
|
||||
)
|
||||
|
||||
return {
|
||||
id: result.insertId,
|
||||
userId: payload.id,
|
||||
source: source || '',
|
||||
slug,
|
||||
movieName: movieName || null,
|
||||
content: content.trim(),
|
||||
spoiler: spoiler ? true : false,
|
||||
anonymous: anonymous ? true : false,
|
||||
parentId: parentId ? Number(parentId) : null,
|
||||
likeCount: 0,
|
||||
dislikeCount: 0,
|
||||
const insertId = (result as any).insertId
|
||||
if (!insertId) {
|
||||
throw createError({ statusCode: 500, message: 'Không thể tạo bình luận' })
|
||||
}
|
||||
|
||||
return {
|
||||
id: Number(insertId),
|
||||
userId: payload.id,
|
||||
source: source || '',
|
||||
slug,
|
||||
movieName: movieName || null,
|
||||
content: content.trim(),
|
||||
spoiler: false,
|
||||
anonymous: anonymous ? true : false,
|
||||
parentId: parentId ? Number(parentId) : null,
|
||||
likeCount: 0,
|
||||
dislikeCount: 0,
|
||||
createdAt: new Date().toISOString(),
|
||||
}
|
||||
} finally {
|
||||
await conn.end()
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user