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
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
.duckversions
|
||||||
|
|
||||||
# Env
|
# Env
|
||||||
.env
|
.env
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Star } from 'lucide-svelte';
|
import { Star } from 'lucide-svelte';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { apiSync } from '$lib/stores/api-sync';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
findId: string;
|
findId: string;
|
||||||
@@ -25,34 +27,97 @@
|
|||||||
let hoverRating = $state(0);
|
let hoverRating = $state(0);
|
||||||
let isSubmitting = $state(false);
|
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) {
|
async function handleRate(stars: number) {
|
||||||
if (readonly || isSubmitting) return;
|
if (readonly || isSubmitting) return;
|
||||||
|
|
||||||
isSubmitting = true;
|
isSubmitting = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/finds/${findId}/rate`, {
|
// Use the centralized apiSync to perform optimistic update + queued server sync
|
||||||
method: 'POST',
|
await apiSync.rateFind(findId, stars);
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ rating: stars })
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
// apiSync will update global state; read it back to sync local component values
|
||||||
throw new Error('Failed to rate find');
|
const s = apiSync.getEntityState('find', findId) as
|
||||||
}
|
| import('$lib/stores/api-sync').FindState
|
||||||
|
| null;
|
||||||
const data = await response.json();
|
if (s) {
|
||||||
rating = data.rating / 100;
|
const serverRating = s.rating ?? 0;
|
||||||
ratingCount = data.ratingCount;
|
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;
|
currentUserRating = stars;
|
||||||
|
}
|
||||||
|
|
||||||
if (onRatingChange) {
|
if (onRatingChange) {
|
||||||
onRatingChange(stars);
|
onRatingChange(stars);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error rating find:', error);
|
console.error('Error rating find via apiSync:', error);
|
||||||
} finally {
|
} finally {
|
||||||
isSubmitting = false;
|
isSubmitting = false;
|
||||||
}
|
}
|
||||||
@@ -68,7 +133,7 @@
|
|||||||
|
|
||||||
<div class="rating-container">
|
<div class="rating-container">
|
||||||
<div class="stars">
|
<div class="stars">
|
||||||
{#each [1, 2, 3, 4, 5] as star}
|
{#each [1, 2, 3, 4, 5] as star (star)}
|
||||||
<button
|
<button
|
||||||
class="star-button"
|
class="star-button"
|
||||||
class:filled={star <= getDisplayRating()}
|
class:filled={star <= getDisplayRating()}
|
||||||
|
|||||||
@@ -59,6 +59,8 @@ export interface FindState {
|
|||||||
thumbnailUrl: string | null;
|
thumbnailUrl: string | null;
|
||||||
orderIndex: number | 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;
|
isLikedByUser: boolean;
|
||||||
likeCount: number;
|
likeCount: number;
|
||||||
commentCount: number;
|
commentCount: number;
|
||||||
@@ -401,7 +403,16 @@ class APISync {
|
|||||||
|
|
||||||
let response: Response;
|
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
|
// Handle like operations
|
||||||
const method = (data as { isLiked?: boolean })?.isLiked ? 'POST' : 'DELETE';
|
const method = (data as { isLiked?: boolean })?.isLiked ? 'POST' : 'DELETE';
|
||||||
response = await fetch(`/api/finds/${entityId}/like`, {
|
response = await fetch(`/api/finds/${entityId}/like`, {
|
||||||
@@ -456,7 +467,45 @@ class APISync {
|
|||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
|
|
||||||
// Update entity state with successful result
|
// 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);
|
this.updateFindLikeState(entityId, result.isLiked, result.likeCount);
|
||||||
} else if (entityType === 'find' && op === 'update') {
|
} else if (entityType === 'find' && op === 'update') {
|
||||||
// Reload the find data to get the updated state
|
// Reload the find data to get the updated state
|
||||||
@@ -564,6 +613,60 @@ class APISync {
|
|||||||
await this.queueOperation('find', findId, 'update', 'like', { isLiked: newIsLiked });
|
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
|
* Add comment to find comments state
|
||||||
*/
|
*/
|
||||||
@@ -801,7 +904,7 @@ class APISync {
|
|||||||
...(data.isPublic !== undefined && { isPublic: data.isPublic }),
|
...(data.isPublic !== undefined && { isPublic: data.isPublic }),
|
||||||
...(data.media !== undefined && {
|
...(data.media !== undefined && {
|
||||||
media: data.media.map((m, index) => ({
|
media: data.media.map((m, index) => ({
|
||||||
id: (m as any).id || `temp-${index}`,
|
id: (m as { id?: string }).id || `temp-${index}`,
|
||||||
findId: findId,
|
findId: findId,
|
||||||
type: m.type,
|
type: m.type,
|
||||||
url: m.url,
|
url: m.url,
|
||||||
|
|||||||
Reference in New Issue
Block a user