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
261
website/src/lib/components/self/CommentSection.svelte
Normal file
261
website/src/lib/components/self/CommentSection.svelte
Normal file
|
|
@ -0,0 +1,261 @@
|
|||
<script lang="ts">
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Textarea } from '$lib/components/ui/textarea';
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import * as Avatar from '$lib/components/ui/avatar';
|
||||
import * as HoverCard from '$lib/components/ui/hover-card';
|
||||
import { Badge } from '$lib/components/ui/badge';
|
||||
import { MessageCircle, Send, Loader2, Heart, CalendarDays } from 'lucide-svelte';
|
||||
import { USER_DATA } from '$lib/stores/user-data';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { goto } from '$app/navigation';
|
||||
import { formatTimeAgo, getPublicUrl } from '$lib/utils';
|
||||
import SignInConfirmDialog from '$lib/components/self/SignInConfirmDialog.svelte';
|
||||
|
||||
const { coinSymbol } = $props<{ coinSymbol: string }>();
|
||||
import type { Comment } from '$lib/types/comment';
|
||||
let comments = $state<Comment[]>([]);
|
||||
let newComment = $state('');
|
||||
let isSubmitting = $state(false);
|
||||
let isLoading = $state(true);
|
||||
let shouldSignIn = $state(false);
|
||||
|
||||
async function loadComments() {
|
||||
try {
|
||||
const response = await fetch(`/api/coin/${coinSymbol}/comments`);
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
comments = result.comments;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load comments:', e);
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function submitComment() {
|
||||
if (!$USER_DATA) {
|
||||
shouldSignIn = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!newComment.trim()) return;
|
||||
|
||||
isSubmitting = true;
|
||||
try {
|
||||
const response = await fetch(`/api/coin/${coinSymbol}/comments`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ content: newComment.trim() })
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
comments = [result.comment, ...comments];
|
||||
newComment = '';
|
||||
} else {
|
||||
const error = await response.json();
|
||||
toast.error(error.message || 'Failed to post comment');
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error('Failed to post comment');
|
||||
} finally {
|
||||
isSubmitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault();
|
||||
submitComment();
|
||||
}
|
||||
}
|
||||
async function toggleLike(commentId: number) {
|
||||
if (!$USER_DATA) {
|
||||
goto('/');
|
||||
return;
|
||||
}
|
||||
|
||||
const commentIndex = comments.findIndex((c) => c.id === commentId);
|
||||
if (commentIndex === -1) return;
|
||||
|
||||
const comment = comments[commentIndex];
|
||||
const wasLiked = comment.isLikedByUser;
|
||||
|
||||
comments[commentIndex] = {
|
||||
...comment,
|
||||
isLikedByUser: !wasLiked,
|
||||
likesCount: wasLiked ? comment.likesCount - 1 : comment.likesCount + 1
|
||||
};
|
||||
|
||||
fetch(`/api/coin/${coinSymbol}/comments/${commentId}/like`, {
|
||||
method: wasLiked ? 'DELETE' : 'POST',
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
}).catch(() => {
|
||||
comments[commentIndex] = comment;
|
||||
});
|
||||
}
|
||||
|
||||
function handleLikeClick(commentId: number) {
|
||||
if (!$USER_DATA) {
|
||||
shouldSignIn = true;
|
||||
return;
|
||||
}
|
||||
toggleLike(commentId);
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
loadComments();
|
||||
});
|
||||
</script>
|
||||
|
||||
<SignInConfirmDialog bind:open={shouldSignIn} />
|
||||
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title class="flex items-center gap-2">
|
||||
<MessageCircle class="h-5 w-5" />
|
||||
Comments ({comments.length})
|
||||
</Card.Title>
|
||||
</Card.Header>
|
||||
<Card.Content class="space-y-4">
|
||||
<!-- Comment Form -->
|
||||
{#if $USER_DATA}
|
||||
<div class="space-y-3">
|
||||
<div class="relative">
|
||||
<Textarea
|
||||
bind:value={newComment}
|
||||
placeholder="Share your thoughts about this coin..."
|
||||
class="min-h-[80px] w-full break-words pb-8 pr-20"
|
||||
style="word-break: break-word; overflow-wrap: break-word;"
|
||||
maxlength={500}
|
||||
onkeydown={handleKeydown}
|
||||
/>
|
||||
<kbd
|
||||
class="bg-muted pointer-events-none absolute bottom-2 right-2 hidden h-5 select-none items-center gap-1 rounded border px-1.5 font-mono text-[10px] font-medium opacity-70 sm:flex"
|
||||
>
|
||||
<span class="text-xs">⌘</span>Enter
|
||||
</kbd>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-muted-foreground text-xs">
|
||||
{newComment.length}/500 characters
|
||||
</span>
|
||||
<Button onclick={submitComment} disabled={!newComment.trim() || isSubmitting} size="sm">
|
||||
{#if isSubmitting}
|
||||
<Loader2 class="h-4 w-4 animate-spin" />
|
||||
Posting...
|
||||
{:else}
|
||||
<Send class="h-4 w-4" />
|
||||
Post
|
||||
{/if}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="text-center">
|
||||
<p class="text-muted-foreground mb-3 text-sm">Sign in to join the discussion</p>
|
||||
<Button onclick={() => goto('/')} size="sm">Sign In</Button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Comments List -->
|
||||
{#if isLoading}
|
||||
<div class="py-8 text-center">
|
||||
<Loader2 class="mx-auto h-6 w-6 animate-spin" />
|
||||
<p class="text-muted-foreground mt-2 text-sm">Loading comments...</p>
|
||||
</div>
|
||||
{:else if comments.length === 0}
|
||||
<div class="py-8 text-center">
|
||||
<MessageCircle class="text-muted-foreground mx-auto h-12 w-12" />
|
||||
<p class="text-muted-foreground mt-2">
|
||||
No comments yet. Be the first to share your thoughts!
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-4">
|
||||
{#each comments as comment (comment.id)}
|
||||
<div class="border-border border-b pb-4 last:border-b-0">
|
||||
<div class="flex items-start gap-3">
|
||||
<button onclick={() => goto(`/user/${comment.userId}`)} class="cursor-pointer">
|
||||
<Avatar.Root class="h-8 w-8">
|
||||
<Avatar.Image src={getPublicUrl(comment.userImage)} alt={comment.userName} />
|
||||
<Avatar.Fallback>{comment.userName?.charAt(0) || '?'}</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
</button>
|
||||
<div class="flex-1 space-y-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<HoverCard.Root>
|
||||
<HoverCard.Trigger
|
||||
class="cursor-pointer font-medium underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-offset-8"
|
||||
onclick={() => goto(`/user/${comment.userId}`)}
|
||||
>
|
||||
{comment.userName}
|
||||
</HoverCard.Trigger>
|
||||
<HoverCard.Content class="w-80" side="top" sideOffset={3}>
|
||||
<div class="flex justify-between space-x-4">
|
||||
<Avatar.Root class="h-14 w-14">
|
||||
<Avatar.Image
|
||||
src={getPublicUrl(comment.userImage)}
|
||||
alt={comment.userName}
|
||||
/>
|
||||
<Avatar.Fallback>{comment.userName?.charAt(0) || '?'}</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
<div class="flex-1 space-y-1">
|
||||
<h4 class="text-sm font-semibold">{comment.userName}</h4>
|
||||
<p class="text-muted-foreground text-sm">@{comment.userUsername}</p>
|
||||
{#if comment.userBio}
|
||||
<p class="text-sm">{comment.userBio}</p>
|
||||
{/if}
|
||||
<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(comment.userCreatedAt).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long'
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</HoverCard.Content>
|
||||
</HoverCard.Root>
|
||||
<button onclick={() => goto(`/user/${comment.userId}`)} class="cursor-pointer">
|
||||
<Badge variant="outline" class="text-xs">
|
||||
@{comment.userUsername}
|
||||
</Badge>
|
||||
</button>
|
||||
<span class="text-muted-foreground text-xs">
|
||||
{formatTimeAgo(comment.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
<p
|
||||
class="whitespace-pre-wrap break-words text-sm leading-relaxed"
|
||||
style="word-break: break-word; overflow-wrap: break-word;"
|
||||
>
|
||||
{comment.content}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onclick={() => handleLikeClick(comment.id)}
|
||||
class="flex h-auto items-center gap-1 p-2 {comment.isLikedByUser
|
||||
? 'text-red-500 hover:text-red-600'
|
||||
: 'text-muted-foreground hover:text-foreground'}"
|
||||
>
|
||||
<Heart class="h-4 w-4 {comment.isLikedByUser ? 'fill-current' : ''}" />
|
||||
{#if comment.likesCount > 0}
|
||||
<span class="text-xs">{comment.likesCount}</span>
|
||||
{/if}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
34
website/src/lib/components/ui/select/index.ts
Normal file
34
website/src/lib/components/ui/select/index.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import { Select as SelectPrimitive } from "bits-ui";
|
||||
|
||||
import Group from "./select-group.svelte";
|
||||
import Label from "./select-label.svelte";
|
||||
import Item from "./select-item.svelte";
|
||||
import Content from "./select-content.svelte";
|
||||
import Trigger from "./select-trigger.svelte";
|
||||
import Separator from "./select-separator.svelte";
|
||||
import ScrollDownButton from "./select-scroll-down-button.svelte";
|
||||
import ScrollUpButton from "./select-scroll-up-button.svelte";
|
||||
|
||||
const Root = SelectPrimitive.Root;
|
||||
|
||||
export {
|
||||
Root,
|
||||
Group,
|
||||
Label,
|
||||
Item,
|
||||
Content,
|
||||
Trigger,
|
||||
Separator,
|
||||
ScrollDownButton,
|
||||
ScrollUpButton,
|
||||
//
|
||||
Root as Select,
|
||||
Group as SelectGroup,
|
||||
Label as SelectLabel,
|
||||
Item as SelectItem,
|
||||
Content as SelectContent,
|
||||
Trigger as SelectTrigger,
|
||||
Separator as SelectSeparator,
|
||||
ScrollDownButton as SelectScrollDownButton,
|
||||
ScrollUpButton as SelectScrollUpButton,
|
||||
};
|
||||
40
website/src/lib/components/ui/select/select-content.svelte
Normal file
40
website/src/lib/components/ui/select/select-content.svelte
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
<script lang="ts">
|
||||
import { Select as SelectPrimitive } from "bits-ui";
|
||||
import SelectScrollUpButton from "./select-scroll-up-button.svelte";
|
||||
import SelectScrollDownButton from "./select-scroll-down-button.svelte";
|
||||
import { cn, type WithoutChild } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
sideOffset = 4,
|
||||
portalProps,
|
||||
children,
|
||||
...restProps
|
||||
}: WithoutChild<SelectPrimitive.ContentProps> & {
|
||||
portalProps?: SelectPrimitive.PortalProps;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<SelectPrimitive.Portal {...portalProps}>
|
||||
<SelectPrimitive.Content
|
||||
bind:ref
|
||||
{sideOffset}
|
||||
data-slot="select-content"
|
||||
class={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 max-h-(--bits-select-content-available-height) origin-(--bits-select-content-transform-origin) relative z-50 min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border shadow-md data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
class={cn(
|
||||
"h-(--bits-select-anchor-height) min-w-(--bits-select-anchor-width) w-full scroll-my-1 p-1"
|
||||
)}
|
||||
>
|
||||
{@render children?.()}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
7
website/src/lib/components/ui/select/select-group.svelte
Normal file
7
website/src/lib/components/ui/select/select-group.svelte
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
<script lang="ts">
|
||||
import { Select as SelectPrimitive } from "bits-ui";
|
||||
|
||||
let { ref = $bindable(null), ...restProps }: SelectPrimitive.GroupProps = $props();
|
||||
</script>
|
||||
|
||||
<SelectPrimitive.Group data-slot="select-group" {...restProps} />
|
||||
38
website/src/lib/components/ui/select/select-item.svelte
Normal file
38
website/src/lib/components/ui/select/select-item.svelte
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
<script lang="ts">
|
||||
import CheckIcon from "@lucide/svelte/icons/check";
|
||||
import { Select as SelectPrimitive } from "bits-ui";
|
||||
import { cn, type WithoutChild } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
value,
|
||||
label,
|
||||
children: childrenProp,
|
||||
...restProps
|
||||
}: WithoutChild<SelectPrimitive.ItemProps> = $props();
|
||||
</script>
|
||||
|
||||
<SelectPrimitive.Item
|
||||
bind:ref
|
||||
{value}
|
||||
data-slot="select-item"
|
||||
class={cn(
|
||||
"data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground outline-hidden *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2 relative flex w-full cursor-default select-none items-center gap-2 rounded-sm py-1.5 pl-2 pr-8 text-sm data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
{#snippet children({ selected, highlighted })}
|
||||
<span class="absolute right-2 flex size-3.5 items-center justify-center">
|
||||
{#if selected}
|
||||
<CheckIcon class="size-4" />
|
||||
{/if}
|
||||
</span>
|
||||
{#if childrenProp}
|
||||
{@render childrenProp({ selected, highlighted })}
|
||||
{:else}
|
||||
{label || value}
|
||||
{/if}
|
||||
{/snippet}
|
||||
</SelectPrimitive.Item>
|
||||
20
website/src/lib/components/ui/select/select-label.svelte
Normal file
20
website/src/lib/components/ui/select/select-label.svelte
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
<script lang="ts">
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {} = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
data-slot="select-label"
|
||||
class={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
<script lang="ts">
|
||||
import ChevronDownIcon from "@lucide/svelte/icons/chevron-down";
|
||||
import { Select as SelectPrimitive } from "bits-ui";
|
||||
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: WithoutChildrenOrChild<SelectPrimitive.ScrollDownButtonProps> = $props();
|
||||
</script>
|
||||
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
bind:ref
|
||||
data-slot="select-scroll-down-button"
|
||||
class={cn("flex cursor-default items-center justify-center py-1", className)}
|
||||
{...restProps}
|
||||
>
|
||||
<ChevronDownIcon class="size-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
<script lang="ts">
|
||||
import ChevronUpIcon from "@lucide/svelte/icons/chevron-up";
|
||||
import { Select as SelectPrimitive } from "bits-ui";
|
||||
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: WithoutChildrenOrChild<SelectPrimitive.ScrollUpButtonProps> = $props();
|
||||
</script>
|
||||
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
bind:ref
|
||||
data-slot="select-scroll-up-button"
|
||||
class={cn("flex cursor-default items-center justify-center py-1", className)}
|
||||
{...restProps}
|
||||
>
|
||||
<ChevronUpIcon class="size-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
18
website/src/lib/components/ui/select/select-separator.svelte
Normal file
18
website/src/lib/components/ui/select/select-separator.svelte
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
<script lang="ts">
|
||||
import type { Separator as SeparatorPrimitive } from "bits-ui";
|
||||
import { Separator } from "$lib/components/ui/separator/index.js";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: SeparatorPrimitive.RootProps = $props();
|
||||
</script>
|
||||
|
||||
<Separator
|
||||
bind:ref
|
||||
data-slot="select-separator"
|
||||
class={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
|
||||
{...restProps}
|
||||
/>
|
||||
29
website/src/lib/components/ui/select/select-trigger.svelte
Normal file
29
website/src/lib/components/ui/select/select-trigger.svelte
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
<script lang="ts">
|
||||
import { Select as SelectPrimitive } from "bits-ui";
|
||||
import ChevronDownIcon from "@lucide/svelte/icons/chevron-down";
|
||||
import { cn, type WithoutChild } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
size = "default",
|
||||
...restProps
|
||||
}: WithoutChild<SelectPrimitive.TriggerProps> & {
|
||||
size?: "sm" | "default";
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<SelectPrimitive.Trigger
|
||||
bind:ref
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
class={cn(
|
||||
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 shadow-xs flex w-fit items-center justify-between gap-2 whitespace-nowrap rounded-md border bg-transparent px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
<ChevronDownIcon class="size-4 opacity-50" />
|
||||
</SelectPrimitive.Trigger>
|
||||
Reference in a new issue