feat: comments

fix: use select instead of dropdown for filter on /market
This commit is contained in:
Face 2025-05-24 19:28:38 +03:00
parent 800b5d1a09
commit bd05b269fe
22 changed files with 2715 additions and 97 deletions

View file

@ -0,0 +1,125 @@
import { auth } from '$lib/auth';
import { error, json } from '@sveltejs/kit';
import { db } from '$lib/server/db';
import { comment, coin, user, commentLike } from '$lib/server/db/schema';
import { eq, and, desc, sql } from 'drizzle-orm';
export async function GET({ params, request }) {
const session = await auth.api.getSession({
headers: request.headers
});
const { coinSymbol } = params;
const normalizedSymbol = coinSymbol.toUpperCase();
try {
const [coinData] = await db
.select({ id: coin.id })
.from(coin)
.where(eq(coin.symbol, normalizedSymbol))
.limit(1);
if (!coinData) {
return json({ message: 'Coin not found' }, { status: 404 });
}
const commentsQuery = db
.select({
id: comment.id,
content: comment.content,
likesCount: comment.likesCount,
createdAt: comment.createdAt,
updatedAt: comment.updatedAt,
userId: user.id,
userName: user.name,
userUsername: user.username,
userImage: user.image,
userBio: user.bio,
userCreatedAt: user.createdAt,
isLikedByUser: session?.user ?
sql<boolean>`EXISTS(SELECT 1 FROM ${commentLike} WHERE ${commentLike.userId} = ${session.user.id} AND ${commentLike.commentId} = ${comment.id})` :
sql<boolean>`FALSE`
})
.from(comment)
.innerJoin(user, eq(comment.userId, user.id))
.where(and(eq(comment.coinId, coinData.id), eq(comment.isDeleted, false)))
.orderBy(desc(comment.createdAt));
const comments = await commentsQuery;
return json({ comments });
} catch (err) {
console.error('Failed to fetch comments:', err);
return json({ message: 'Internal server error' }, { status: 500 });
}
}
export async function POST({ request, params }) {
const session = await auth.api.getSession({
headers: request.headers
});
if (!session?.user) {
throw error(401, 'Not authenticated');
}
const { coinSymbol } = params;
const { content } = await request.json();
if (!content || content.trim().length === 0) {
throw error(400, 'Comment content is required');
}
if (content.length > 500) {
throw error(400, 'Comment must be 500 characters or less');
}
const normalizedSymbol = coinSymbol.toUpperCase();
const userId = Number(session.user.id);
try {
const [coinData] = await db
.select({ id: coin.id })
.from(coin)
.where(eq(coin.symbol, normalizedSymbol))
.limit(1);
if (!coinData) {
throw error(404, 'Coin not found');
}
const [newComment] = await db
.insert(comment)
.values({
userId,
coinId: coinData.id,
content: content.trim()
})
.returning();
const [commentWithUser] = await db
.select({
id: comment.id,
content: comment.content,
likesCount: comment.likesCount,
createdAt: comment.createdAt,
updatedAt: comment.updatedAt,
userId: comment.userId,
userName: user.name,
userUsername: user.username,
userImage: user.image,
userBio: user.bio,
userCreatedAt: user.createdAt,
isLikedByUser: sql<boolean>`FALSE`
})
.from(comment)
.innerJoin(user, eq(comment.userId, user.id))
.where(eq(comment.id, newComment.id))
.limit(1);
return json({ comment: commentWithUser });
} catch (e) {
console.error('Error creating comment:', e);
throw error(500, 'Failed to create comment');
}
}

View file

