mirror of
https://github.com/ngthanhvu/kr-phim.git
synced 2026-08-10 13:27:46 +00:00
feat: add gif component section
This commit is contained in:
+2
-1
@@ -1,2 +1,3 @@
|
|||||||
SUPABASE_URL=
|
SUPABASE_URL=
|
||||||
SUPABASE_KEY=
|
SUPABASE_KEY=
|
||||||
|
GIPHY_API_KEY=
|
||||||
@@ -8,6 +8,7 @@ const props = defineProps<{
|
|||||||
replyContent: Record<number, string>
|
replyContent: Record<number, string>
|
||||||
isReplyAnonymous: Record<number, boolean>
|
isReplyAnonymous: Record<number, boolean>
|
||||||
isSubmittingReply?: Record<number, boolean>
|
isSubmittingReply?: Record<number, boolean>
|
||||||
|
openGifPicker?: (id: number) => void
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -27,7 +28,9 @@ function handleVote(replyId: number, vote: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function formatMentions(content: string) {
|
function formatMentions(content: string) {
|
||||||
return content.replace(/@(\S+)/g, '<span class="text-cinek-400 font-medium">@$1</span>')
|
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
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -100,9 +103,13 @@ function formatMentions(content: string) {
|
|||||||
</button>
|
</button>
|
||||||
<span class="text-xs text-slate-500">Tiết lộ</span>
|
<span class="text-xs text-slate-500">Tiết lộ</span>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" class="flex items-center gap-1.5 text-xs text-slate-500 hover:text-slate-300 transition">
|
<div class="relative">
|
||||||
<AppIcon name="image" class="size-4" /><span>GIF</span>
|
<button type="button" @click="openGifPicker?.(reply.id)"
|
||||||
</button>
|
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>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<span class="text-xs text-white/30">{{ (replyContent[reply.id] || '').length }} / 1000</span>
|
<span class="text-xs text-white/30">{{ (replyContent[reply.id] || '').length }} / 1000</span>
|
||||||
@@ -124,11 +131,12 @@ function formatMentions(content: string) {
|
|||||||
<div v-if="reply.replies?.length" class="mt-3 pl-4 border-l-2 border-white/10">
|
<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"
|
<CommentReplies :replies="reply.replies" :depth="depth + 1" :user="user" :parent-id="reply.id"
|
||||||
:is-replying-to="isReplyingTo" :reply-content="replyContent" :is-reply-anonymous="isReplyAnonymous"
|
:is-replying-to="isReplyingTo" :reply-content="replyContent" :is-reply-anonymous="isReplyAnonymous"
|
||||||
|
:is-submitting-reply="isSubmittingReply" :open-gif-picker="openGifPicker"
|
||||||
@start-reply="emit('start-reply', $event)" @cancel-reply="emit('cancel-reply', $event)"
|
@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)" />
|
@submit-reply="emit('submit-reply', $event)" @vote="emit('vote', $event)" @delete="emit('delete', $event)" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</TransitionGroup>
|
</TransitionGroup>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -14,8 +14,30 @@ const commentContent = ref('')
|
|||||||
const isCommentSubmitting = ref(false)
|
const isCommentSubmitting = ref(false)
|
||||||
const hasDraft = ref(false)
|
const hasDraft = ref(false)
|
||||||
const isContentHidden = ref(false)
|
const isContentHidden = ref(false)
|
||||||
|
const isGifPickerOpen = ref(false)
|
||||||
|
const gifPickerTarget = ref<'main' | number>('main')
|
||||||
|
const gifPickerRef = ref<any>(null)
|
||||||
let draftAutoSaveTimer: ReturnType<typeof setTimeout> | undefined
|
let draftAutoSaveTimer: ReturnType<typeof setTimeout> | undefined
|
||||||
|
|
||||||
|
function insertGif(url: string) {
|
||||||
|
const gifTag = ``
|
||||||
|
if (gifPickerTarget.value === 'main') {
|
||||||
|
if (commentContent.value) {
|
||||||
|
commentContent.value += '\n' + gifTag
|
||||||
|
} else {
|
||||||
|
commentContent.value = gifTag
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const currentContent = replyContent.value[gifPickerTarget.value] || ''
|
||||||
|
if (currentContent) {
|
||||||
|
replyContent.value[gifPickerTarget.value] = currentContent + '\n' + gifTag
|
||||||
|
} else {
|
||||||
|
replyContent.value[gifPickerTarget.value] = gifTag
|
||||||
|
}
|
||||||
|
}
|
||||||
|
isGifPickerOpen.value = false
|
||||||
|
}
|
||||||
|
|
||||||
const comments = ref<any[]>([])
|
const comments = ref<any[]>([])
|
||||||
const isCommentsLoading = ref(false)
|
const isCommentsLoading = ref(false)
|
||||||
const replyContent = ref<Record<number, string>>({})
|
const replyContent = ref<Record<number, string>>({})
|
||||||
@@ -330,7 +352,24 @@ function timeAgo(dateStr: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function formatMentions(content: string) {
|
function formatMentions(content: string) {
|
||||||
return content.replace(/@(\S+)/g, '<span class="text-cinek-400 font-medium">@$1</span>')
|
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-[200px] rounded-lg mt-1" loading="lazy" />')
|
||||||
|
return html
|
||||||
|
}
|
||||||
|
|
||||||
|
function openGifPicker(target: 'main' | number = 'main') {
|
||||||
|
gifPickerTarget.value = target
|
||||||
|
isGifPickerOpen.value = !isGifPickerOpen.value
|
||||||
|
if (isGifPickerOpen.value) {
|
||||||
|
nextTick(() => {
|
||||||
|
const selector = target === 'main' ? '.comment-gif-btn' : `.reply-gif-btn-${target}`
|
||||||
|
document.querySelectorAll('.gif-trigger-active').forEach(el => el.classList.remove('gif-trigger-active'))
|
||||||
|
document.querySelector(selector)?.classList.add('gif-trigger-active')
|
||||||
|
gifPickerRef.value?.updatePosition(selector)
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
document.querySelectorAll('.gif-trigger-active').forEach(el => el.classList.remove('gif-trigger-active'))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
@@ -356,7 +395,7 @@ watch(() => props.slug, () => {
|
|||||||
<p class="text-sm text-slate-400">Đăng nhập để bình luận về phim này.</p>
|
<p class="text-sm text-slate-400">Đăng nhập để bình luận về phim này.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="mb-8 rounded-2xl bg-[#181a24] border border-white/5 overflow-hidden">
|
<div v-else class="mb-8 rounded-2xl bg-[#181a24] border border-white/5 overflow-visible">
|
||||||
<div class="flex items-center gap-3 px-5 pt-4 pb-2">
|
<div class="flex items-center gap-3 px-5 pt-4 pb-2">
|
||||||
<div class="size-10 rounded-full bg-white/10 flex items-center justify-center shrink-0">
|
<div class="size-10 rounded-full bg-white/10 flex items-center justify-center shrink-0">
|
||||||
<img v-if="user?.avatar" :src="user.avatar" class="size-10 rounded-full object-cover" alt="" />
|
<img v-if="user?.avatar" :src="user.avatar" class="size-10 rounded-full object-cover" alt="" />
|
||||||
@@ -381,9 +420,11 @@ watch(() => props.slug, () => {
|
|||||||
<span class="text-xs font-medium" :class="isContentHidden ? 'text-[#FFD166]' : 'text-white/60'">Không tiết
|
<span class="text-xs font-medium" :class="isContentHidden ? 'text-[#FFD166]' : 'text-white/60'">Không tiết
|
||||||
lộ</span>
|
lộ</span>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" class="flex items-center gap-1.5 text-xs text-white/50 hover:text-white/80 transition">
|
<div class="relative">
|
||||||
<AppIcon name="image" class="size-4" /><span class="text-white/50">GIF</span>
|
<button type="button" @click="openGifPicker('main')" class="comment-gif-btn flex items-center gap-1.5 text-xs text-white/50 hover:text-white/80 transition">
|
||||||
</button>
|
<AppIcon name="image" class="size-4" /><span class="text-white/50">GIF</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
<span class="text-xs text-white/30">{{ commentContent.length }} / 1000</span>
|
<span class="text-xs text-white/30">{{ commentContent.length }} / 1000</span>
|
||||||
@@ -500,10 +541,13 @@ watch(() => props.slug, () => {
|
|||||||
</button>
|
</button>
|
||||||
<span class="text-xs text-slate-500">Tiết lộ</span>
|
<span class="text-xs text-slate-500">Tiết lộ</span>
|
||||||
</div>
|
</div>
|
||||||
<button type="button"
|
<div class="relative">
|
||||||
class="flex items-center gap-1.5 text-xs text-slate-500 hover:text-slate-300 transition">
|
<button type="button" @click="openGifPicker(comment.id)"
|
||||||
<AppIcon name="image" class="size-4" /><span>GIF</span>
|
class="reply-gif-btn flex items-center gap-1.5 text-xs text-slate-500 hover:text-slate-300 transition"
|
||||||
</button>
|
:class="'reply-gif-btn-' + comment.id">
|
||||||
|
<AppIcon name="image" class="size-4" /><span>GIF</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<span class="text-xs text-white/30">{{ (replyContent[comment.id] || '').length }} / 1000</span>
|
<span class="text-xs text-white/30">{{ (replyContent[comment.id] || '').length }} / 1000</span>
|
||||||
@@ -532,7 +576,8 @@ watch(() => props.slug, () => {
|
|||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
<CommentReplies :replies="comment.replies" :depth="0" :user="user" :parent-id="comment.id"
|
<CommentReplies :replies="comment.replies" :depth="0" :user="user" :parent-id="comment.id"
|
||||||
:is-replying-to="isReplyingTo" :reply-content="replyContent" :is-reply-anonymous="isReplyAnonymous"
|
:is-replying-to="isReplyingTo" :reply-content="replyContent" :is-reply-anonymous="isReplyAnonymous"
|
||||||
:is-submitting-reply="isSubmittingReply" @start-reply="startReply" @cancel-reply="cancelReply"
|
:is-submitting-reply="isSubmittingReply" :open-gif-picker="openGifPicker"
|
||||||
|
@start-reply="startReply" @cancel-reply="cancelReply"
|
||||||
@submit-reply="submitReply" @vote="handleVote" @delete="handleDeleteComment" />
|
@submit-reply="submitReply" @vote="handleVote" @delete="handleDeleteComment" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -563,6 +608,10 @@ watch(() => props.slug, () => {
|
|||||||
<AppIcon name="message-square" class="size-10 text-white/10 mb-3" />
|
<AppIcon name="message-square" class="size-10 text-white/10 mb-3" />
|
||||||
<p class="text-slate-400 text-sm">Chưa có bình luận nào.</p>
|
<p class="text-slate-400 text-sm">Chưa có bình luận nào.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<ClientOnly>
|
||||||
|
<GifPicker ref="gifPickerRef" v-model="isGifPickerOpen" @select="insertGif" />
|
||||||
|
</ClientOnly>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,166 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
const open = defineModel<boolean>({ default: false })
|
||||||
|
const emit = defineEmits<{
|
||||||
|
select: [url: string]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const config = useRuntimeConfig()
|
||||||
|
const GIPHY_API_KEY = config.public.giphyApiKey
|
||||||
|
const GIPHY_BASE = 'https://api.giphy.com/v1/gifs'
|
||||||
|
|
||||||
|
const searchQuery = ref('')
|
||||||
|
const gifs = ref<any[]>([])
|
||||||
|
const isLoading = ref(false)
|
||||||
|
const searchDebounce = ref<ReturnType<typeof setTimeout> | null>(null)
|
||||||
|
const popupRef = ref<HTMLElement | null>(null)
|
||||||
|
const popupStyle = ref({})
|
||||||
|
|
||||||
|
async function fetchTrending() {
|
||||||
|
isLoading.value = true
|
||||||
|
try {
|
||||||
|
const data = await $fetch(`${GIPHY_BASE}/trending?api_key=${GIPHY_API_KEY}&limit=20&rating=pg-13`)
|
||||||
|
gifs.value = data.data || []
|
||||||
|
} catch {
|
||||||
|
gifs.value = []
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function searchGifs(query: string) {
|
||||||
|
if (!query.trim()) {
|
||||||
|
fetchTrending()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
isLoading.value = true
|
||||||
|
try {
|
||||||
|
const data = await $fetch(`${GIPHY_BASE}/search?api_key=${GIPHY_API_KEY}&q=${encodeURIComponent(query)}&limit=20&rating=pg-13&lang=vi`)
|
||||||
|
gifs.value = data.data || []
|
||||||
|
} catch {
|
||||||
|
gifs.value = []
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSearchInput() {
|
||||||
|
if (searchDebounce.value) clearTimeout(searchDebounce.value)
|
||||||
|
searchDebounce.value = setTimeout(() => {
|
||||||
|
searchGifs(searchQuery.value)
|
||||||
|
}, 400)
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectGif(gif: any) {
|
||||||
|
const url = gif.images?.fixed_width?.url || gif.images?.original?.url || ''
|
||||||
|
emit('select', url)
|
||||||
|
open.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function closePicker() {
|
||||||
|
open.value = false
|
||||||
|
searchQuery.value = ''
|
||||||
|
gifs.value = []
|
||||||
|
}
|
||||||
|
|
||||||
|
function updatePosition(triggerSelector: string) {
|
||||||
|
const trigger = document.querySelector(triggerSelector) as HTMLElement
|
||||||
|
if (!trigger || !popupRef.value) return
|
||||||
|
const rect = trigger.getBoundingClientRect()
|
||||||
|
const popupWidth = 340
|
||||||
|
const left = Math.min(rect.left, window.innerWidth - popupWidth - 16)
|
||||||
|
popupStyle.value = {
|
||||||
|
position: 'fixed' as const,
|
||||||
|
top: `${rect.top - 8}px`,
|
||||||
|
left: `${Math.max(16, left)}px`,
|
||||||
|
zIndex: 10000,
|
||||||
|
width: `${popupWidth}px`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClickOutside(e: MouseEvent, triggerSelector: string) {
|
||||||
|
const trigger = document.querySelector(triggerSelector) as HTMLElement
|
||||||
|
if (!trigger || !popupRef.value) return
|
||||||
|
if (!popupRef.value.contains(e.target as Node) && !trigger.contains(e.target as Node)) {
|
||||||
|
closePicker()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(open, (val) => {
|
||||||
|
if (val) {
|
||||||
|
fetchTrending()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
window.addEventListener('resize', () => {
|
||||||
|
const trigger = document.querySelector('.gif-trigger-active') as HTMLElement
|
||||||
|
if (trigger) updatePosition('.gif-trigger-active')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
defineExpose({ updatePosition, handleClickOutside })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<div v-if="open" ref="popupRef"
|
||||||
|
class="gif-popup rounded-xl border border-white/10 bg-[#1e1e2e] shadow-2xl overflow-hidden" :style="popupStyle">
|
||||||
|
<div class="flex items-center gap-2 px-3 py-2.5 border-b border-white/5">
|
||||||
|
<div class="flex items-center gap-1.5 flex-1 bg-[#2a2a3d] rounded-lg px-2 py-1.5">
|
||||||
|
<AppIcon name="search" class="size-3.5 text-white/40 shrink-0" />
|
||||||
|
<input v-model="searchQuery" @input="onSearchInput" type="text" placeholder="Tìm kiếm GIF..."
|
||||||
|
class="flex-1 bg-transparent text-xs text-white placeholder:text-white/30 outline-none" />
|
||||||
|
</div>
|
||||||
|
<button type="button"
|
||||||
|
class="size-6 grid place-items-center rounded-md text-white/40 hover:text-white hover:bg-white/10 transition"
|
||||||
|
@click="closePicker">
|
||||||
|
<AppIcon name="x" class="size-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="max-h-70 overflow-y-auto p-2">
|
||||||
|
<div v-if="isLoading" class="grid grid-cols-5 gap-1.5">
|
||||||
|
<div v-for="i in 15" :key="i" class="aspect-square animate-pulse rounded-md bg-white/5" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="!gifs.length" class="flex flex-col items-center justify-center py-8 text-center">
|
||||||
|
<AppIcon name="image" class="size-8 text-white/10 mb-2" />
|
||||||
|
<p class="text-xs text-white/30">Không tìm thấy GIF</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="grid grid-cols-5 gap-1.5">
|
||||||
|
<button v-for="gif in gifs" :key="gif.id" type="button"
|
||||||
|
class="group relative aspect-square overflow-hidden rounded-md bg-white/5 transition hover:ring-2 hover:ring-[#FFD166]"
|
||||||
|
@click="selectGif(gif)">
|
||||||
|
<img :src="gif.images?.fixed_width_small?.url || gif.images?.preview_gif?.url" :alt="gif.title"
|
||||||
|
class="h-full w-full object-cover transition group-hover:scale-110" loading="lazy" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-center gap-1 px-3 py-2 border-t border-white/5">
|
||||||
|
<span class="text-[10px] text-white/25">POWERED BY</span>
|
||||||
|
<span
|
||||||
|
class="text-[10px] font-bold bg-linear-to-r from-pink-400 via-purple-400 to-blue-400 bg-clip-text text-transparent">GIPHY</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.gif-popup {
|
||||||
|
animation: gifPopIn 0.2s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes gifPopIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(8px) scale(0.96);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0) scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 283 KiB |
@@ -4,6 +4,11 @@ import tailwindcss from '@tailwindcss/vite'
|
|||||||
export default defineNuxtConfig({
|
export default defineNuxtConfig({
|
||||||
compatibilityDate: '2025-07-15',
|
compatibilityDate: '2025-07-15',
|
||||||
devtools: { enabled: true },
|
devtools: { enabled: true },
|
||||||
|
runtimeConfig: {
|
||||||
|
public: {
|
||||||
|
giphyApiKey: process.env.GIPHY_API_KEY,
|
||||||
|
},
|
||||||
|
},
|
||||||
app: {
|
app: {
|
||||||
head: {
|
head: {
|
||||||
titleTemplate: (titleChunk) => titleChunk ? `${titleChunk}` : 'CineK - Xem phim Hàn Quốc online',
|
titleTemplate: (titleChunk) => titleChunk ? `${titleChunk}` : 'CineK - Xem phim Hàn Quốc online',
|
||||||
|
|||||||
Reference in New Issue
Block a user