feat: comments
fix: use select instead of dropdown for filter on /market
This commit is contained in:
parent
800b5d1a09
commit
bd05b269fe
22 changed files with 2715 additions and 97 deletions
125
website/src/routes/api/coin/[coinSymbol]/comments/+server.ts
Normal file
125
website/src/routes/api/coin/[coinSymbol]/comments/+server.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
};
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
Reference in a new issue