feat:user rating to api-sync
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -12,6 +12,7 @@ pnpm-lock.yaml
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
.duckversions
|
||||
|
||||
# Env
|
||||
.env
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { Star } from 'lucide-svelte';
|
||||
import { onMount } from 'svelte';
|
||||
import { apiSync } from '$lib/stores/api-sync';
|
||||
|
||||
interface Props {
|
||||
findId: string;
|
||||
@@ -25,34 +27,97 @@
|
||||
let hoverRating = $state(0);
|
||||
let isSubmitting = $state(false);
|
||||
|
||||
// Prefer the centralized apiSync state and subscribe to updates so the finds panel
|
||||
// always shows up-to-date information and benefits from optimistic updates.
|
||||
onMount(() => {
|
||||
if (!findId) return;
|
||||
|
||||
// Subscribe to the global find entity state
|
||||
const entitySub = apiSync.subscribe<import('$lib/stores/api-sync').FindState>('find', findId);
|
||||
const unsubscribe = entitySub.subscribe((s) => {
|
||||
if (s && s.data) {
|
||||
const findData = s.data as import('$lib/stores/api-sync').FindState;
|
||||
// Backend may store rating as integer * 100 or as a normalized float.
|
||||
if (typeof findData.rating === 'number') {
|
||||
rating = findData.rating > 10 ? findData.rating / 100 : findData.rating;
|
||||
} else {
|
||||
rating = initialRating;
|
||||
}
|
||||
ratingCount =
|
||||
typeof findData.ratingCount === 'number' ? findData.ratingCount : initialCount;
|
||||
currentUserRating = findData.userRating ?? userRating ?? null;
|
||||
} else {
|
||||
// fallback to provided initial props
|
||||
rating = initialRating;
|
||||
ratingCount = initialCount;
|
||||
currentUserRating = userRating;
|
||||
}
|
||||
});
|
||||
|
||||
// Ensure apiSync has at least a minimal entity state so optimistic updates can apply
|
||||
const existing = apiSync.getEntityState('find', findId);
|
||||
if (!existing) {
|
||||
// Minimal shape to avoid runtime crashes; other fields may be filled later.
|
||||
apiSync.setEntityState(
|
||||
'find',
|
||||
findId,
|
||||
{
|
||||
id: findId,
|
||||
title: '',
|
||||
latitude: '',
|
||||
longitude: '',
|
||||
isPublic: true,
|
||||
createdAt: new Date(),
|
||||
userId: '',
|
||||
username: '',
|
||||
isLikedByUser: false,
|
||||
likeCount: 0,
|
||||
commentCount: 0,
|
||||
isFromFriend: false,
|
||||
rating: initialRating || 0,
|
||||
ratingCount: initialCount,
|
||||
userRating: userRating ?? null
|
||||
} as import('$lib/stores/api-sync').FindState,
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
return () => unsubscribe();
|
||||
});
|
||||
|
||||
async function handleRate(stars: number) {
|
||||
if (readonly || isSubmitting) return;
|
||||
|
||||
isSubmitting = true;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/finds/${findId}/rate`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ rating: stars })
|
||||
});
|
||||
// Use the centralized apiSync to perform optimistic update + queued server sync
|
||||
await apiSync.rateFind(findId, stars);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to rate find');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
rating = data.rating / 100;
|
||||
ratingCount = data.ratingCount;
|
||||
// apiSync will update global state; read it back to sync local component values
|
||||
const s = apiSync.getEntityState('find', findId) as
|
||||
| import('$lib/stores/api-sync').FindState
|
||||
| null;
|
||||
if (s) {
|
||||
const serverRating = s.rating ?? 0;
|
||||
rating =
|
||||
typeof serverRating === 'number'
|
||||
? serverRating > 10
|
||||
? serverRating / 100
|
||||
: serverRating
|
||||
: rating;
|
||||
ratingCount = s.ratingCount ?? ratingCount;
|
||||
currentUserRating = s?.userRating ?? stars;
|
||||
} else {
|
||||
// Fallback: use the new user rating optimistically
|
||||
currentUserRating = stars;
|
||||
}
|
||||
|
||||
if (onRatingChange) {
|
||||
onRatingChange(stars);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error rating find:', error);
|
||||
console.error('Error rating find via apiSync:', error);
|
||||
} finally {
|
||||
isSubmitting = false;
|
||||
}
|
||||
@@ -68,7 +133,7 @@
|
||||
|
||||
<div class="rating-container">
|
||||
<div class="stars">
|
||||
{#each [1, 2, 3, 4, 5] as star}
|
||||
{#each [1, 2, 3, 4, 5] as star (star)}
|
||||
<button
|
||||
class="star-button"
|
||||
class:filled={star <= getDisplayRating()}
|
||||
|
||||
@@ -59,6 +59,8 @@ export interface FindState {
|
||||
thumbnailUrl: string | null;
|
||||
orderIndex: number | null;
|
||||
}>;
|
||||
// Per-user rating (optional) — stored on the find entity so UI components can highlight user's rating
|
||||
userRating?: number | null;
|
||||
isLikedByUser: boolean;
|
||||
likeCount: number;
|
||||
commentCount: number;
|
||||
@@ -401,7 +403,16 @@ class APISync {
|
||||
|
||||
let response: Response;
|
||||
|
||||
if (entityType === 'find' && action === 'like') {
|
||||
if (entityType === 'find' && action === 'rate') {
|
||||
// Handle rating operations
|
||||
response = await fetch(`/api/finds/${entityId}/rate`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ rating: (data as { rating?: number })?.rating })
|
||||
});
|
||||
} else if (entityType === 'find' && action === 'like') {
|
||||
// Handle like operations
|
||||
const method = (data as { isLiked?: boolean })?.isLiked ? 'POST' : 'DELETE';
|
||||
response = await fetch(`/api/finds/${entityId}/like`, {
|
||||
@@ -456,7 +467,45 @@ class APISync {
|
||||
const result = await response.json();
|
||||
|
||||
// Update entity state with successful result
|
||||
if (entityType === 'find' && action === 'like') {
|
||||
if (entityType === 'find' && action === 'rate') {
|
||||
// Server should return authoritative rating and count (and optionally userRating)
|
||||
const serverRating = result.rating;
|
||||
const serverCount = result.ratingCount;
|
||||
const serverUserRating = result.userRating ?? (data as { rating?: number })?.rating ?? null;
|
||||
|
||||
const store = this.getEntityStore('find');
|
||||
|
||||
store.update(($entities) => {
|
||||
const newEntities = new Map($entities);
|
||||
const existing = newEntities.get(entityId);
|
||||
|
||||
if (existing && existing.data) {
|
||||
const findData = existing.data as FindState;
|
||||
|
||||
// Normalize rating if backend returns scaled value (e.g. stored as integer * 100)
|
||||
let normalizedRating = findData.rating;
|
||||
if (typeof serverRating === 'number') {
|
||||
normalizedRating = serverRating > 10 ? serverRating / 100 : serverRating;
|
||||
}
|
||||
|
||||
newEntities.set(entityId, {
|
||||
...existing,
|
||||
data: {
|
||||
...findData,
|
||||
rating: normalizedRating,
|
||||
ratingCount: typeof serverCount === 'number' ? serverCount : findData.ratingCount,
|
||||
// persist user's rating on the entity for UI consumers
|
||||
userRating: serverUserRating
|
||||
},
|
||||
isLoading: false,
|
||||
error: null,
|
||||
lastUpdated: new Date()
|
||||
});
|
||||
}
|
||||
|
||||
return newEntities;
|
||||
});
|
||||
} else if (entityType === 'find' && action === 'like') {
|
||||
this.updateFindLikeState(entityId, result.isLiked, result.likeCount);
|
||||
} else if (entityType === 'find' && op === 'update') {
|
||||
// Reload the find data to get the updated state
|
||||
@@ -564,6 +613,60 @@ class APISync {
|
||||
await this.queueOperation('find', findId, 'update', 'like', { isLiked: newIsLiked });
|
||||
}
|
||||
|
||||
/**
|
||||
* Rate a find with optimistic update and queued server sync
|
||||
*/
|
||||
async rateFind(findId: string, stars: number): Promise<void> {
|
||||
const store = this.getEntityStore('find');
|
||||
const currentState = this.getEntityState<FindState>('find', findId);
|
||||
|
||||
if (!currentState) {
|
||||
console.warn(`Cannot rate find ${findId}: find state not found`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Read previous values
|
||||
const prevRating = currentState.rating ?? 0;
|
||||
const prevCount = currentState.ratingCount ?? 0;
|
||||
const prevUserRating = currentState.userRating ?? null;
|
||||
|
||||
let newCount = prevCount;
|
||||
let newRating = prevRating;
|
||||
|
||||
if (prevUserRating == null) {
|
||||
// New rater
|
||||
newCount = prevCount + 1;
|
||||
newRating = (prevRating * prevCount + stars) / newCount;
|
||||
} else {
|
||||
// Updating existing user's rating
|
||||
newRating = (prevRating * prevCount - prevUserRating + stars) / Math.max(1, prevCount);
|
||||
}
|
||||
|
||||
// Apply optimistic update (store userRating on find object so UI can read it)
|
||||
store.update(($entities) => {
|
||||
const newEntities = new Map($entities);
|
||||
const existing = newEntities.get(findId);
|
||||
if (existing && existing.data) {
|
||||
const findData = existing.data as FindState;
|
||||
newEntities.set(findId, {
|
||||
...existing,
|
||||
data: {
|
||||
...findData,
|
||||
rating: newRating,
|
||||
ratingCount: newCount,
|
||||
// attach user's rating for UI
|
||||
userRating: stars
|
||||
},
|
||||
isLoading: true
|
||||
});
|
||||
}
|
||||
return newEntities;
|
||||
});
|
||||
|
||||
// Queue the operation using action 'rate' so executeOperation will POST to /rate
|
||||
await this.queueOperation('find', findId, 'update', 'rate', { rating: stars });
|
||||
}
|
||||
|
||||
/**
|
||||
* Add comment to find comments state
|
||||
*/
|
||||
@@ -801,7 +904,7 @@ class APISync {
|
||||
...(data.isPublic !== undefined && { isPublic: data.isPublic }),
|
||||
...(data.media !== undefined && {
|
||||
media: data.media.map((m, index) => ({
|
||||
id: (m as any).id || `temp-${index}`,
|
||||
id: (m as { id?: string }).id || `temp-${index}`,
|
||||
findId: findId,
|
||||
type: m.type,
|
||||
url: m.url,
|
||||
|
||||
Reference in New Issue
Block a user