mirror of
https://github.com/nagisa77/OpenIsle.git
synced 2026-06-07 18:47:44 +08:00
187
frontend_nuxt/components/BaseItemGroup.vue
Normal file
187
frontend_nuxt/components/BaseItemGroup.vue
Normal file
@@ -0,0 +1,187 @@
|
||||
<template>
|
||||
<div
|
||||
ref="groupRef"
|
||||
class="base-item-group"
|
||||
:class="groupClass"
|
||||
:style="groupStyle"
|
||||
@mouseenter="onMouseEnter"
|
||||
@mouseleave="onMouseLeave"
|
||||
@focusin="onFocusIn"
|
||||
@focusout="onFocusOut"
|
||||
>
|
||||
<div
|
||||
v-for="(item, index) in normalizedItems"
|
||||
:key="resolveKey(item, index)"
|
||||
class="base-item-group-item"
|
||||
:style="{ zIndex: getZIndex(index) }"
|
||||
>
|
||||
<slot name="item" :item="item" :index="index"></slot>
|
||||
</div>
|
||||
<slot name="after"></slot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
items: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
itemKey: {
|
||||
type: [String, Function],
|
||||
default: null,
|
||||
},
|
||||
overlap: {
|
||||
type: [Number, String],
|
||||
default: 12,
|
||||
},
|
||||
expandedGap: {
|
||||
type: [Number, String],
|
||||
default: 8,
|
||||
},
|
||||
direction: {
|
||||
type: String,
|
||||
default: 'horizontal',
|
||||
validator: (value) => ['horizontal', 'vertical'].includes(value),
|
||||
},
|
||||
reverse: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
animationDuration: {
|
||||
type: [Number, String],
|
||||
default: 200,
|
||||
},
|
||||
})
|
||||
|
||||
const groupRef = ref(null)
|
||||
const state = reactive({
|
||||
hovering: false,
|
||||
focused: false,
|
||||
})
|
||||
|
||||
const normalizedItems = computed(() => props.items || [])
|
||||
|
||||
const sanitizedOverlap = computed(() => Math.max(0, Number(props.overlap) || 0))
|
||||
const sanitizedExpandedGap = computed(() => Math.max(0, Number(props.expandedGap) || 0))
|
||||
const sanitizedAnimationDuration = computed(() => Math.max(0, Number(props.animationDuration) || 0))
|
||||
|
||||
const groupClass = computed(() => [
|
||||
`base-item-group--${props.direction}`,
|
||||
{
|
||||
'is-expanded': isExpanded.value,
|
||||
'is-reversed': props.reverse,
|
||||
},
|
||||
])
|
||||
|
||||
const groupStyle = computed(() => ({
|
||||
'--base-item-group-overlap': `${sanitizedOverlap.value}px`,
|
||||
'--base-item-group-expanded-gap': `${sanitizedExpandedGap.value}px`,
|
||||
'--base-item-group-transition-duration': `${sanitizedAnimationDuration.value}ms`,
|
||||
}))
|
||||
|
||||
const isExpanded = computed(() => state.hovering || state.focused)
|
||||
|
||||
function onMouseEnter() {
|
||||
state.hovering = true
|
||||
}
|
||||
|
||||
function onMouseLeave() {
|
||||
state.hovering = false
|
||||
}
|
||||
|
||||
function onFocusIn() {
|
||||
state.focused = true
|
||||
}
|
||||
|
||||
function onFocusOut(event) {
|
||||
const nextTarget = event.relatedTarget
|
||||
if (!groupRef.value) {
|
||||
state.focused = false
|
||||
return
|
||||
}
|
||||
if (!nextTarget || !groupRef.value.contains(nextTarget)) {
|
||||
state.focused = false
|
||||
}
|
||||
}
|
||||
|
||||
function resolveKey(item, index) {
|
||||
if (typeof props.itemKey === 'function') {
|
||||
return props.itemKey(item, index)
|
||||
}
|
||||
if (props.itemKey && item && Object.prototype.hasOwnProperty.call(item, props.itemKey)) {
|
||||
return item[props.itemKey]
|
||||
}
|
||||
return index
|
||||
}
|
||||
|
||||
function getZIndex(index) {
|
||||
if (props.reverse) {
|
||||
return index + 1
|
||||
}
|
||||
return normalizedItems.value.length - index
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.base-item-group {
|
||||
--base-item-group-overlap: 12px;
|
||||
--base-item-group-expanded-gap: 8px;
|
||||
--base-item-group-transition-duration: 200ms;
|
||||
display: inline-flex;
|
||||
position: relative;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.base-item-group:focus-within {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.base-item-group--horizontal {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.base-item-group--horizontal.is-reversed {
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
|
||||
.base-item-group--vertical {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.base-item-group--vertical.is-reversed {
|
||||
flex-direction: column-reverse;
|
||||
}
|
||||
|
||||
.base-item-group-item {
|
||||
transition:
|
||||
margin var(--base-item-group-transition-duration) ease,
|
||||
transform var(--base-item-group-transition-duration) ease;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.base-item-group--horizontal:not(.is-expanded) .base-item-group-item:not(:first-child) {
|
||||
margin-left: calc(var(--base-item-group-overlap) * -1);
|
||||
}
|
||||
|
||||
.base-item-group--horizontal.is-expanded .base-item-group-item:not(:first-child) {
|
||||
margin-left: var(--base-item-group-expanded-gap);
|
||||
}
|
||||
|
||||
.base-item-group--vertical:not(.is-expanded) .base-item-group-item:not(:first-child) {
|
||||
margin-top: calc(var(--base-item-group-overlap) * -1);
|
||||
}
|
||||
|
||||
.base-item-group--vertical.is-expanded .base-item-group-item:not(:first-child) {
|
||||
margin-top: var(--base-item-group-expanded-gap);
|
||||
}
|
||||
|
||||
.base-item-group.is-expanded .base-item-group-item {
|
||||
transform: translateZ(0);
|
||||
}
|
||||
</style>
|
||||
@@ -15,7 +15,7 @@ import { computed, ref, watch } from 'vue'
|
||||
import { useAttrs } from 'vue'
|
||||
import BaseImage from './BaseImage.vue'
|
||||
|
||||
const DEFAULT_AVATAR = '/default-avatar.svg'
|
||||
const DEFAULT_AVATAR = '/default-avatar.jpg'
|
||||
|
||||
const props = defineProps({
|
||||
userId: {
|
||||
@@ -109,7 +109,7 @@ function onError() {
|
||||
}
|
||||
|
||||
.base-user-avatar:hover {
|
||||
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.1);
|
||||
box-shadow: 0 4px 24px rgba(251, 138, 138, 0.1);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
|
||||
@@ -53,14 +53,29 @@
|
||||
@click="handleContentClick"
|
||||
></div>
|
||||
<div class="article-footer-container">
|
||||
<ReactionsGroup v-model="comment.reactions" content-type="comment" :content-id="comment.id">
|
||||
<div class="make-reaction-item comment-reaction" @click="toggleEditor">
|
||||
<ReactionsGroup
|
||||
ref="commentReactionsGroupRef"
|
||||
v-model="comment.reactions"
|
||||
content-type="comment"
|
||||
:content-id="comment.id"
|
||||
/>
|
||||
<div class="comment-reaction-actions">
|
||||
<div
|
||||
class="reaction-action like-action"
|
||||
:class="{ selected: commentLikedByMe }"
|
||||
@click="toggleCommentLike"
|
||||
>
|
||||
<like v-if="!commentLikedByMe" />
|
||||
<like v-else theme="filled" />
|
||||
<span v-if="commentLikeCount" class="reaction-count">{{ commentLikeCount }}</span>
|
||||
</div>
|
||||
<div class="reaction-action comment-reaction" @click="toggleEditor">
|
||||
<comment-icon />
|
||||
</div>
|
||||
<div class="make-reaction-item copy-link" @click="copyCommentLink">
|
||||
<div class="reaction-action copy-link" @click="copyCommentLink">
|
||||
<link-icon />
|
||||
</div>
|
||||
</ReactionsGroup>
|
||||
</div>
|
||||
</div>
|
||||
<div class="comment-editor-wrapper" ref="editorWrapper">
|
||||
<CommentEditor
|
||||
@@ -156,6 +171,18 @@ const lightboxVisible = ref(false)
|
||||
const lightboxIndex = ref(0)
|
||||
const lightboxImgs = ref([])
|
||||
const loggedIn = computed(() => authState.loggedIn)
|
||||
const commentReactionsGroupRef = ref(null)
|
||||
const commentLikeCount = computed(
|
||||
() => (props.comment.reactions || []).filter((reaction) => reaction.type === 'LIKE').length,
|
||||
)
|
||||
const commentLikedByMe = computed(() =>
|
||||
(props.comment.reactions || []).some(
|
||||
(reaction) => reaction.type === 'LIKE' && reaction.user === authState.username,
|
||||
),
|
||||
)
|
||||
const toggleCommentLike = () => {
|
||||
commentReactionsGroupRef.value?.toggleReaction('LIKE')
|
||||
}
|
||||
const countReplies = (list) => list.reduce((sum, r) => sum + 1 + countReplies(r.reply || []), 0)
|
||||
const replyCount = computed(() => countReplies(props.comment.reply || []))
|
||||
const isCommentFromPostAuthor = computed(() => {
|
||||
@@ -365,6 +392,47 @@ const handleContentClick = (e) => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.comment-reaction-actions {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.reaction-action {
|
||||
cursor: pointer;
|
||||
padding: 4px 10px;
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
opacity: 0.6;
|
||||
font-size: 18px;
|
||||
transition:
|
||||
background-color 0.2s ease,
|
||||
opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.reaction-action:hover {
|
||||
opacity: 1;
|
||||
background-color: var(--normal-light-background-color);
|
||||
}
|
||||
|
||||
.reaction-action.like-action {
|
||||
color: #ff0000;
|
||||
}
|
||||
|
||||
.reaction-action.selected {
|
||||
opacity: 1;
|
||||
background-color: var(--normal-light-background-color);
|
||||
}
|
||||
|
||||
.reaction-count {
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.reply-toggle {
|
||||
cursor: pointer;
|
||||
color: var(--primary-color);
|
||||
@@ -378,10 +446,6 @@ const handleContentClick = (e) => {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.comment-reaction:hover {
|
||||
background-color: lightgray;
|
||||
}
|
||||
|
||||
.comment-highlight {
|
||||
animation: highlight 2s;
|
||||
}
|
||||
@@ -424,6 +488,16 @@ const handleContentClick = (e) => {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.article-footer-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
margin-top: 0px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
|
||||
.medal-name {
|
||||
font-size: 12px;
|
||||
margin-left: 1px;
|
||||
|
||||
305
frontend_nuxt/components/DonateGroup.vue
Normal file
305
frontend_nuxt/components/DonateGroup.vue
Normal file
@@ -0,0 +1,305 @@
|
||||
<template>
|
||||
<div class="donate-container">
|
||||
<ToolTip content="打赏作者" placement="bottom" v-if="donationList.length > 0">
|
||||
<div class="donate-viewer" @click="openPanel">
|
||||
<div
|
||||
class="donate-viewer-item-container"
|
||||
@mouseenter="cancelHide"
|
||||
@mouseleave="scheduleHide"
|
||||
>
|
||||
<BaseItemGroup
|
||||
:items="donationList"
|
||||
:overlap="10"
|
||||
:expanded-gap="2"
|
||||
:direction="vertical"
|
||||
>
|
||||
<template #item="{ item }">
|
||||
<BaseUserAvatar
|
||||
:user-id="item.userId"
|
||||
:src="item.avatar"
|
||||
:alt="item.username"
|
||||
:width="20"
|
||||
:disable-link="true"
|
||||
/>
|
||||
</template>
|
||||
</BaseItemGroup>
|
||||
<div class="donate-counts-text">{{ totalAmount }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</ToolTip>
|
||||
<ToolTip content="赞赏作者" placement="bottom" v-else>
|
||||
<div class="donate-viewer-item placeholder" @click="openPanel">
|
||||
<financing class="donate-viewer-item-placeholder-icon" />
|
||||
</div>
|
||||
</ToolTip>
|
||||
<div
|
||||
v-if="panelVisible"
|
||||
class="donate-panel"
|
||||
ref="donatePanelRef"
|
||||
:style="panelInlineStyle"
|
||||
@mouseenter="cancelHide"
|
||||
@mouseleave="scheduleHide"
|
||||
>
|
||||
<div
|
||||
v-for="option in donateOptions"
|
||||
:key="option"
|
||||
class="donate-option"
|
||||
:class="{ disabled: donating || isAuthorUser || !authState.loggedIn }"
|
||||
@click="handleDonate(option)"
|
||||
>
|
||||
<financing class="donate-option-icon" />
|
||||
<div class="donate-counts-text">{{ option }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { Finance } from '@icon-park/vue-next'
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { toast } from '~/main'
|
||||
import { authState, getToken } from '~/utils/auth'
|
||||
|
||||
const financing = Finance
|
||||
|
||||
const props = defineProps({
|
||||
postId: {
|
||||
type: [Number, String],
|
||||
required: true,
|
||||
},
|
||||
authorId: {
|
||||
type: [Number, String],
|
||||
required: true,
|
||||
},
|
||||
isAuthor: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
const config = useRuntimeConfig()
|
||||
const API_BASE_URL = config.public.apiBaseUrl
|
||||
|
||||
const panelVisible = ref(false)
|
||||
const donatePanelRef = ref(null)
|
||||
const panelInlineStyle = ref({})
|
||||
const donationSummary = ref({ totalAmount: 0, donations: [] })
|
||||
const donating = ref(false)
|
||||
let hideTimer = null
|
||||
|
||||
const donateOptions = [10, 30, 100]
|
||||
const donationList = computed(() => donationSummary.value?.donations ?? [])
|
||||
const totalAmount = computed(() => donationSummary.value?.totalAmount ?? 0)
|
||||
const isAuthorUser = computed(() => {
|
||||
if (props.isAuthor) return true
|
||||
if (!authState.userId || !props.authorId) return false
|
||||
return Number(authState.userId) === Number(props.authorId)
|
||||
})
|
||||
|
||||
const openPanel = () => {
|
||||
clearTimeout(hideTimer)
|
||||
panelVisible.value = true
|
||||
}
|
||||
|
||||
const scheduleHide = () => {
|
||||
clearTimeout(hideTimer)
|
||||
hideTimer = setTimeout(() => {
|
||||
panelVisible.value = false
|
||||
}, 500)
|
||||
}
|
||||
const cancelHide = () => {
|
||||
clearTimeout(hideTimer)
|
||||
}
|
||||
|
||||
const updatePanelInlineStyle = () => {
|
||||
if (!panelVisible.value) return
|
||||
const panelEl = donatePanelRef.value
|
||||
if (!panelEl) return
|
||||
const parentEl = panelEl.closest('.donate-container')?.parentElement.parentElement
|
||||
if (!parentEl) return
|
||||
const parentWidth = parentEl.clientWidth - 20
|
||||
panelInlineStyle.value = {
|
||||
width: 'max-content',
|
||||
maxWidth: `${parentWidth}px`,
|
||||
}
|
||||
}
|
||||
|
||||
watch(panelVisible, async (visible) => {
|
||||
if (visible) {
|
||||
await nextTick()
|
||||
updatePanelInlineStyle()
|
||||
}
|
||||
})
|
||||
|
||||
const normalizeSummary = (data) => ({
|
||||
totalAmount: data?.totalAmount ?? 0,
|
||||
donations: Array.isArray(data?.donations) ? data.donations : [],
|
||||
})
|
||||
|
||||
const loadDonations = async () => {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE_URL}/api/posts/${props.postId}/donations`)
|
||||
if (!res.ok) return
|
||||
const data = await res.json()
|
||||
donationSummary.value = normalizeSummary(data)
|
||||
} catch (e) {
|
||||
// ignore network errors for donation summary
|
||||
}
|
||||
}
|
||||
|
||||
const handleDonate = async (amount) => {
|
||||
if (!amount || donating.value) return
|
||||
if (!authState.loggedIn) {
|
||||
toast.error('请先登录后再打赏')
|
||||
panelVisible.value = false
|
||||
return
|
||||
}
|
||||
if (isAuthorUser.value) {
|
||||
toast.warning('不能给自己打赏')
|
||||
return
|
||||
}
|
||||
try {
|
||||
donating.value = true
|
||||
const token = getToken()
|
||||
const res = await fetch(`${API_BASE_URL}/api/posts/${props.postId}/donations`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: token ? `Bearer ${token}` : '',
|
||||
},
|
||||
body: JSON.stringify({ amount }),
|
||||
})
|
||||
const data = await res.json().catch(() => null)
|
||||
if (!res.ok) {
|
||||
if (res.status === 401) {
|
||||
toast.error('请先登录后再打赏')
|
||||
} else {
|
||||
toast.error(data?.error || '打赏失败')
|
||||
}
|
||||
return
|
||||
}
|
||||
donationSummary.value = normalizeSummary(data)
|
||||
toast.success('打赏成功,感谢你的支持!')
|
||||
panelVisible.value = false
|
||||
} catch (e) {
|
||||
toast.error('打赏失败,请稍后再试')
|
||||
} finally {
|
||||
donating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
window.addEventListener('resize', updatePanelInlineStyle)
|
||||
await loadDonations()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('resize', updatePanelInlineStyle)
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.postId,
|
||||
async () => {
|
||||
donationSummary.value = { totalAmount: 0, donations: [] }
|
||||
await loadDonations()
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.donate-container {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.donate-viewer-item-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.donate-viewer {
|
||||
border-radius: 13px;
|
||||
padding: 3px;
|
||||
padding-right: 6px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.5s ease;
|
||||
}
|
||||
|
||||
.donate-viewer:hover {
|
||||
background-color: var(--secondary-color-hover);
|
||||
}
|
||||
|
||||
.donate-counts-text {
|
||||
color: var(--primary-color);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.donate-panel {
|
||||
position: absolute;
|
||||
bottom: 35px;
|
||||
background-color: var(--background-color);
|
||||
border: 1px solid var(--normal-border-color);
|
||||
border-radius: 20px;
|
||||
padding: 5px 10px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
z-index: 10;
|
||||
gap: 5px;
|
||||
box-shadow: 0 0 10px 0 rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.donate-viewer-item.placeholder {
|
||||
display: flex;
|
||||
cursor: pointer;
|
||||
flex-direction: row;
|
||||
padding: 2px 10px;
|
||||
gap: 5px;
|
||||
border: 1px solid var(--normal-border-color);
|
||||
border-radius: 10px;
|
||||
margin-right: 5px;
|
||||
font-size: 14px;
|
||||
color: var(--text-color);
|
||||
align-items: center;
|
||||
background-color: var(--normal-light-background-color);
|
||||
}
|
||||
|
||||
.donate-viewer-item {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.donate-viewer-item-placeholder-icon {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.donate-option {
|
||||
cursor: pointer;
|
||||
padding: 3px 6px;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.donate-option:hover {
|
||||
background-color: var(--normal-light-background-color);
|
||||
}
|
||||
|
||||
.donate-option.disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.donate-option.disabled:hover {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.donate-option-icon {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
</style>
|
||||
@@ -42,6 +42,9 @@
|
||||
<span v-else-if="log.type === 'LOTTERY_RESULT'" class="change-log-content"
|
||||
>系统已「精密计算」抽奖结果 (=゚ω゚)ノ</span
|
||||
>
|
||||
<span v-else-if="log.type === 'DONATE'" class="change-log-content"
|
||||
>为文章打赏了 {{ log.amount ?? 0 }} 积分</span
|
||||
>
|
||||
</div>
|
||||
<div class="change-log-time">{{ log.time }}</div>
|
||||
<div
|
||||
|
||||
@@ -18,9 +18,11 @@
|
||||
<div>{{ counts[r.type] }}</div>
|
||||
</div>
|
||||
|
||||
<div class="reactions-viewer-item placeholder" @click="openPanel">
|
||||
<sly-face-whit-smile class="reactions-viewer-item-placeholder-icon" />
|
||||
</div>
|
||||
<ToolTip content="发表心情" placement="bottom">
|
||||
<div class="reactions-viewer-item placeholder" @click="openPanel">
|
||||
<sly-face-whit-smile class="reactions-viewer-item-placeholder-icon" />
|
||||
</div>
|
||||
</ToolTip>
|
||||
</template>
|
||||
<template v-else-if="displayedReactions.length">
|
||||
<div
|
||||
@@ -35,21 +37,11 @@
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div class="make-reaction-container">
|
||||
<div
|
||||
v-if="props.contentType !== 'message'"
|
||||
class="make-reaction-item like-reaction"
|
||||
@click="toggleReaction('LIKE')"
|
||||
>
|
||||
<like v-if="!userReacted('LIKE')" />
|
||||
<like v-else theme="filled" />
|
||||
<span class="reactions-count" v-if="likeCount">{{ likeCount }}</span>
|
||||
</div>
|
||||
<slot></slot>
|
||||
</div>
|
||||
<div
|
||||
v-if="panelVisible"
|
||||
class="reactions-panel"
|
||||
ref="reactionsPanelRef"
|
||||
:style="panelInlineStyle"
|
||||
@mouseenter="cancelHide"
|
||||
@mouseleave="scheduleHide"
|
||||
>
|
||||
@@ -69,7 +61,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { toast } from '~/main'
|
||||
import { authState, getToken } from '~/utils/auth'
|
||||
import { reactionEmojiMap } from '~/utils/reactions'
|
||||
@@ -102,8 +94,6 @@ const counts = computed(() => {
|
||||
})
|
||||
|
||||
const totalCount = computed(() => Object.values(counts.value).reduce((a, b) => a + b, 0))
|
||||
const likeCount = computed(() => counts.value['LIKE'] || 0)
|
||||
|
||||
const userReacted = (type) =>
|
||||
reactions.value.some((r) => r.type === type && r.user === authState.username)
|
||||
|
||||
@@ -152,9 +142,11 @@ const displayedReactions = computed(() => {
|
||||
.map((type) => ({ type }))
|
||||
})
|
||||
|
||||
const panelTypes = computed(() => sortedReactionTypes.value.filter((t) => t !== 'LIKE'))
|
||||
const panelTypes = computed(() => sortedReactionTypes.value)
|
||||
|
||||
const panelVisible = ref(false)
|
||||
const reactionsPanelRef = ref(null)
|
||||
const panelInlineStyle = ref({})
|
||||
let hideTimer = null
|
||||
const openPanel = () => {
|
||||
clearTimeout(hideTimer)
|
||||
@@ -170,6 +162,33 @@ const cancelHide = () => {
|
||||
clearTimeout(hideTimer)
|
||||
}
|
||||
|
||||
const updatePanelInlineStyle = () => {
|
||||
if (!panelVisible.value) return
|
||||
const panelEl = reactionsPanelRef.value
|
||||
if (!panelEl) return
|
||||
const parentEl = panelEl.closest('.reactions-container')?.parentElement?.parentElement
|
||||
if (!parentEl) return
|
||||
const parentWidth = parentEl.clientWidth - 20
|
||||
panelInlineStyle.value = {
|
||||
width: 'max-content',
|
||||
maxWidth: `${parentWidth}px`,
|
||||
}
|
||||
}
|
||||
|
||||
watch(panelVisible, async (visible) => {
|
||||
if (visible) {
|
||||
await nextTick()
|
||||
updatePanelInlineStyle()
|
||||
}
|
||||
})
|
||||
|
||||
watch(panelTypes, async () => {
|
||||
if (panelVisible.value) {
|
||||
await nextTick()
|
||||
updatePanelInlineStyle()
|
||||
}
|
||||
})
|
||||
|
||||
const toggleReaction = async (type) => {
|
||||
const token = getToken()
|
||||
if (!token) {
|
||||
@@ -245,6 +264,15 @@ const toggleReaction = async (type) => {
|
||||
|
||||
onMounted(async () => {
|
||||
await initialize()
|
||||
window.addEventListener('resize', updatePanelInlineStyle)
|
||||
})
|
||||
|
||||
defineExpose({
|
||||
toggleReaction,
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('resize', updatePanelInlineStyle)
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -253,11 +281,7 @@ onMounted(async () => {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.reactions-viewer {
|
||||
@@ -295,32 +319,6 @@ onMounted(async () => {
|
||||
padding-left: 5px;
|
||||
}
|
||||
|
||||
.make-reaction-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.make-reaction-item {
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
opacity: 0.5;
|
||||
border-radius: 8px;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.like-reaction {
|
||||
color: #ff0000;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.make-reaction-item:hover {
|
||||
background-color: #ffe2e2;
|
||||
}
|
||||
|
||||
.reactions-count {
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
@@ -328,7 +326,7 @@ onMounted(async () => {
|
||||
|
||||
.reactions-panel {
|
||||
position: absolute;
|
||||
bottom: 50px;
|
||||
bottom: 35px;
|
||||
background-color: var(--background-color);
|
||||
border: 1px solid var(--normal-border-color);
|
||||
border-radius: 20px;
|
||||
@@ -361,7 +359,6 @@ onMounted(async () => {
|
||||
border: 1px solid var(--normal-border-color);
|
||||
border-radius: 10px;
|
||||
margin-right: 5px;
|
||||
margin-bottom: 5px;
|
||||
font-size: 14px;
|
||||
color: var(--text-color);
|
||||
align-items: center;
|
||||
|
||||
Reference in New Issue
Block a user