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

@@ -0,0 +1,15 @@
CREATE TABLE "find_rating" (
"id" text PRIMARY KEY NOT NULL,
"find_id" text NOT NULL,
"user_id" text NOT NULL,
"rating" integer NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "find" ADD COLUMN "rating" integer;--> statement-breakpoint
ALTER TABLE "find" ADD COLUMN "rating_count" integer DEFAULT 0;--> statement-breakpoint
ALTER TABLE "location" ADD COLUMN "average_rating" integer;--> statement-breakpoint
ALTER TABLE "location" ADD COLUMN "rating_count" integer DEFAULT 0;--> statement-breakpoint
ALTER TABLE "find_rating" ADD CONSTRAINT "find_rating_find_id_find_id_fk" FOREIGN KEY ("find_id") REFERENCES "public"."find"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "find_rating" ADD CONSTRAINT "find_rating_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;

View File

@@ -0,0 +1,933 @@
{
"id": "30fb4a72-dd57-46c3-99e4-9e01f36acce0",
"prevId": "5654d58b-23f8-48cb-9933-5ac32141b75e",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.find": {
"name": "find",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"location_id": {
"name": "location_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false
},
"category": {
"name": "category",
"type": "text",
"primaryKey": false,
"notNull": false
},
"rating": {
"name": "rating",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"rating_count": {
"name": "rating_count",
"type": "integer",
"primaryKey": false,
"notNull": false,
"default": 0
},
"is_public": {
"name": "is_public",
"type": "integer",
"primaryKey": false,
"notNull": false,
"default": 1
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"find_location_id_location_id_fk": {
"name": "find_location_id_location_id_fk",
"tableFrom": "find",
"tableTo": "location",
"columnsFrom": [
"location_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"find_user_id_user_id_fk": {
"name": "find_user_id_user_id_fk",
"tableFrom": "find",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.find_comment": {
"name": "find_comment",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"find_id": {
"name": "find_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"content": {
"name": "content",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"find_comment_find_id_find_id_fk": {
"name": "find_comment_find_id_find_id_fk",
"tableFrom": "find_comment",
"tableTo": "find",
"columnsFrom": [
"find_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"find_comment_user_id_user_id_fk": {
"name": "find_comment_user_id_user_id_fk",
"tableFrom": "find_comment",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.find_like": {
"name": "find_like",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"find_id": {
"name": "find_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"find_like_find_id_find_id_fk": {
"name": "find_like_find_id_find_id_fk",
"tableFrom": "find_like",
"tableTo": "find",
"columnsFrom": [
"find_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"find_like_user_id_user_id_fk": {
"name": "find_like_user_id_user_id_fk",
"tableFrom": "find_like",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.find_media": {
"name": "find_media",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"find_id": {
"name": "find_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true
},
"url": {
"name": "url",
"type": "text",
"primaryKey": false,
"notNull": true
},
"thumbnail_url": {
"name": "thumbnail_url",
"type": "text",
"primaryKey": false,
"notNull": false
},
"fallback_url": {
"name": "fallback_url",
"type": "text",
"primaryKey": false,
"notNull": false
},
"fallback_thumbnail_url": {
"name": "fallback_thumbnail_url",
"type": "text",
"primaryKey": false,
"notNull": false
},
"order_index": {
"name": "order_index",
"type": "integer",
"primaryKey": false,
"notNull": false,
"default": 0
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"find_media_find_id_find_id_fk": {
"name": "find_media_find_id_find_id_fk",
"tableFrom": "find_media",
"tableTo": "find",
"columnsFrom": [
"find_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.find_rating": {
"name": "find_rating",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"find_id": {
"name": "find_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"rating": {
"name": "rating",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"find_rating_find_id_find_id_fk": {
"name": "find_rating_find_id_find_id_fk",
"tableFrom": "find_rating",
"tableTo": "find",
"columnsFrom": [
"find_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"find_rating_user_id_user_id_fk": {
"name": "find_rating_user_id_user_id_fk",
"tableFrom": "find_rating",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.friendship": {
"name": "friendship",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"friend_id": {
"name": "friend_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"friendship_user_id_user_id_fk": {
"name": "friendship_user_id_user_id_fk",
"tableFrom": "friendship",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"friendship_friend_id_user_id_fk": {
"name": "friendship_friend_id_user_id_fk",
"tableFrom": "friendship",
"tableTo": "user",
"columnsFrom": [
"friend_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.location": {
"name": "location",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"latitude": {
"name": "latitude",
"type": "text",
"primaryKey": false,
"notNull": true
},
"longitude": {
"name": "longitude",
"type": "text",
"primaryKey": false,
"notNull": true
},
"location_name": {
"name": "location_name",
"type": "text",
"primaryKey": false,
"notNull": false
},
"average_rating": {
"name": "average_rating",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"rating_count": {
"name": "rating_count",
"type": "integer",
"primaryKey": false,
"notNull": false,
"default": 0
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"location_user_id_user_id_fk": {
"name": "location_user_id_user_id_fk",
"tableFrom": "location",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.notification": {
"name": "notification",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true
},
"message": {
"name": "message",
"type": "text",
"primaryKey": false,
"notNull": true
},
"data": {
"name": "data",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"is_read": {
"name": "is_read",
"type": "boolean",
"primaryKey": false,
"notNull": false,
"default": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"notification_user_id_user_id_fk": {
"name": "notification_user_id_user_id_fk",
"tableFrom": "notification",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.notification_preferences": {
"name": "notification_preferences",
"schema": "",
"columns": {
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"friend_requests": {
"name": "friend_requests",
"type": "boolean",
"primaryKey": false,
"notNull": false,
"default": true
},
"friend_accepted": {
"name": "friend_accepted",
"type": "boolean",
"primaryKey": false,
"notNull": false,
"default": true
},
"find_liked": {
"name": "find_liked",
"type": "boolean",
"primaryKey": false,
"notNull": false,
"default": true
},
"find_commented": {
"name": "find_commented",
"type": "boolean",
"primaryKey": false,
"notNull": false,
"default": true
},
"push_enabled": {
"name": "push_enabled",
"type": "boolean",
"primaryKey": false,
"notNull": false,
"default": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"notification_preferences_user_id_user_id_fk": {
"name": "notification_preferences_user_id_user_id_fk",
"tableFrom": "notification_preferences",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.notification_subscription": {
"name": "notification_subscription",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"endpoint": {
"name": "endpoint",
"type": "text",
"primaryKey": false,
"notNull": true
},
"p256dh_key": {
"name": "p256dh_key",
"type": "text",
"primaryKey": false,
"notNull": true
},
"auth_key": {
"name": "auth_key",
"type": "text",
"primaryKey": false,
"notNull": true
},
"user_agent": {
"name": "user_agent",
"type": "text",
"primaryKey": false,
"notNull": false
},
"is_active": {
"name": "is_active",
"type": "boolean",
"primaryKey": false,
"notNull": false,
"default": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"notification_subscription_user_id_user_id_fk": {
"name": "notification_subscription_user_id_user_id_fk",
"tableFrom": "notification_subscription",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.session": {
"name": "session",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"session_user_id_user_id_fk": {
"name": "session_user_id_user_id_fk",
"tableFrom": "session",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.user": {
"name": "user",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"age": {
"name": "age",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"username": {
"name": "username",
"type": "text",
"primaryKey": false,
"notNull": true
},
"password_hash": {
"name": "password_hash",
"type": "text",
"primaryKey": false,
"notNull": false
},
"google_id": {
"name": "google_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"profile_picture_url": {
"name": "profile_picture_url",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"user_username_unique": {
"name": "user_username_unique",
"nullsNotDistinct": false,
"columns": [
"username"
]
},
"user_google_id_unique": {
"name": "user_google_id_unique",
"nullsNotDistinct": false,
"columns": [
"google_id"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View File

@@ -64,6 +64,13 @@
"when": 1765885558230, "when": 1765885558230,
"tag": "0008_common_supreme_intelligence", "tag": "0008_common_supreme_intelligence",
"breakpoints": true "breakpoints": true
},
{
"idx": 9,
"version": "7",
"when": 1765894394394,
"tag": "0009_lazy_monster_badoon",
"breakpoints": true
} }
] ]
} }

View File

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

View File

@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { formatDistance } from '$lib/utils/distance'; import { formatDistance } from '$lib/utils/distance';
import { Star } from 'lucide-svelte';
interface Location { interface Location {
id: string; id: string;
@@ -10,6 +11,8 @@
username: string; username: string;
profilePictureUrl?: string | null; profilePictureUrl?: string | null;
findCount: number; findCount: number;
averageRating?: number | null;
ratingCount?: number;
distance?: number; distance?: number;
} }
@@ -84,6 +87,13 @@
</svg> </svg>
<span>{location.findCount} {location.findCount === 1 ? 'find' : 'finds'}</span> <span>{location.findCount} {location.findCount === 1 ? 'find' : 'finds'}</span>
</div> </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>
</div> </div>
@@ -188,6 +198,14 @@
flex-shrink: 0; flex-shrink: 0;
} }
.meta-item.rating {
color: #fbbf24;
}
:global(.star-icon) {
stroke-width: 2;
}
.explore-button { .explore-button {
display: flex; display: flex;
align-items: center; 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', { export const user = pgTable('user', {
id: text('id').primaryKey(), id: text('id').primaryKey(),
@@ -30,6 +30,8 @@ export const location = pgTable('location', {
latitude: text('latitude').notNull(), // Using text for precision latitude: text('latitude').notNull(), // Using text for precision
longitude: text('longitude').notNull(), // Using text for precision longitude: text('longitude').notNull(), // Using text for precision
locationName: text('location_name'), // e.g., "Café Belga, Brussels" 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() createdAt: timestamp('created_at', { withTimezone: true, mode: 'date' }).defaultNow().notNull()
}); });
@@ -45,6 +47,8 @@ export const find = pgTable('find', {
title: text('title').notNull(), title: text('title').notNull(),
description: text('description'), description: text('description'),
category: text('category'), // e.g., "cafe", "restaurant", "park", "landmark" 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) isPublic: integer('is_public').default(1), // Using integer for boolean (1 = true, 0 = false)
createdAt: timestamp('created_at', { withTimezone: true, mode: 'date' }).defaultNow().notNull(), createdAt: timestamp('created_at', { withTimezone: true, mode: 'date' }).defaultNow().notNull(),
updatedAt: timestamp('updated_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() 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', { export const friendship = pgTable('friendship', {
id: text('id').primaryKey(), id: text('id').primaryKey(),
userId: text('user_id') userId: text('user_id')
@@ -146,6 +163,7 @@ export type Location = typeof location.$inferSelect;
export type Find = typeof find.$inferSelect; export type Find = typeof find.$inferSelect;
export type FindMedia = typeof findMedia.$inferSelect; export type FindMedia = typeof findMedia.$inferSelect;
export type FindLike = typeof findLike.$inferSelect; export type FindLike = typeof findLike.$inferSelect;
export type FindRating = typeof findRating.$inferSelect;
export type FindComment = typeof findComment.$inferSelect; export type FindComment = typeof findComment.$inferSelect;
export type Friendship = typeof friendship.$inferSelect; export type Friendship = typeof friendship.$inferSelect;
export type Notification = typeof notification.$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 FindInsert = typeof find.$inferInsert;
export type FindMediaInsert = typeof findMedia.$inferInsert; export type FindMediaInsert = typeof findMedia.$inferInsert;
export type FindLikeInsert = typeof findLike.$inferInsert; export type FindLikeInsert = typeof findLike.$inferInsert;
export type FindRatingInsert = typeof findRating.$inferInsert;
export type FindCommentInsert = typeof findComment.$inferInsert; export type FindCommentInsert = typeof findComment.$inferInsert;
export type FriendshipInsert = typeof friendship.$inferInsert; export type FriendshipInsert = typeof friendship.$inferInsert;
export type NotificationInsert = typeof notification.$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');
}
}