This commit is contained in:
17
frontend/vue-app/src/common/api.ts
Normal file
17
frontend/vue-app/src/common/api.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
export async function parseErrorResponse(res: Response): Promise<{ msg: string; code?: string }> {
|
||||
try {
|
||||
const data = await res.json()
|
||||
return { msg: data.error || data.message || 'Error', code: data.code }
|
||||
} catch {
|
||||
const text = await res.text()
|
||||
return { msg: text || 'Error' }
|
||||
}
|
||||
}
|
||||
|
||||
export function isEmailValid(email: string): boolean {
|
||||
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)
|
||||
}
|
||||
|
||||
export function isPasswordStrong(password: string): boolean {
|
||||
return /^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]{8,}$/.test(password)
|
||||
}
|
||||
30
frontend/vue-app/src/common/backendEvents.ts
Normal file
30
frontend/vue-app/src/common/backendEvents.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { onMounted, onBeforeUnmount, watch } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
import { eventBus } from './eventBus'
|
||||
|
||||
export function useBackendEvents(userId: Ref<string>) {
|
||||
let eventSource: EventSource | null = null
|
||||
|
||||
const connect = () => {
|
||||
if (eventSource) eventSource.close()
|
||||
if (userId.value) {
|
||||
console.log('Connecting to backend events for user:', userId.value)
|
||||
eventSource = new EventSource(`/events?user_id=${userId.value}`)
|
||||
|
||||
eventSource.onmessage = (event) => {
|
||||
const data = JSON.parse(event.data)
|
||||
|
||||
// Emit globally for any component that cares
|
||||
eventBus.emit(data.type, data)
|
||||
eventBus.emit('sse', data) // optional: catch-all channel
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(connect)
|
||||
watch(userId, connect)
|
||||
onBeforeUnmount(() => {
|
||||
console.log('Disconnecting from backend events for user:', userId.value)
|
||||
eventSource?.close()
|
||||
})
|
||||
}
|
||||
12
frontend/vue-app/src/common/errorCodes.ts
Normal file
12
frontend/vue-app/src/common/errorCodes.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export const MISSING_FIELDS = 'MISSING_FIELDS'
|
||||
export const EMAIL_EXISTS = 'EMAIL_EXISTS'
|
||||
export const MISSING_TOKEN = 'MISSING_TOKEN'
|
||||
export const INVALID_TOKEN = 'INVALID_TOKEN'
|
||||
export const TOKEN_TIMESTAMP_MISSING = 'TOKEN_TIMESTAMP_MISSING'
|
||||
export const TOKEN_EXPIRED = 'TOKEN_EXPIRED'
|
||||
export const MISSING_EMAIL = 'MISSING_EMAIL'
|
||||
export const USER_NOT_FOUND = 'USER_NOT_FOUND'
|
||||
export const ALREADY_VERIFIED = 'ALREADY_VERIFIED'
|
||||
export const MISSING_EMAIL_OR_PASSWORD = 'MISSING_EMAIL_OR_PASSWORD'
|
||||
export const INVALID_CREDENTIALS = 'INVALID_CREDENTIALS'
|
||||
export const NOT_VERIFIED = 'NOT_VERIFIED'
|
||||
29
frontend/vue-app/src/common/eventBus.ts
Normal file
29
frontend/vue-app/src/common/eventBus.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
type Callback = (payload: any) => void
|
||||
|
||||
class EventBus {
|
||||
private listeners: Map<string, Callback[]> = new Map()
|
||||
|
||||
on(event: string, callback: Callback) {
|
||||
if (!this.listeners.has(event)) {
|
||||
this.listeners.set(event, [])
|
||||
}
|
||||
this.listeners.get(event)!.push(callback)
|
||||
}
|
||||
|
||||
off(event: string, callback: Callback) {
|
||||
const list = this.listeners.get(event)
|
||||
if (!list) return
|
||||
|
||||
const index = list.indexOf(callback)
|
||||
if (index !== -1) list.splice(index, 1)
|
||||
}
|
||||
|
||||
emit(event: string, payload?: any) {
|
||||
const list = this.listeners.get(event)
|
||||
if (!list) return
|
||||
|
||||
list.forEach((callback) => callback(payload))
|
||||
}
|
||||
}
|
||||
|
||||
export const eventBus = new EventBus()
|
||||
64
frontend/vue-app/src/common/imageCache.ts
Normal file
64
frontend/vue-app/src/common/imageCache.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
export const DEFAULT_IMAGE_CACHE = 'images-v1'
|
||||
|
||||
const objectUrlMap = new Map<string, string>()
|
||||
|
||||
export async function getCachedImageUrl(
|
||||
imageId: string,
|
||||
cacheName = DEFAULT_IMAGE_CACHE,
|
||||
): Promise<string> {
|
||||
if (!imageId) throw new Error('imageId required')
|
||||
|
||||
// reuse existing object URL if created in this session
|
||||
const existing = objectUrlMap.get(imageId)
|
||||
if (existing) return existing
|
||||
|
||||
const requestUrl = `/api/image/request/${imageId}`
|
||||
|
||||
// Try Cache Storage first
|
||||
let response: Response | undefined
|
||||
if ('caches' in window) {
|
||||
const cache = await caches.open(cacheName)
|
||||
response = await cache.match(requestUrl)
|
||||
if (!response) {
|
||||
const fetched = await fetch(requestUrl)
|
||||
if (!fetched.ok) throw new Error(`HTTP ${fetched.status}`)
|
||||
// store a clone in Cache Storage (non-blocking)
|
||||
cache.put(requestUrl, fetched.clone()).catch((e) => {
|
||||
console.warn('Cache put failed:', e)
|
||||
})
|
||||
response = fetched
|
||||
}
|
||||
} else {
|
||||
const fetched = await fetch(requestUrl)
|
||||
if (!fetched.ok) throw new Error(`HTTP ${fetched.status}`)
|
||||
response = fetched
|
||||
}
|
||||
|
||||
const blob = await response.blob()
|
||||
const objectUrl = URL.createObjectURL(blob)
|
||||
objectUrlMap.set(imageId, objectUrl)
|
||||
return objectUrl
|
||||
}
|
||||
|
||||
export function revokeImageUrl(imageId: string) {
|
||||
const url = objectUrlMap.get(imageId)
|
||||
if (url) {
|
||||
try {
|
||||
URL.revokeObjectURL(url)
|
||||
} catch (e) {
|
||||
/* ignore */
|
||||
}
|
||||
objectUrlMap.delete(imageId)
|
||||
}
|
||||
}
|
||||
|
||||
export function revokeAllImageUrls() {
|
||||
for (const url of objectUrlMap.values()) {
|
||||
try {
|
||||
URL.revokeObjectURL(url)
|
||||
} catch (e) {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
objectUrlMap.clear()
|
||||
}
|
||||
124
frontend/vue-app/src/common/models.ts
Normal file
124
frontend/vue-app/src/common/models.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
export interface Task {
|
||||
id: string
|
||||
name: string
|
||||
points: number
|
||||
is_good: boolean
|
||||
image_id: string | null
|
||||
image_url?: string | null // optional, for resolved URLs
|
||||
}
|
||||
export const TASK_FIELDS = ['id', 'name', 'points', 'is_good', 'image_id'] as const
|
||||
|
||||
export interface Child {
|
||||
id: string
|
||||
name: string
|
||||
age: number
|
||||
tasks: string[]
|
||||
rewards: string[]
|
||||
points: number
|
||||
image_id: string | null
|
||||
image_url?: string | null // optional, for resolved URLs
|
||||
}
|
||||
export const CHILD_FIELDS = ['id', 'name', 'age', 'tasks', 'rewards', 'points', 'image_id'] as const
|
||||
|
||||
export interface Reward {
|
||||
id: string
|
||||
name: string
|
||||
cost: number
|
||||
points_needed: number
|
||||
image_id: string | null
|
||||
image_url?: string | null // optional, for resolved URLs
|
||||
}
|
||||
export const REWARD_FIELDS = ['id', 'name', 'cost', 'points_needed', 'image_id'] as const
|
||||
|
||||
export interface RewardStatus {
|
||||
id: string
|
||||
name: string
|
||||
points_needed: number
|
||||
cost: number
|
||||
redeeming: boolean
|
||||
image_id: string | null
|
||||
image_url?: string | null // optional, for resolved URLs
|
||||
}
|
||||
export const REWARD_STATUS_FIELDS = [
|
||||
'id',
|
||||
'name',
|
||||
'points_needed',
|
||||
'cost',
|
||||
'redeeming',
|
||||
'image_id',
|
||||
] as const
|
||||
|
||||
export interface PendingReward {
|
||||
id: string
|
||||
child_id: string
|
||||
child_name: string
|
||||
child_image_id: string | null
|
||||
child_image_url?: string | null // optional, for resolved URLs
|
||||
reward_id: string
|
||||
reward_name: string
|
||||
reward_image_id: string | null
|
||||
reward_image_url?: string | null // optional, for resolved URLs
|
||||
}
|
||||
export const PENDING_REWARD_FIELDS = [
|
||||
'id',
|
||||
'child_id',
|
||||
'child_name',
|
||||
'child_image_id',
|
||||
'reward_id',
|
||||
'reward_name',
|
||||
'reward_image_id',
|
||||
] as const
|
||||
|
||||
export interface Event {
|
||||
type: string
|
||||
payload:
|
||||
| ChildModifiedEventPayload
|
||||
| ChildTaskTriggeredEventPayload
|
||||
| ChildRewardTriggeredEventPayload
|
||||
| ChildRewardRequestEventPayload
|
||||
| ChildTasksSetEventPayload
|
||||
| ChildRewardsSetEventPayload
|
||||
| TaskModifiedEventPayload
|
||||
| RewardModifiedEventPayload
|
||||
}
|
||||
|
||||
export interface ChildModifiedEventPayload {
|
||||
child_id: string
|
||||
operation: 'ADD' | 'DELETE' | 'EDIT'
|
||||
}
|
||||
|
||||
export interface ChildTaskTriggeredEventPayload {
|
||||
task_id: string
|
||||
child_id: string
|
||||
points: number
|
||||
}
|
||||
|
||||
export interface ChildRewardTriggeredEventPayload {
|
||||
task_id: string
|
||||
child_id: string
|
||||
points: number
|
||||
}
|
||||
|
||||
export interface ChildRewardRequestEventPayload {
|
||||
child_id: string
|
||||
reward_id: string
|
||||
operation: 'GRANTED' | 'CREATED' | 'CANCELLED'
|
||||
}
|
||||
|
||||
export interface ChildTasksSetEventPayload {
|
||||
child_id: string
|
||||
task_ids: string[]
|
||||
}
|
||||
|
||||
export interface ChildRewardsSetEventPayload {
|
||||
child_id: string
|
||||
reward_ids: string[]
|
||||
}
|
||||
export interface TaskModifiedEventPayload {
|
||||
task_id: string
|
||||
operation: 'ADD' | 'DELETE' | 'EDIT'
|
||||
}
|
||||
export interface RewardModifiedEventPayload {
|
||||
reward_id: string
|
||||
operation: 'ADD' | 'DELETE' | 'EDIT'
|
||||
}
|
||||
Reference in New Issue
Block a user