Add detailed Copilot instructions and enhance child API logging
All checks were successful
Gitea Actions Demo / build-and-push (push) Successful in 14s
All checks were successful
Gitea Actions Demo / build-and-push (push) Successful in 14s
- Introduced a comprehensive instructions document for the Reward project, outlining architecture, data flow, key patterns, and developer workflows. - Enhanced logging in the child API to track points and reward costs, improving error handling for insufficient points. - Updated Vue components to reflect changes in reward handling and improve user experience with pending rewards.
This commit is contained in:
@@ -1,8 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { ref, onMounted, onUnmounted, computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import ChildDetailCard from './ChildDetailCard.vue'
|
||||
import ChildRewardList from '../reward/ChildRewardList.vue'
|
||||
import ScrollingList from '../shared/ScrollingList.vue'
|
||||
import { eventBus } from '@/common/eventBus'
|
||||
import '@/assets/view-shared.css'
|
||||
@@ -11,6 +10,7 @@ import type {
|
||||
Event,
|
||||
Task,
|
||||
Reward,
|
||||
RewardStatus,
|
||||
ChildTaskTriggeredEventPayload,
|
||||
ChildRewardTriggeredEventPayload,
|
||||
ChildRewardRequestEventPayload,
|
||||
@@ -33,8 +33,6 @@ const showRewardDialog = ref(false)
|
||||
const showCancelDialog = ref(false)
|
||||
const dialogReward = ref<Reward | null>(null)
|
||||
const childRewardListRef = ref()
|
||||
const childChoreListRef = ref()
|
||||
const childPenaltyListRef = ref()
|
||||
|
||||
function handleTaskTriggered(event: Event) {
|
||||
const payload = event.payload as ChildTaskTriggeredEventPayload
|
||||
@@ -47,7 +45,6 @@ function handleRewardTriggered(event: Event) {
|
||||
const payload = event.payload as ChildRewardTriggeredEventPayload
|
||||
if (child.value && payload.child_id == child.value.id) {
|
||||
child.value.points = payload.points
|
||||
childRewardListRef.value?.refresh()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,7 +59,6 @@ function handleChildRewardSet(event: Event) {
|
||||
const payload = event.payload as ChildRewardsSetEventPayload
|
||||
if (child.value && payload.child_id == child.value.id) {
|
||||
rewards.value = payload.reward_ids
|
||||
childRewardListRef.value?.refresh()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,26 +167,25 @@ const triggerTask = (task: Task) => {
|
||||
}
|
||||
}
|
||||
|
||||
const triggerReward = (reward: Reward) => {}
|
||||
/*
|
||||
|
||||
const triggerReward = (reward: Reward, redeemable: boolean, pending: boolean) => {
|
||||
const triggerReward = (reward: RewardStatus) => {
|
||||
if ('speechSynthesis' in window && reward.name) {
|
||||
const utterString =
|
||||
reward.name + (redeemable ? '' : `, You still need ${reward.points_needed} points.`)
|
||||
reward.name +
|
||||
(reward.points_needed <= 0 ? '' : `, You still need ${reward.points_needed} points.`)
|
||||
const utter = new window.SpeechSynthesisUtterance(utterString)
|
||||
window.speechSynthesis.speak(utter)
|
||||
console.log('Reward data is:', reward)
|
||||
if (reward.redeeming) {
|
||||
dialogReward.value = reward
|
||||
showCancelDialog.value = true
|
||||
return // Do not allow redeeming if already pending
|
||||
}
|
||||
if (reward.points_needed <= 0) {
|
||||
dialogReward.value = reward
|
||||
showRewardDialog.value = true
|
||||
}
|
||||
}
|
||||
if (pending) {
|
||||
dialogReward.value = reward
|
||||
showCancelDialog.value = true
|
||||
return // Do not allow redeeming if already pending
|
||||
}
|
||||
if (redeemable) {
|
||||
dialogReward.value = reward
|
||||
showRewardDialog.value = true
|
||||
}
|
||||
}*/
|
||||
}
|
||||
|
||||
async function cancelPendingReward() {
|
||||
if (!child.value?.id || !dialogReward.value) return
|
||||
@@ -258,7 +253,7 @@ function resetInactivityTimer() {
|
||||
if (inactivityTimer) clearTimeout(inactivityTimer)
|
||||
inactivityTimer = setTimeout(() => {
|
||||
router.push({ name: 'ChildrenListView' })
|
||||
}, 6000000) // 60 seconds
|
||||
}, 60000) // 60 seconds
|
||||
}
|
||||
|
||||
function setupInactivityListeners() {
|
||||
@@ -272,6 +267,10 @@ function removeInactivityListeners() {
|
||||
if (inactivityTimer) clearTimeout(inactivityTimer)
|
||||
}
|
||||
|
||||
const hasPendingRewards = computed(() =>
|
||||
childRewardListRef.value?.items.some((r: RewardStatus) => r.redeeming),
|
||||
)
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
eventBus.on('child_task_triggered', handleTaskTriggered)
|
||||
@@ -379,19 +378,15 @@ onUnmounted(() => {
|
||||
<ScrollingList
|
||||
title="Rewards"
|
||||
ref="childRewardListRef"
|
||||
:fetchBaseUrl="`/api/reward/list?ids=${rewards.join(',')}`"
|
||||
:ids="rewards"
|
||||
itemKey="rewards"
|
||||
:fetchBaseUrl="`/api/child/${child?.id}/reward-status`"
|
||||
itemKey="reward_status"
|
||||
imageField="image_id"
|
||||
@trigger-item="triggerReward"
|
||||
:getItemClass="(item) => ({ bad: !item.is_good, good: item.is_good })"
|
||||
:filter-fn="
|
||||
(item) => {
|
||||
return !item.is_good
|
||||
}
|
||||
:getItemClass="
|
||||
(item) => ({ reward: true, disabled: hasPendingRewards && !item.redeeming })
|
||||
"
|
||||
>
|
||||
<template #item="{ item }">
|
||||
<template #item="{ item }: { item: RewardStatus }">
|
||||
<div class="item-name">{{ item.name }}</div>
|
||||
<img
|
||||
v-if="item.image_url"
|
||||
@@ -399,21 +394,13 @@ onUnmounted(() => {
|
||||
alt="Reward Image"
|
||||
class="item-image"
|
||||
/>
|
||||
<div
|
||||
class="item-points"
|
||||
:class="{ 'good-points': item.is_good, 'bad-points': !item.is_good }"
|
||||
>
|
||||
{{ item.is_good ? item.points : -item.points }} Points
|
||||
<div class="item-points">
|
||||
<span v-if="item.redeeming" class="pending">PENDING</span>
|
||||
<span v-if="item.points_needed <= 0" class="ready">REWARD READY</span>
|
||||
<span v-else>{{ item.points_needed }} more points</span>
|
||||
</div>
|
||||
</template>
|
||||
</ScrollingList>
|
||||
<ChildRewardList
|
||||
ref="childRewardListRef"
|
||||
:child-id="child ? child.id : null"
|
||||
:child-points="child?.points ?? 0"
|
||||
:is-parent-authenticated="false"
|
||||
@trigger-reward="triggerReward"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -421,8 +408,8 @@ onUnmounted(() => {
|
||||
<div class="modal">
|
||||
<div class="reward-info">
|
||||
<img
|
||||
v-if="dialogReward.image_id"
|
||||
:src="dialogReward.image_id"
|
||||
v-if="dialogReward.image_url"
|
||||
:src="dialogReward.image_url"
|
||||
alt="Reward Image"
|
||||
class="reward-image"
|
||||
/>
|
||||
@@ -445,8 +432,8 @@ onUnmounted(() => {
|
||||
<div class="modal">
|
||||
<div class="reward-info">
|
||||
<img
|
||||
v-if="dialogReward.image_id"
|
||||
:src="dialogReward.image_id"
|
||||
v-if="dialogReward.image_url"
|
||||
:src="dialogReward.image_url"
|
||||
alt="Reward Image"
|
||||
class="reward-image"
|
||||
/>
|
||||
@@ -469,6 +456,13 @@ onUnmounted(() => {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.assign-buttons {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: center;
|
||||
margin: 2rem 0;
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
background: var(--back-btn-bg);
|
||||
border: 0;
|
||||
@@ -480,19 +474,37 @@ onUnmounted(() => {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.assign-buttons {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: center;
|
||||
margin: 2rem 0;
|
||||
}
|
||||
|
||||
.item-points {
|
||||
color: var(--item-points-color, #ffd166);
|
||||
font-size: 1rem;
|
||||
font-weight: 900;
|
||||
text-shadow: var(--item-points-shadow);
|
||||
}
|
||||
.ready {
|
||||
color: var(--item-points-ready-color, #38c172);
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.pending {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 80%;
|
||||
background: var(--pending-block-bg, #222b);
|
||||
color: var(--pending-block-color, #62ff7a);
|
||||
font-weight: 700;
|
||||
font-size: 1.05rem;
|
||||
text-align: center;
|
||||
border-radius: 6px;
|
||||
padding: 0.4rem 0;
|
||||
letter-spacing: 2px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 2;
|
||||
opacity: 0.95;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Mobile tweaks */
|
||||
@media (max-width: 480px) {
|
||||
@@ -509,4 +521,13 @@ onUnmounted(() => {
|
||||
border-color: var(--list-item-border-bad);
|
||||
background: var(--list-item-bg-bad);
|
||||
}
|
||||
:deep(.reward) {
|
||||
border-color: var(--list-item-border-reward);
|
||||
background: var(--list-item-bg-reward);
|
||||
}
|
||||
:deep(.disabled) {
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
filter: grayscale(0.7);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed, onUnmounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { isParentAuthenticated } from '../../stores/auth'
|
||||
import ChildDetailCard from './ChildDetailCard.vue'
|
||||
import ChildTaskList from '../task/ChildTaskList.vue'
|
||||
import ChildRewardList from '../reward/ChildRewardList.vue'
|
||||
import ScrollingList from '../shared/ScrollingList.vue'
|
||||
import { eventBus } from '@/common/eventBus'
|
||||
import '@/assets/view-shared.css'
|
||||
import type {
|
||||
@@ -36,14 +34,13 @@ const selectedTask = ref<Task | null>(null)
|
||||
const showRewardConfirm = ref(false)
|
||||
const selectedReward = ref<Reward | null>(null)
|
||||
const childRewardListRef = ref()
|
||||
const childChoreListRef = ref()
|
||||
const childHabitListRef = ref()
|
||||
const showPendingRewardDialog = ref(false)
|
||||
|
||||
function handleTaskTriggered(event: Event) {
|
||||
const payload = event.payload as ChildTaskTriggeredEventPayload
|
||||
if (child.value && payload.child_id == child.value.id) {
|
||||
child.value.points = payload.points
|
||||
childRewardListRef.value?.refresh()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,7 +63,6 @@ function handleChildRewardSet(event: Event) {
|
||||
const payload = event.payload as ChildRewardsSetEventPayload
|
||||
if (child.value && payload.child_id == child.value.id) {
|
||||
rewards.value = payload.reward_ids
|
||||
childRewardListRef.value?.refresh()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,6 +101,7 @@ function handleTaskModified(event: Event) {
|
||||
if (data) {
|
||||
tasks.value = data.tasks || []
|
||||
}
|
||||
loading.value = false
|
||||
})
|
||||
} catch (err) {
|
||||
console.warn('Failed to fetch child after EDIT operation:', err)
|
||||
@@ -117,6 +114,7 @@ function handleTaskModified(event: Event) {
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Failed to fetch child after task modification:', err)
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -154,9 +152,11 @@ function handleChildModified(event: Event) {
|
||||
if (data) {
|
||||
child.value = data
|
||||
}
|
||||
loading.value = false
|
||||
})
|
||||
} catch (err) {
|
||||
console.warn('Failed to fetch child after EDIT operation:', err)
|
||||
loading.value = false
|
||||
}
|
||||
break
|
||||
default:
|
||||
@@ -178,7 +178,6 @@ async function fetchChildData(id: string | number) {
|
||||
console.error(err)
|
||||
return null
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -203,6 +202,7 @@ onMounted(async () => {
|
||||
tasks.value = data.tasks || []
|
||||
rewards.value = data.rewards || []
|
||||
}
|
||||
loading.value = false
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -222,10 +222,16 @@ onUnmounted(() => {
|
||||
eventBus.off('reward_modified', handleRewardModified)
|
||||
})
|
||||
|
||||
function getPendingRewardIds(): string[] {
|
||||
const items = childRewardListRef.value?.items || []
|
||||
return items.filter((item: RewardStatus) => item.redeeming).map((item: RewardStatus) => item.id)
|
||||
}
|
||||
|
||||
const triggerTask = (task: Task) => {
|
||||
selectedTask.value = task
|
||||
const pendingRewardIds = childRewardListRef.value?.getPendingRewards()
|
||||
if (pendingRewardIds && pendingRewardIds.length > 0) {
|
||||
const pendingRewardIds = getPendingRewardIds()
|
||||
console.log('Pending reward IDs:', pendingRewardIds)
|
||||
if (pendingRewardIds.length > 0) {
|
||||
showPendingRewardDialog.value = true
|
||||
return
|
||||
}
|
||||
@@ -253,8 +259,8 @@ async function cancelPendingReward() {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const pendingRewardIds = childRewardListRef.value?.getPendingRewards()
|
||||
await Promise.all(pendingRewardIds?.map((id: string) => cancelRewardById(id)) || [])
|
||||
const pendingRewardIds = getPendingRewardIds()
|
||||
await Promise.all(pendingRewardIds.map((id: string) => cancelRewardById(id)))
|
||||
childRewardListRef.value?.refresh()
|
||||
} catch (err) {
|
||||
console.error('Failed to cancel pending reward:', err)
|
||||
@@ -286,8 +292,8 @@ const confirmTriggerTask = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const triggerReward = (reward: Reward, redeemable: boolean) => {
|
||||
if (!redeemable) return
|
||||
const triggerReward = (reward: RewardStatus) => {
|
||||
if (reward.points_needed > 0) return
|
||||
selectedReward.value = reward
|
||||
showRewardConfirm.value = true
|
||||
}
|
||||
@@ -328,8 +334,6 @@ function goToAssignRewards() {
|
||||
router.push({ name: 'RewardAssignView', params: { id: child.value.id } })
|
||||
}
|
||||
}
|
||||
|
||||
const childId = computed(() => child.value?.id ?? null)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -340,31 +344,82 @@ const childId = computed(() => child.value?.id ?? null)
|
||||
<div v-else class="layout">
|
||||
<div class="main">
|
||||
<ChildDetailCard :child="child" />
|
||||
<ChildTaskList
|
||||
<ScrollingList
|
||||
title="Chores"
|
||||
ref="childChoreListRef"
|
||||
:task-ids="tasks"
|
||||
:child-id="childId"
|
||||
:is-parent-authenticated="isParentAuthenticated"
|
||||
:filter-type="1"
|
||||
@trigger-task="triggerTask"
|
||||
/>
|
||||
<ChildTaskList
|
||||
title="Bad Habits"
|
||||
ref="childHabitListRef"
|
||||
:task-ids="tasks"
|
||||
:child-id="childId"
|
||||
:is-parent-authenticated="isParentAuthenticated"
|
||||
:filter-type="2"
|
||||
@trigger-task="triggerTask"
|
||||
/>
|
||||
<ChildRewardList
|
||||
:fetchBaseUrl="`/api/task/list?ids=${tasks.join(',')}`"
|
||||
:ids="tasks"
|
||||
itemKey="tasks"
|
||||
imageField="image_id"
|
||||
@trigger-item="triggerTask"
|
||||
:getItemClass="(item) => ({ bad: !item.is_good, good: item.is_good })"
|
||||
:filter-fn="
|
||||
(item) => {
|
||||
return item.is_good
|
||||
}
|
||||
"
|
||||
>
|
||||
<template #item="{ item }">
|
||||
<div class="item-name">{{ item.name }}</div>
|
||||
<img v-if="item.image_url" :src="item.image_url" alt="Task Image" class="item-image" />
|
||||
<div
|
||||
class="item-points"
|
||||
:class="{ 'good-points': item.is_good, 'bad-points': !item.is_good }"
|
||||
>
|
||||
{{ item.is_good ? item.points : -item.points }} Points
|
||||
</div>
|
||||
</template>
|
||||
</ScrollingList>
|
||||
<ScrollingList
|
||||
title="Penalties"
|
||||
ref="childPenaltyListRef"
|
||||
:fetchBaseUrl="`/api/task/list?ids=${tasks.join(',')}`"
|
||||
:ids="tasks"
|
||||
itemKey="tasks"
|
||||
imageField="image_id"
|
||||
@trigger-item="triggerTask"
|
||||
:getItemClass="(item) => ({ bad: !item.is_good, good: item.is_good })"
|
||||
:filter-fn="
|
||||
(item) => {
|
||||
return !item.is_good
|
||||
}
|
||||
"
|
||||
>
|
||||
<template #item="{ item }">
|
||||
<div class="item-name">{{ item.name }}</div>
|
||||
<img v-if="item.image_url" :src="item.image_url" alt="Task Image" class="item-image" />
|
||||
<div
|
||||
class="item-points"
|
||||
:class="{ 'good-points': item.is_good, 'bad-points': !item.is_good }"
|
||||
>
|
||||
{{ item.is_good ? item.points : -item.points }} Points
|
||||
</div>
|
||||
</template>
|
||||
</ScrollingList>
|
||||
<ScrollingList
|
||||
title="Rewards"
|
||||
ref="childRewardListRef"
|
||||
:child-id="childId"
|
||||
:child-points="child?.points ?? 0"
|
||||
:is-parent-authenticated="false"
|
||||
@trigger-reward="triggerReward"
|
||||
/>
|
||||
:fetchBaseUrl="`/api/child/${child?.id}/reward-status`"
|
||||
itemKey="reward_status"
|
||||
imageField="image_id"
|
||||
@trigger-item="triggerReward"
|
||||
:getItemClass="(item) => ({ reward: true })"
|
||||
>
|
||||
<template #item="{ item }: { item: RewardStatus }">
|
||||
<div class="item-name">{{ item.name }}</div>
|
||||
<img
|
||||
v-if="item.image_url"
|
||||
:src="item.image_url"
|
||||
alt="Reward Image"
|
||||
class="item-image"
|
||||
/>
|
||||
<div class="item-points">
|
||||
<span v-if="item.redeeming" class="pending">PENDING</span>
|
||||
<span v-if="item.points_needed <= 0" class="ready">REWARD READY</span>
|
||||
<span v-else>{{ item.points_needed }} more points</span>
|
||||
</div>
|
||||
</template>
|
||||
</ScrollingList>
|
||||
</div>
|
||||
</div>
|
||||
<div class="assign-buttons">
|
||||
@@ -432,8 +487,8 @@ const childId = computed(() => child.value?.id ?? null)
|
||||
<div class="modal">
|
||||
<div class="reward-info">
|
||||
<img
|
||||
v-if="selectedReward.image_id"
|
||||
:src="selectedReward.image_id"
|
||||
v-if="selectedReward.image_url"
|
||||
:src="selectedReward.image_url"
|
||||
alt="Reward Image"
|
||||
class="reward-image"
|
||||
/>
|
||||
@@ -478,4 +533,68 @@ const childId = computed(() => child.value?.id ?? null)
|
||||
justify-content: center;
|
||||
margin: 2rem 0;
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
background: var(--back-btn-bg);
|
||||
border: 0;
|
||||
padding: 0.6rem 1rem;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
margin-bottom: 1.5rem;
|
||||
color: var(--back-btn-color);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.item-points {
|
||||
color: var(--item-points-color, #ffd166);
|
||||
font-size: 1rem;
|
||||
font-weight: 900;
|
||||
text-shadow: var(--item-points-shadow);
|
||||
}
|
||||
|
||||
.ready {
|
||||
color: var(--item-points-ready-color, #38c172);
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.pending {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 80%;
|
||||
background: var(--pending-block-bg, #222b);
|
||||
color: var(--pending-block-color, #62ff7a);
|
||||
font-weight: 700;
|
||||
font-size: 1.05rem;
|
||||
text-align: center;
|
||||
border-radius: 6px;
|
||||
padding: 0.4rem 0;
|
||||
letter-spacing: 2px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 2;
|
||||
opacity: 0.95;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Mobile tweaks */
|
||||
@media (max-width: 480px) {
|
||||
.item-points {
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.good) {
|
||||
border-color: var(--list-item-border-good);
|
||||
background: var(--list-item-bg-good);
|
||||
}
|
||||
:deep(.bad) {
|
||||
border-color: var(--list-item-border-bad);
|
||||
background: var(--list-item-bg-bad);
|
||||
}
|
||||
:deep(.reward) {
|
||||
border-color: var(--list-item-border-reward);
|
||||
background: var(--list-item-bg-reward);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { getCachedImageUrl, revokeAllImageUrls } from '@/common/imageCache'
|
||||
const props = defineProps<{
|
||||
title: string
|
||||
fetchBaseUrl: string
|
||||
ids: readonly string[]
|
||||
ids?: readonly string[]
|
||||
itemKey: string
|
||||
imageFields?: readonly string[]
|
||||
isParentAuthenticated?: boolean
|
||||
@@ -78,6 +78,16 @@ const fetchItems = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
await fetchItems()
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
refresh,
|
||||
items,
|
||||
})
|
||||
|
||||
const centerItem = async (itemId: string) => {
|
||||
await nextTick()
|
||||
const wrapper = scrollWrapper.value
|
||||
@@ -231,6 +241,7 @@ onBeforeUnmount(() => {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
user-select: none; /* Prevent image selection */
|
||||
}
|
||||
|
||||
.item-card:hover {
|
||||
|
||||
Reference in New Issue
Block a user