Add detailed Copilot instructions and enhance child API logging
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:
2026-01-21 16:35:50 -05:00
parent 59b480621e
commit a47df7171c
6 changed files with 325 additions and 107 deletions

61
.github/copilot-instructions.md vendored Normal file
View File

@@ -0,0 +1,61 @@
# Reward Project: AI Coding Agent Instructions
## 🏗️ Architecture & Data Flow
- **Stack**: Flask (Python, backend) + Vue 3 (TypeScript, frontend) + TinyDB (JSON, thread-safe, see `db/`).
- **API**: RESTful endpoints in `api/`, grouped by entity (child, reward, task, user, image, etc). Each API file maps to a business domain.
- **Models**: Maintain strict 1:1 mapping between Python `@dataclass`es (`models/`) and TypeScript interfaces (`web/vue-app/src/common/models.ts`).
- **Database**: Use TinyDB with `from_dict()`/`to_dict()` for serialization. All logic should operate on model instances, not raw dicts.
- **Events**: Real-time updates via Server-Sent Events (SSE). Every mutation (add/edit/delete/trigger) must call `send_event_for_current_user` (see `events/`).
- **Changes**: Do not use comments to replace code. All changes must be reflected in both backend and frontend files as needed.
## 🧩 Key Patterns & Conventions
- **Frontend Styling**: Use only `:root` CSS variables from `global.css` for all colors, spacing, and tokens. Example: `--btn-primary`, `--list-item-bg-good`.
- **Scoped Styles**: All `.vue` files must use `<style scoped>`. Reference global variables for theme consistency.
- **Rewards UI**: If `points >= cost`, apply `--item-card-ready-shadow` and `--item-card-ready-border`.
- **API Error Handling**: Backend returns JSON with `error` and `code` (see `api/error_codes.py`). Frontend extracts `{ msg, code }` using `parseErrorResponse(res)` from `api.ts`.
- **Validation**: Use `isEmailValid` and `isPasswordStrong` (min 8 chars, 1 letter, 1 number) from `api.ts` for all user input. Use `sanitize_email()` for directory names and unique IDs.
- **JWT Auth**: Tokens are stored in HttpOnly, Secure, SameSite=Strict cookies.
## 🚦 Frontend Logic & Event Bus
- **SSE Event Management**: Register listeners in `onMounted`, clean up in `onUnmounted`. Listen for events like `child_task_triggered`, `child_reward_request`, `task_modified`, etc. See `web/vue-app/src/common/backendEvents.ts` and `components/BackendEventsListener.vue`.
- **UI Guardrails**:
- Before triggering a task, check for pending rewards. If found, prompt for cancellation before proceeding.
- On `EDIT`, always refetch the full object from the API to ensure state integrity.
- **Layout Hierarchy**: Use `ParentLayout` for admin/management, `ChildLayout` for dashboard/focus views.
## ⚖️ Business Logic & Safeguards
- **Points**: Always enforce `child.points = max(child.points, 0)` after any mutation.
- **Token Expiry**: Verification tokens expire in 4 hours; password reset tokens in 10 minutes.
- **Image Assets**: Models use `image_id` for storage; frontend resolves to `image_url` for rendering.
## 🛠️ Developer Workflows
- **Backend**: Run Flask with `python -m flask run --host=0.0.0.0 --port=5000` from project root. Main entry: `main.py`.
- **Frontend**: From `web/vue-app/`, run `npm install` then `npm run dev`.
- **Tests**: Run backend tests with `pytest tests/`. Frontend tests: `npm run test` in `web/vue-app/`.
- **Debugging**: Use VS Code launch configs or run Flask/Vue dev servers directly. For SSE, use browser dev tools to inspect event streams.
## 📁 Key Files & Directories
- `api/` — Flask API endpoints (one file per entity)
- `models/` — Python dataclasses (business logic, serialization)
- `db/` — TinyDB setup and helpers
- `events/` — SSE event types, broadcaster, payloads
- `web/vue-app/` — Vue 3 frontend (see `src/common/`, `src/components/`, `src/layout/`)
- `web/vue-app/src/common/models.ts` — TypeScript interfaces (mirror Python models)
- `web/vue-app/src/common/api.ts` — API helpers, error parsing, validation
- `web/vue-app/src/common/backendEvents.ts` — SSE event types and handlers
## 🧠 Integration & Cross-Component Patterns
- **Every backend mutation must trigger an SSE event** for the current user.
- **Frontend state is event-driven**: always listen for and react to SSE events for real-time updates.
- **Model changes require updating both Python and TypeScript definitions** to maintain parity.
---
For any unclear or missing conventions, review the referenced files or ask for clarification. Keep this document concise and actionable for AI agents.

View File