@ -0,0 +1,120 @@
import { error, json } from '@sveltejs/kit';
import { db } from '$lib/server/db';
import { comment, commentLike, coin } from '$lib/server/db/schema';
import { eq, and, sql } from 'drizzle-orm';
import type { RequestHandler } from './$types';
import { auth } from '$lib/auth';
export const POST: RequestHandler = async ({ request, params }) => {
const session = await auth.api.getSession({
headers: request.headers
});
if (!session?.user) {
return json({ message: 'Not authenticated' }, { status: 401 });
}
const commentId = parseInt(params.id);
const { coinSymbol } = params;
const userId = Number(session.user.id);
if (isNaN(commentId)) {
return json({ message: 'Invalid comment ID' }, { status: 400 });
}
try {
// Verify the comment exists and belongs to the specified coin
const [commentData] = await db
.select()
.from(comment)
.innerJoin(coin, eq(comment.coinId, coin.id))
.where(and(eq(comment.id, commentId), eq(coin.symbol, coinSymbol)));
if (!commentData) {
return json({ message: 'Comment not found' }, { status: 404 });
}
// Check if user already liked this comment
const [existingLike] = await db
.select()
.from(commentLike)
.where(and(eq(commentLike.userId, userId), eq(commentLike.commentId, commentId)));
if (existingLike) {
return json({ message: 'Comment already liked' }, { status: 400 });
}
// Add like and increment count
await db.transaction(async (tx) => {
await tx.insert(commentLike).values({ userId, commentId });
await tx
.update(comment)
.set({ likesCount: sql`${comment.likesCount} + 1` })
.where(eq(comment.id, commentId));
});
return json({ success: true });
} catch (error) {
console.error('Failed to like comment:', error);
return json({ message: 'Internal server error' }, { status: 500 });
}
};
export const DELETE: RequestHandler = async ({ request, params }) => {
const session = await auth.api.getSession({
headers: request.headers
});
if (!session?.user) {
throw error(401, 'Not authenticated');
}
const commentId = parseInt(params.id);
const { coinSymbol } = params;
const userId = Number(session.user.id);
if (isNaN(commentId)) {
return json({ message: 'Invalid comment ID' }, { status: 400 });
}
try {
// Verify the comment exists and belongs to the specified coin
const [commentData] = await db
.select()
.from(comment)
.innerJoin(coin, eq(comment.coinId, coin.id))
.where(and(eq(comment.id, commentId), eq(coin.symbol, coinSymbol)));
if (!commentData) {
return json({ message: 'Comment not found' }, { status: 404 });
}
// Check if user has liked this comment
const [existingLike] = await db
.select()
.from(commentLike)
.where(and(eq(commentLike.userId, userId), eq(commentLike.commentId, commentId)));
if (!existingLike) {
return json({ message: 'Comment not liked' }, { status: 400 });
}
// Remove like and decrement count
await db.transaction(async (tx) => {
await tx
.delete(commentLike)
.where(and(eq(commentLike.userId, userId), eq(commentLike.commentId, commentId)));
await tx
.update(comment)
.set({ likesCount: sql`GREATEST(0, ${comment.likesCount} - 1)` })
.where(eq(comment.id, commentId));
});
return json({ success: true });
} catch (error) {
console.error('Failed to unlike comment:', error);
return json({ message: 'Internal server error' }, { status: 500 });
}
};

View file

