feat:rating

This commit is contained in:
2025-12-16 15:22:09 +01:00
parent abed2792dc
commit 851a9dfa2d
9 changed files with 1338 additions and 1 deletions

View File

@@ -12,6 +12,7 @@
import VideoPlayer from '../media/VideoPlayer.svelte';
import ProfilePicture from '../profile/ProfilePicture.svelte';
import CommentsList from './CommentsList.svelte';
import Rating from './Rating.svelte';
import { Ellipsis, MessageCircle, Share, Edit, Trash2 } from '@lucide/svelte';
import { apiSync } from '$lib/stores/api-sync';
@@ -39,6 +40,9 @@
likeCount?: number;
isLiked?: boolean;
commentCount?: number;
rating?: number | null;
ratingCount?: number;
userRating?: number | null;
currentUserId?: string;
onExplore?: (id: string) => void;
onDeleted?: () => void;
@@ -61,6 +65,9 @@
likeCount = 0,
isLiked = false,
commentCount = 0,
rating,
ratingCount = 0,
userRating,
currentUserId,
onExplore,
onDeleted,
@@ -237,6 +244,18 @@
</Button>
</div>
<!-- Rating Section -->
{#if currentUserId}
<div class="rating-section">
<Rating
findId={id}
initialRating={rating ? rating / 100 : 0}
initialCount={ratingCount}
{userRating}
/>
</div>
{/if}
<!-- Comments Section -->
{#if showComments}
<div class="comments-section">
@@ -427,6 +446,13 @@
font-size: 0.875rem;
}
/* Rating Section */
.rating-section {
padding: 0.75rem 1rem;
border-top: 1px solid hsl(var(--border));
background: hsl(var(--muted) / 0.2);
}
/* Comments Section */
.comments-section {
padding: 0 1rem 1rem 1rem;

View File

@@ -0,0 +1,156 @@
<script lang="ts">
import { Star } from 'lucide-svelte';
interface Props {
findId: string;
initialRating?: number;
initialCount?: number;
userRating?: number | null;
onRatingChange?: (rating: number) => void;
readonly?: boolean;
}
let {
findId,
initialRating = 0,
initialCount = 0,
userRating = null,
onRatingChange,
readonly = false
}: Props = $props();
let rating = $state(initialRating);
let ratingCount = $state(initialCount);
let currentUserRating = $state(userRating);
let hoverRating = $state(0);
let isSubmitting = $state(false);
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 })
});
if (!response.ok) {
throw new Error('Failed to rate find');
}
const data = await response.json();
rating = data.rating / 100;
ratingCount = data.ratingCount;
currentUserRating = stars;
if (onRatingChange) {
onRatingChange(stars);
}
} catch (error) {
console.error('Error rating find:', error);
} finally {
isSubmitting = false;
}
}
function getDisplayRating() {
if (readonly) {
return rating;
}
return hoverRating || rating;
}
</script>
<div class="rating-container">
<div class="stars">
{#each [1, 2, 3, 4, 5] as star}
<button
class="star-button"
class:filled={star <= getDisplayRating()}
class:user-rated={!readonly && currentUserRating && star <= currentUserRating}
class:readonly
disabled={readonly || isSubmitting}
onclick={() => handleRate(star)}
onmouseenter={() => !readonly && (hoverRating = star)}
onmouseleave={() => !readonly && (hoverRating = 0)}
aria-label={`Rate ${star} star${star > 1 ? 's' : ''}`}
>
<Star
class="star-icon"
fill={star <= getDisplayRating() ? 'currentColor' : 'none'}
size={readonly ? 16 : 24}
/>
</button>
{/each}
</div>
{#if ratingCount > 0}
<span class="rating-info">
{(rating || 0).toFixed(1)} ({ratingCount}
{ratingCount === 1 ? 'rating' : 'ratings'})
</span>
{:else if !readonly}
<span class="rating-info">Be the first to rate</span>
{/if}
</div>
<style>
.rating-container {
display: flex;
align-items: center;
gap: 0.5rem;
}
.stars {
display: flex;
gap: 0.25rem;
}
.star-button {
background: none;
border: none;
padding: 0.25rem;
cursor: pointer;
color: #94a3b8;
transition: all 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
}
.star-button:not(.readonly):hover {
transform: scale(1.1);
color: #fbbf24;
}
.star-button.filled {
color: #fbbf24;
}
.star-button.user-rated {
color: #f59e0b;
}
.star-button.readonly {
cursor: default;
}
.star-button:disabled {
cursor: not-allowed;
opacity: 0.5;
}
.rating-info {
font-size: 0.875rem;
color: #64748b;
white-space: nowrap;
}
:global(.star-icon) {
stroke-width: 2;
}
</style>

View File

@@ -9,3 +9,4 @@ export { default as FindPreview } from './FindPreview.svelte';
export { default as FindsFilter } from './FindsFilter.svelte';
export { default as FindsList } from './FindsList.svelte';
export { default as LikeButton } from './LikeButton.svelte';
export { default as Rating } from './Rating.svelte';

View File

@@ -1,5 +1,6 @@
<script lang="ts">
import { formatDistance } from '$lib/utils/distance';
import { Star } from 'lucide-svelte';
interface Location {
id: string;
@@ -10,6 +11,8 @@
username: string;
profilePictureUrl?: string | null;
findCount: number;
averageRating?: number | null;
ratingCount?: number;
distance?: number;
}
@@ -84,6 +87,13 @@
</svg>
<span>{location.findCount} {location.findCount === 1 ? 'find' : 'finds'}</span>
</div>
{#if location.averageRating && (location.ratingCount || 0) > 0}
<div class="meta-item rating">
<Star size={16} fill="currentColor" class="star-icon" />
<span>{(location.averageRating / 100).toFixed(1)} ({location.ratingCount})</span>
</div>
{/if}
</div>
</div>
@@ -188,6 +198,14 @@
flex-shrink: 0;
}
.meta-item.rating {
color: #fbbf24;
}
:global(.star-icon) {
stroke-width: 2;
}
.explore-button {
display: flex;
align-items: center;

View File

@@ -1,4 +1,4 @@
import { pgTable, integer, text, timestamp, boolean, jsonb } from 'drizzle-orm/pg-core';
import { pgTable, integer, text, timestamp, boolean, jsonb, real } from 'drizzle-orm/pg-core';
export const user = pgTable('user', {
id: text('id').primaryKey(),
@@ -30,6 +30,8 @@ export const location = pgTable('location', {
latitude: text('latitude').notNull(), // Using text for precision
longitude: text('longitude').notNull(), // Using text for precision
locationName: text('location_name'), // e.g., "Café Belga, Brussels"
averageRating: integer('average_rating'), // Average rating (1-5 scale, stored as integer * 100 for precision)
ratingCount: integer('rating_count').default(0), // Total number of finds with ratings at this location
createdAt: timestamp('created_at', { withTimezone: true, mode: 'date' }).defaultNow().notNull()
});
@@ -45,6 +47,8 @@ export const find = pgTable('find', {
title: text('title').notNull(),
description: text('description'),
category: text('category'), // e.g., "cafe", "restaurant", "park", "landmark"
rating: integer('rating'), // Average rating for this find (1-5 stars, stored as integer * 100)
ratingCount: integer('rating_count').default(0), // Number of ratings for this find
isPublic: integer('is_public').default(1), // Using integer for boolean (1 = true, 0 = false)
createdAt: timestamp('created_at', { withTimezone: true, mode: 'date' }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true, mode: 'date' }).defaultNow().notNull()
@@ -75,6 +79,19 @@ export const findLike = pgTable('find_like', {
createdAt: timestamp('created_at', { withTimezone: true, mode: 'date' }).defaultNow().notNull()
});
export const findRating = pgTable('find_rating', {
id: text('id').primaryKey(),
findId: text('find_id')
.notNull()
.references(() => find.id, { onDelete: 'cascade' }),
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
rating: integer('rating').notNull(), // 1-5 stars (stored as 100-500 for precision)
createdAt: timestamp('created_at', { withTimezone: true, mode: 'date' }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true, mode: 'date' }).defaultNow().notNull()
});
export const friendship = pgTable('friendship', {
id: text('id').primaryKey(),
userId: text('user_id')
@@ -146,6 +163,7 @@ export type Location = typeof location.$inferSelect;
export type Find = typeof find.$inferSelect;
export type FindMedia = typeof findMedia.$inferSelect;
export type FindLike = typeof findLike.$inferSelect;
export type FindRating = typeof findRating.$inferSelect;
export type FindComment = typeof findComment.$inferSelect;
export type Friendship = typeof friendship.$inferSelect;
export type Notification = typeof notification.$inferSelect;
@@ -156,6 +174,7 @@ export type LocationInsert = typeof location.$inferInsert;
export type FindInsert = typeof find.$inferInsert;
export type FindMediaInsert = typeof findMedia.$inferInsert;
export type FindLikeInsert = typeof findLike.$inferInsert;
export type FindRatingInsert = typeof findRating.$inferInsert;
export type FindCommentInsert = typeof findComment.$inferInsert;
export type FriendshipInsert = typeof friendship.$inferInsert;
export type NotificationInsert = typeof notification.$inferInsert;

View File

@@ -0,0 +1,162 @@
import { json, error } from '@sveltejs/kit';
import { db } from '$lib/server/db';
import { findRating, find, location } from '$lib/server/db/schema';
import { eq, and } from 'drizzle-orm';
import { encodeBase64url } from '@oslojs/encoding';
function generateRatingId(): string {
const bytes = crypto.getRandomValues(new Uint8Array(15));
return encodeBase64url(bytes);
}
// POST /api/finds/[findId]/rate - Rate a find
export async function POST({
params,
request,
locals
}: {
params: { findId: string };
request: Request;
locals: { user: { id: string } };
}) {
if (!locals.user) {
throw error(401, 'Unauthorized');
}
const findId = params.findId;
if (!findId) {
throw error(400, 'Find ID is required');
}
try {
const body = await request.json();
const ratingValue = body.rating;
// Validate rating (1-5 stars)
if (!ratingValue || ratingValue < 1 || ratingValue > 5) {
throw error(400, 'Rating must be between 1 and 5');
}
// Convert to integer representation (100-500)
const ratingInt = Math.round(ratingValue * 100);
// Check if find exists
const findRecord = await db.select().from(find).where(eq(find.id, findId)).limit(1);
if (findRecord.length === 0) {
throw error(404, 'Find not found');
}
// Check if user has already rated this find
const existingRating = await db
.select()
.from(findRating)
.where(and(eq(findRating.findId, findId), eq(findRating.userId, locals.user.id)))
.limit(1);
if (existingRating.length > 0) {
// Update existing rating
await db
.update(findRating)
.set({
rating: ratingInt,
updatedAt: new Date()
})
.where(eq(findRating.id, existingRating[0].id));
} else {
// Create new rating
const ratingId = generateRatingId();
await db.insert(findRating).values({
id: ratingId,
findId,
userId: locals.user.id,
rating: ratingInt
});
}
// Recalculate average rating for the find
const ratings = await db.select().from(findRating).where(eq(findRating.findId, findId));
const avgRating = ratings.reduce((sum, r) => sum + (r.rating || 0), 0) / ratings.length;
const roundedAvgRating = Math.round(avgRating);
await db
.update(find)
.set({
rating: roundedAvgRating,
ratingCount: ratings.length,
updatedAt: new Date()
})
.where(eq(find.id, findId));
// Recalculate location average rating
const locationId = findRecord[0].locationId;
const locationFinds = await db.select().from(find).where(eq(find.locationId, locationId));
// Only include finds that have ratings
const ratedFinds = locationFinds.filter((f) => f.rating !== null && (f.ratingCount || 0) > 0);
if (ratedFinds.length > 0) {
const locationAvgRating =
ratedFinds.reduce((sum, f) => sum + (f.rating || 0), 0) / ratedFinds.length;
const roundedLocationAvgRating = Math.round(locationAvgRating);
await db
.update(location)
.set({
averageRating: roundedLocationAvgRating,
ratingCount: ratedFinds.length
})
.where(eq(location.id, locationId));
}
return json({
success: true,
rating: roundedAvgRating,
ratingCount: ratings.length
});
} catch (err) {
console.error('Error rating find:', err);
throw error(500, 'Failed to rate find');
}
}
// GET /api/finds/[findId]/rate - Get user's rating for a find
export async function GET({
params,
locals
}: {
params: { findId: string };
locals: { user: { id: string } };
}) {
if (!locals.user) {
throw error(401, 'Unauthorized');
}
const findId = params.findId;
if (!findId) {
throw error(400, 'Find ID is required');
}
try {
const userRating = await db
.select()
.from(findRating)
.where(and(eq(findRating.findId, findId), eq(findRating.userId, locals.user.id)))
.limit(1);
if (userRating.length === 0) {
return json({ rating: null });
}
// Convert from integer representation to float (100-500 -> 1-5)
const ratingValue = (userRating[0].rating || 0) / 100;
return json({ rating: ratingValue });
} catch (err) {
console.error('Error getting user rating:', err);
throw error(500, 'Failed to get rating');
}
}