@@ -21,8 +21,10 @@ from models.child import Child
from models.pending_reward import PendingReward
from models.reward import Reward
from models.task import Task
import logging
child_api = Blueprint('child_api', __name__)
logger = logging.getLogger(__name__)
@child_api.route('/child/<name>', methods=['GET'])
@child_api.route('/child/<id>', methods=['GET'])
@@ -495,24 +497,30 @@ def trigger_child_reward(id):
return jsonify({'error': 'Reward not found in reward database'}), 404
reward: Reward = Reward.from_dict(reward_result[0])
# Check if child has enough points
logger.info(f'Child {child.name} has {child.points} points, reward {reward.name} costs {reward.cost} points')
if child.points < reward.cost:
points_needed = reward.cost - child.points
return jsonify({
'error': 'Insufficient points',
'points_needed': points_needed,
'current_points': child.points,
'reward_cost': reward.cost
}), 400
# Remove matching pending reward requests for this child and reward
PendingQuery = Query()
removed = pending_reward_db.remove(
(PendingQuery.child_id == child.id) & (PendingQuery.reward_id == reward.id)
)
if removed:
resp = send_event_for_current_user(Event(EventType.CHILD_REWARD_REQUEST.value, ChildRewardRequest(reward.id, child.id, ChildRewardRequest.REQUEST_GRANTED)))
if resp:
return resp
send_event_for_current_user(Event(EventType.CHILD_REWARD_REQUEST.value, ChildRewardRequest(reward.id, child.id, ChildRewardRequest.REQUEST_GRANTED)))
# update the child's points based on reward cost
child.points -= reward.cost
# update the child in the database
child_db.update({'points': child.points}, ChildQuery.id == id)
resp = send_event_for_current_user(Event(EventType.CHILD_REWARD_TRIGGERED.value, ChildRewardTriggered(reward.id, child.id, child.points)))
if resp:
return resp
send_event_for_current_user(Event(EventType.CHILD_REWARD_TRIGGERED.value, ChildRewardTriggered(reward.id, child.id, child.points)))
return jsonify({'message': f'{reward.name} assigned to {child.name}.', 'points': child.points, 'id': child.id}), 200
@child_api.route('/child/<id>/affordable-rewards', methods=['GET'])
@@ -584,6 +592,7 @@ def request_reward(id):
reward = Reward.from_dict(reward_result[0])
# Check if child has enough points
logger.info(f'Child {child.name} has {child.points} points, reward {reward.name} costs {reward.cost} points')
if child.points < reward.cost:
points_needed = reward.cost - child.points
return jsonify({
@@ -595,9 +604,8 @@ def request_reward(id):
pending = PendingReward(child_id=child.id, reward_id=reward.id)
pending_reward_db.insert(pending.to_dict())
resp = send_event_for_current_user(Event(EventType.CHILD_REWARD_REQUEST.value, ChildRewardRequest(child.id, reward.id, ChildRewardRequest.REQUEST_CREATED)))
if resp:
return resp
logger.info(f'Pending reward request created for child {child.name} for reward {reward.name}')
send_event_for_current_user(Event(EventType.CHILD_REWARD_REQUEST.value, ChildRewardRequest(child.id, reward.id, ChildRewardRequest.REQUEST_CREATED)))
return jsonify({
'message': f'Reward request for {reward.name} submitted for {child.name}.',
'reward_id': reward.id,

View File

@@ -29,6 +29,7 @@ def get_queue(user_id: str, connection_id: str) -> queue.Queue:
def send_to_user(user_id: str, data: Dict[str, Any]):
"""Send data to all connections for a specific user."""
logger.info(f"Sending data to {user_id} user quesues are {user_queues.keys()}")
logger.info(f"Data: {data}")
if user_id in user_queues:
logger.info(f"Queued {user_id}")
# Format as SSE message once
@@ -37,8 +38,6 @@ def send_to_user(user_id: str, data: Dict[str, Any]):
# Send to all connections for this user
for connection_id, q in user_queues[user_id].items():
try:
logger.info(f"Sending message to {connection_id}")
q.put(message)
q.put(message, block=False)
except queue.Full:
# Skip if queue is full (connection might be dead)
@@ -61,7 +60,6 @@ def sse_response_for_user(user_id: str):
try:
while True:
# Get message from queue (blocks until available)
logger.info(f"blocking on get for {user_id} user")
message = user_queue.get()
yield message
except GeneratorExit:

View File

@@ -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)
}
if (pending) {
console.log('Reward data is:', reward)
if (reward.redeeming) {
dialogReward.value = reward
showCancelDialog.value = true
return // Do not allow redeeming if already pending
}
if (redeemable) {
if (reward.points_needed <= 0) {
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>

View File

@@ -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>

View File

@@ -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 {