2026-07-24 03:39:06 -04:00
|
|
|
<script setup lang="ts">
|
|
|
|
|
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>
|
2026-07-25 11:42:32 -04:00
|
|
|
openGifPicker?: (id: number) => void
|
2026-07-24 03:39:06 -04:00
|
|
|
}>()
|
|
|
|
|
|
|
|
|
|
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
|
2026-07-24 05:51:08 -04:00
|
|
|
const revealedReplies = ref<Set<number>>(new Set())
|
2026-07-24 03:39:06 -04:00
|
|
|
|
|
|
|
|
function handleVote(replyId: number, vote: number) {
|
|
|
|
|
emit('vote', replyId, vote, true, props.parentId)
|
|
|
|
|
}
|
2026-07-24 05:51:08 -04:00
|
|
|
|
|
|
|
|
function formatMentions(content: string) {
|
2026-07-25 11:42:32 -04:00
|
|
|
let html = content.replace(/@(\S+)/g, '<span class="text-cinek-400 font-medium">@$1</span>')
|
|
|
|
|
html = html.replace(/!\[GIF\]\((https?:\/\/[^\s)]+)\)/g, '<img src="$1" alt="GIF" class="max-w-[150px] rounded-lg mt-1" loading="lazy" />')
|
|
|
|
|
return html
|
2026-07-24 05:51:08 -04:00
|
|
|
}
|
2026-07-24 03:39:06 -04:00
|
|
|
</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"
|
2026-07-24 05:51:08 -04:00
|
|
|
:class="reply.userRole === 'admin' ? 'ring-2 ring-yellow-400 ring-offset-1 ring-offset-[#0d0f17]' : ''">
|
|
|
|
|
<img v-if="reply.userAvatar" :src="reply.userAvatar"
|
2026-07-24 03:39:06 -04:00
|
|
|
class="size-8 rounded-full object-cover" alt="" />
|
2026-07-25 10:39:40 -04:00
|
|
|
<AppIcon name="user" v-else class="size-3.5 text-slate-500" />
|
2026-07-24 03:39:06 -04:00
|
|
|
</div>
|
2026-07-24 05:51:08 -04:00
|
|
|
<div v-if="reply.userRole === 'admin'"
|
2026-07-25 10:39:40 -04:00
|
|
|
class="absolute -top-0.5 -left-0.5 size-3 bg-yellow-400 rounded-full flex items-center justify-center ring-1 ring-[#0d0f17]">
|
|
|
|
|
<svg class="size-1.5 text-slate-950" viewBox="0 0 24 24" fill="currentColor"><path d="M5 16L3 5l5.5 5L12 4l3.5 6L21 5l-2 11H5z"/></svg>
|
2026-07-24 03:39:06 -04:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="flex-1 min-w-0">
|
|
|
|
|
<div class="flex flex-wrap items-center gap-2 mb-1">
|
2026-07-24 05:51:08 -04:00
|
|
|
<span v-if="reply.userRole === 'admin'"
|
2026-07-24 03:39:06 -04:00
|
|
|
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>
|
2026-07-25 10:39:40 -04:00
|
|
|
<AppIcon name="venus" v-if="reply.userGender === 'female'" class="size-3 text-pink-400" />
|
|
|
|
|
<AppIcon name="mars" v-if="reply.userGender === 'male'" class="size-3 text-blue-400" />
|
|
|
|
|
<AppIcon name="minus" v-if="reply.userGender === 'other'" class="size-3 text-purple-400" />
|
2026-07-24 03:39:06 -04:00
|
|
|
<span class="text-xs text-slate-600">{{ reply.timeAgo || '' }}</span>
|
|
|
|
|
</div>
|
2026-07-24 05:51:08 -04:00
|
|
|
<p class="text-xs text-slate-400 whitespace-pre-wrap wrap-break-word leading-relaxed cursor-pointer select-none py-1 transition-all duration-200"
|
|
|
|
|
:class="reply.anonymous && !revealedReplies.has(reply.id) ? 'blur-sm opacity-60' : ''"
|
|
|
|
|
v-html="formatMentions(reply.content)"
|
|
|
|
|
@click="reply.anonymous && (revealedReplies.has(reply.id) ? revealedReplies.delete(reply.id) : revealedReplies.add(reply.id))">
|
|
|
|
|
</p>
|
2026-07-24 03:39:06 -04:00
|
|
|
<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'">
|
2026-07-25 10:39:40 -04:00
|
|
|
<AppIcon name="thumbs-up" class="size-3" /><span v-if="reply.likeCount">{{ reply.likeCount }}</span>
|
2026-07-24 03:39:06 -04:00
|
|
|
</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'">
|
2026-07-25 10:39:40 -04:00
|
|
|
<AppIcon name="thumbs-down" class="size-3" /><span v-if="reply.dislikeCount">{{ reply.dislikeCount }}</span>
|
2026-07-24 03:39:06 -04:00
|
|
|
</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">
|
2026-07-25 10:39:40 -04:00
|
|
|
<AppIcon name="corner-down-left" class="size-3" /><span>Trả lời</span>
|
2026-07-24 03:39:06 -04:00
|
|
|
</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)">
|
2026-07-25 10:39:40 -04:00
|
|
|
<AppIcon name="loader" v-if="props.isDeleting?.has(reply.id)" class="size-3 animate-spin" />
|
|
|
|
|
<AppIcon name="trash" v-else class="size-3" />
|
2026-07-24 03:39:06 -04:00
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
<!-- Reply form -->
|
2026-07-24 05:51:08 -04:00
|
|
|
<div v-if="isReplyingTo === reply.id" class="mt-3">
|
|
|
|
|
<div class="rounded-xl border border-white/10 bg-[#13151f] p-4">
|
2026-07-24 03:39:06 -04:00
|
|
|
<textarea v-model="replyContent[reply.id]" rows="2" maxlength="1000"
|
2026-07-24 05:51:08 -04:00
|
|
|
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"
|
2026-07-24 03:39:06 -04:00
|
|
|
placeholder="Viết trả lời..." />
|
2026-07-24 05:51:08 -04:00
|
|
|
<div class="flex items-center justify-between mt-3">
|
|
|
|
|
<div class="flex items-center gap-4">
|
|
|
|
|
<div class="flex items-center gap-2">
|
|
|
|
|
<button type="button" @click="isReplyAnonymous[reply.id] = !isReplyAnonymous[reply.id]"
|
|
|
|
|
class="relative w-9 h-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-4 rounded-full bg-white shadow transition-transform"
|
|
|
|
|
:class="(isReplyAnonymous[reply.id] ?? false) ? 'translate-x-4' : 'translate-x-0'" />
|
|
|
|
|
</button>
|
|
|
|
|
<span class="text-xs text-slate-500">Tiết lộ</span>
|
|
|
|
|
</div>
|
2026-07-25 11:42:32 -04:00
|
|
|
<div class="relative">
|
|
|
|
|
<button type="button" @click="openGifPicker?.(reply.id)"
|
|
|
|
|
class="reply-gif-btn flex items-center gap-1.5 text-xs text-slate-500 hover:text-slate-300 transition"
|
|
|
|
|
:class="'reply-gif-btn-' + reply.id">
|
|
|
|
|
<AppIcon name="image" class="size-4" /><span>GIF</span>
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
2026-07-24 05:51:08 -04:00
|
|
|
</div>
|
|
|
|
|
<div class="flex items-center gap-3">
|
2026-07-25 10:39:40 -04:00
|
|
|
<span class="text-xs text-white/30">{{ (replyContent[reply.id] || '').length }} / 1000</span>
|
2026-07-24 05:51:08 -04:00
|
|
|
<button type="button" @click="emit('cancel-reply', reply.id)"
|
2026-07-25 10:39:40 -04:00
|
|
|
class="px-3 py-1.5 rounded-lg text-xs text-white/50 hover:text-white transition">Hủy</button>
|
2026-07-24 05:51:08 -04:00
|
|
|
<button type="button" @click="emit('submit-reply', reply.id)"
|
2026-07-25 10:39:40 -04:00
|
|
|
class="flex items-center gap-2 text-sm font-semibold transition disabled:opacity-30 disabled:cursor-not-allowed"
|
|
|
|
|
:class="(replyContent[reply.id] || '').trim() ? 'text-[#FFD166] hover:text-[#FFC845]' : 'text-white/30'"
|
2026-07-24 05:51:08 -04:00
|
|
|
:disabled="props.isSubmittingReply?.[reply.id]">
|
2026-07-25 10:39:40 -04:00
|
|
|
<span>Gửi</span>
|
|
|
|
|
<AppIcon name="loader" v-if="props.isSubmittingReply?.[reply.id]" class="size-4 animate-spin" />
|
|
|
|
|
<AppIcon name="send" v-else class="size-4" />
|
2026-07-24 03:39:06 -04:00
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</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"
|
2026-07-25 11:42:32 -04:00
|
|
|
:is-submitting-reply="isSubmittingReply" :open-gif-picker="openGifPicker"
|
2026-07-24 03:39:06 -04:00
|
|
|
@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>
|
2026-07-25 11:42:32 -04:00
|
|
|
</div>
|
2026-07-24 03:39:06 -04:00
|
|
|
</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>
|