feat:user rating to api-sync

This commit is contained in:
2026-01-18 20:12:20 +01:00
parent e9ef0fbf22
commit cb428bceb2
4 changed files with 1789 additions and 19 deletions

1
.gitignore vendored
View File

@@ -12,6 +12,7 @@ pnpm-lock.yaml
# OS
.DS_Store
Thumbs.db
.duckversions
# Env
.env

1601
bun.lock Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -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()}

View File

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