@ -5,6 +5,7 @@
import * as Avatar from '$lib/components/ui/avatar';
import * as HoverCard from '$lib/components/ui/hover-card';
import TradeModal from '$lib/components/self/TradeModal.svelte';
import CommentSection from '$lib/components/self/CommentSection.svelte';
import {
TrendingUp,
TrendingDown,
@ -361,7 +362,7 @@
<div class="flex items-center pt-2">
<CalendarDays class="mr-2 h-4 w-4 opacity-70" />
<span class="text-muted-foreground text-xs">
Joined {new Date(coin.createdAt).toLocaleDateString('en-US', {
Joined {new Date(coin.creatorCreatedAt).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long'
})}
@ -565,6 +566,9 @@
</Card.Content>
</Card.Root>
</div>
<!-- Comments Section -->
<CommentSection {coinSymbol} />
</div>
</div>
{/if}

View file

@ -2,7 +2,7 @@
import * as Card from '$lib/components/ui/card';
import * as Popover from '$lib/components/ui/popover';
import * as Pagination from '$lib/components/ui/pagination';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import * as Select from '$lib/components/ui/select';
import { Input } from '$lib/components/ui/input';
import { Button } from '$lib/components/ui/button';
import { Badge } from '$lib/components/ui/badge';
@ -18,11 +18,11 @@
SlidersHorizontal,
ChevronLeft,
ChevronRight,
ChevronDown,
DollarSign,
TrendingUp,
ArrowUpDown
} from 'lucide-svelte'; import { formatPrice, formatMarketCap, debounce, formatRelativeTime } from '$lib/utils';
} from 'lucide-svelte';
import { formatPrice, formatMarketCap, debounce, formatRelativeTime } from '$lib/utils';
import { MediaQuery } from 'svelte/reactivity';
import type { CoinData, FilterOption, VolatilityBadge, MarketResponse } from '$lib/types/market';
@ -165,8 +165,19 @@
fetchMarketData();
}
function handleSortOrderChange(newSortOrder: string) {
sortOrder = newSortOrder;
function handleSortOrderChange() {
currentPage = 1;
updateURL();
fetchMarketData();
}
function handlePriceFilterChange() {
currentPage = 1;
updateURL();
fetchMarketData();
}
function handleChangeFilterChange() {
currentPage = 1;
updateURL();
fetchMarketData();
@ -199,20 +210,6 @@
return null;
}
function handlePriceFilterChange(value: string) {
priceFilter = value;
currentPage = 1;
updateURL();
fetchMarketData();
}
function handleChangeFilterChange(value: string) {
changeFilter = value;
currentPage = 1;
updateURL();
fetchMarketData();
}
let hasActiveFilters = $derived(
searchQuery !== '' ||
priceFilter !== 'all' ||
@ -316,89 +313,68 @@
<div class="space-y-2">
<Label class="text-sm font-medium">Sort Order</Label>
<DropdownMenu.Root>
<DropdownMenu.Trigger
class="border-input bg-background ring-offset-background focus-visible:ring-ring flex h-9 w-full items-center justify-between rounded-md border px-3 py-1 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2"
>
<div class="flex items-center gap-2">
<ArrowUpDown class="h-4 w-4" />
<span>{currentSortOrderLabel}</span>
</div>
<ChevronDown class="h-4 w-4 opacity-50" />
</DropdownMenu.Trigger>
<DropdownMenu.Content class="w-56">
{#each sortOrderOptions as option}
<DropdownMenu.Item
onclick={() => handleSortOrderChange(option.value)}
class="cursor-pointer"
>
<ArrowUpDown class="h-4 w-4" />
<span>{option.label}</span>
{#if sortOrder === option.value}
<div class="bg-primary ml-auto h-2 w-2 rounded-full"></div>
{/if}
</DropdownMenu.Item>
{/each}
</DropdownMenu.Content>
</DropdownMenu.Root>
<Select.Root
type="single"
bind:value={sortOrder}
onValueChange={handleSortOrderChange}
>
<Select.Trigger class="w-full">
{currentSortOrderLabel}
</Select.Trigger>
<Select.Content>
<Select.Group>
{#each sortOrderOptions as option}
<Select.Item value={option.value} label={option.label}>
{option.label}
</Select.Item>
{/each}
</Select.Group>
</Select.Content>
</Select.Root>
</div>
<div class="space-y-2">
<Label class="text-sm font-medium">Price Range</Label>
<DropdownMenu.Root>
<DropdownMenu.Trigger
class="border-input bg-background ring-offset-background focus-visible:ring-ring flex h-9 w-full items-center justify-between rounded-md border px-3 py-1 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2"
>
<div class="flex items-center gap-2">
<DollarSign class="h-4 w-4" />
<span>{currentPriceFilterLabel}</span>
</div>
<ChevronDown class="h-4 w-4 opacity-50" />
</DropdownMenu.Trigger>
<DropdownMenu.Content class="w-56">
{#each priceFilterOptions as option}
<DropdownMenu.Item
onclick={() => handlePriceFilterChange(option.value)}
class="cursor-pointer"
>
<DollarSign class="h-4 w-4" />
<span>{option.label}</span>
{#if priceFilter === option.value}
<div class="bg-primary ml-auto h-2 w-2 rounded-full"></div>
{/if}
</DropdownMenu.Item>
{/each}
</DropdownMenu.Content>
</DropdownMenu.Root>
<Select.Root
type="single"
bind:value={priceFilter}
onValueChange={handlePriceFilterChange}
>
<Select.Trigger class="w-full">
{currentPriceFilterLabel}
</Select.Trigger>
<Select.Content>
<Select.Group>
{#each priceFilterOptions as option}
<Select.Item value={option.value} label={option.label}>
{option.label}
</Select.Item>
{/each}
</Select.Group>
</Select.Content>
</Select.Root>
</div>
<div class="space-y-2">
<Label class="text-sm font-medium">24h Change</Label>
<DropdownMenu.Root>
<DropdownMenu.Trigger
class="border-input bg-background ring-offset-background focus-visible:ring-ring flex h-9 w-full items-center justify-between rounded-md border px-3 py-1 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2"
>
<div class="flex items-center gap-2">
<TrendingUp class="h-4 w-4" />
<span>{currentChangeFilterLabel}</span>
</div>
<ChevronDown class="h-4 w-4 opacity-50" />
</DropdownMenu.Trigger>
<DropdownMenu.Content class="w-56">
{#each changeFilterOptions as option}
<DropdownMenu.Item
onclick={() => handleChangeFilterChange(option.value)}
class="cursor-pointer"
>
<TrendingUp class="h-4 w-4" />
<span>{option.label}</span>
{#if changeFilter === option.value}
<div class="bg-primary ml-auto h-2 w-2 rounded-full"></div>
{/if}
</DropdownMenu.Item>
{/each}
</DropdownMenu.Content>
</DropdownMenu.Root>
<Select.Root
type="single"
bind:value={changeFilter}
onValueChange={handleChangeFilterChange}
>
<Select.Trigger class="w-full">
{currentChangeFilterLabel}
</Select.Trigger>
<Select.Content>
<Select.Group>
{#each changeFilterOptions as option}
<Select.Item value={option.value} label={option.label}>
{option.label}
</Select.Item>
{/each}
</Select.Group>
</Select.Content>
</Select.Root>
</div>
<div class="flex gap-2 pt-2">