feat(newsfeed): Improved UI flow for the course newsfeed (#791)
* feat(newsfeed): improved the newsfeed ui * feat(newsfeed): updated the ui * feat(newsfeed): some improvements * feat(newsfeed): cleanup * chore(newsfeed): added translations * fix(newsfeed): fixing comments and reviews from greptile and coderabbit * fix(newsfeed): fixing comments and reviews from greptile and coderabbit, improved replies to a reply * fix(newsfeed): fixing reviews * fix(ui): expose cancelLabel prop in comment-tree-input and pass localized text --------- Co-authored-by: Rotimi Best <best@classroomio.com>
This commit is contained in:
co-authored by
Rotimi Best
parent
88c4cf03b3
commit
a1c4ee720f
@@ -0,0 +1,5 @@
|
||||
ALTER TABLE "course_newsfeed_comment" ADD COLUMN "parent_id" bigint;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "course_newsfeed_comment" ADD CONSTRAINT "course_newsfeed_comment_parent_id_fkey" FOREIGN KEY ("parent_id") REFERENCES "public"."course_newsfeed_comment"("id") ON DELETE cascade ON UPDATE no action;
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "course_newsfeed_comment_parent_id_idx" ON "course_newsfeed_comment" USING btree ("parent_id");
|
||||
File diff suppressed because it is too large
Load Diff
@@ -36,6 +36,13 @@
|
||||
"when": 1783938329367,
|
||||
"tag": "0004_organizationmember_email_notifications",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 5,
|
||||
"version": "7",
|
||||
"when": 1785228000000,
|
||||
"tag": "0005_add_parent_id_to_course_newsfeed_comment",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as schema from '@db/schema';
|
||||
|
||||
import type { TCourseNewsfeed, TCourseNewsfeedComment, TNewCourseNewsfeed, TNewCourseNewsfeedComment } from '@db/types';
|
||||
import { and, db, desc, eq, lt, ne, sql } from '@db/drizzle';
|
||||
import { and, db, desc, eq, isNull, lt, ne, sql } from '@db/drizzle';
|
||||
import { ROLE } from '@cio/utils/constants';
|
||||
|
||||
/**
|
||||
@@ -221,36 +221,50 @@ export async function getNewsfeedCommentsByFeedId(feedId: string): Promise<TCour
|
||||
/**
|
||||
* Gets paginated comments for a newsfeed item with author profile
|
||||
* @param feedId Newsfeed ID
|
||||
* @param options Pagination options (cursor, limit)
|
||||
* @param options Pagination options (parentId, cursor, limit)
|
||||
* @returns Object with comments array, total count, hasMore flag, and nextCursor
|
||||
*/
|
||||
export async function getNewsfeedCommentsByFeedIdPaginated(
|
||||
feedId: string,
|
||||
options: { cursor?: string; limit: number }
|
||||
options: { parentId?: number; cursor?: string; limit: number }
|
||||
) {
|
||||
try {
|
||||
const { cursor, limit } = options;
|
||||
const { parentId, cursor, limit } = options;
|
||||
|
||||
// Build base parent-scoped conditions
|
||||
const parentCondition =
|
||||
parentId !== undefined
|
||||
? eq(schema.courseNewsfeedComment.parentId, parentId)
|
||||
: isNull(schema.courseNewsfeedComment.parentId);
|
||||
|
||||
const baseWhereConditions = [eq(schema.courseNewsfeedComment.courseNewsfeedId, feedId), parentCondition];
|
||||
|
||||
const whereConditions = [...baseWhereConditions];
|
||||
|
||||
// Build where conditions
|
||||
const whereConditions = [eq(schema.courseNewsfeedComment.courseNewsfeedId, feedId)];
|
||||
if (cursor) {
|
||||
// Cursor is the last comment ID, fetch comments created after it
|
||||
whereConditions.push(lt(schema.courseNewsfeedComment.id, Number(cursor)));
|
||||
}
|
||||
|
||||
// Get total count
|
||||
const totalCountResult = await db
|
||||
.select({ count: sql<number>`count(*)`.as('count') })
|
||||
.from(schema.courseNewsfeedComment)
|
||||
.where(eq(schema.courseNewsfeedComment.courseNewsfeedId, feedId));
|
||||
.where(and(...baseWhereConditions));
|
||||
const totalCount = Number(totalCountResult[0]?.count || 0);
|
||||
|
||||
// Fetch comments with author profile (limit + 1 to check if there are more)
|
||||
// Fetch comments with author profile & reply count
|
||||
const comments = await db
|
||||
.select({
|
||||
comment: schema.courseNewsfeedComment,
|
||||
groupmember: schema.groupmember,
|
||||
profile: schema.profile
|
||||
profile: schema.profile,
|
||||
replyCount: sql<number>`
|
||||
COALESCE(
|
||||
(SELECT COUNT(*)::int
|
||||
FROM ${schema.courseNewsfeedComment} c2
|
||||
WHERE c2.parent_id = ${schema.courseNewsfeedComment.id}),
|
||||
0
|
||||
)
|
||||
`.as('replyCount')
|
||||
})
|
||||
.from(schema.courseNewsfeedComment)
|
||||
.leftJoin(schema.groupmember, eq(schema.courseNewsfeedComment.authorId, schema.groupmember.id))
|
||||
@@ -259,11 +273,9 @@ export async function getNewsfeedCommentsByFeedIdPaginated(
|
||||
.orderBy(desc(schema.courseNewsfeedComment.createdAt))
|
||||
.limit(limit + 1);
|
||||
|
||||
// Check if there are more comments
|
||||
const hasMore = comments.length > limit;
|
||||
const items = comments.slice(0, limit);
|
||||
|
||||
// Get next cursor (ID of the oldest comment in this batch - for loading older comments)
|
||||
const nextCursor = hasMore && items.length > 0 ? String(items[items.length - 1].comment.id) : null;
|
||||
|
||||
return {
|
||||
@@ -272,7 +284,8 @@ export async function getNewsfeedCommentsByFeedIdPaginated(
|
||||
authorProfileId: row.profile?.id || null,
|
||||
authorFullname: row.profile?.fullname || null,
|
||||
authorUsername: row.profile?.username || null,
|
||||
authorAvatarUrl: row.profile?.avatarUrl || null
|
||||
authorAvatarUrl: row.profile?.avatarUrl || null,
|
||||
replyCount: Number(row.replyCount || 0)
|
||||
})),
|
||||
totalCount,
|
||||
hasMore,
|
||||
@@ -343,6 +356,27 @@ export async function deleteNewsfeedComment(commentId: number): Promise<TCourseN
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a single newsfeed comment by ID
|
||||
* @param commentId Comment ID
|
||||
* @returns Comment or null
|
||||
*/
|
||||
export async function getNewsfeedCommentById(commentId: number): Promise<TCourseNewsfeedComment | null> {
|
||||
try {
|
||||
const [comment] = await db
|
||||
.select()
|
||||
.from(schema.courseNewsfeedComment)
|
||||
.where(eq(schema.courseNewsfeedComment.id, commentId))
|
||||
.limit(1);
|
||||
return comment || null;
|
||||
} catch (error) {
|
||||
console.error('getNewsfeedCommentById error:', error);
|
||||
throw new Error(
|
||||
`Failed to get newsfeed comment by ID "${commentId}": ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets comment author and course ID for authorization checks
|
||||
* @param commentId Comment ID
|
||||
@@ -489,7 +523,8 @@ function getCommentCount() {
|
||||
COALESCE(
|
||||
(SELECT COUNT(*)::int
|
||||
FROM ${schema.courseNewsfeedComment}
|
||||
WHERE ${eq(schema.courseNewsfeedComment.courseNewsfeedId, schema.courseNewsfeed.id)}),
|
||||
WHERE ${eq(schema.courseNewsfeedComment.courseNewsfeedId, schema.courseNewsfeed.id)}
|
||||
AND ${isNull(schema.courseNewsfeedComment.parentId)}),
|
||||
0
|
||||
)
|
||||
`.as('commentCount');
|
||||
|
||||
@@ -1692,7 +1692,8 @@ export const courseNewsfeedComment = pgTable(
|
||||
minValue: 1,
|
||||
cache: 1
|
||||
}),
|
||||
courseNewsfeedId: uuid('course_newsfeed_id')
|
||||
courseNewsfeedId: uuid('course_newsfeed_id'),
|
||||
parentId: bigint('parent_id', { mode: 'number' })
|
||||
},
|
||||
(table) => [
|
||||
foreignKey({
|
||||
@@ -1704,7 +1705,13 @@ export const courseNewsfeedComment = pgTable(
|
||||
columns: [table.courseNewsfeedId],
|
||||
foreignColumns: [courseNewsfeed.id],
|
||||
name: 'course_newsfeed_comment_course_newsfeed_id_fkey'
|
||||
})
|
||||
}),
|
||||
foreignKey({
|
||||
columns: [table.parentId],
|
||||
foreignColumns: [table.id],
|
||||
name: 'course_newsfeed_comment_parent_id_fkey'
|
||||
}).onDelete('cascade'),
|
||||
index('course_newsfeed_comment_parent_id_idx').on(table.parentId)
|
||||
]
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '../../base/button';
|
||||
import * as DropdownMenu from '../../base/dropdown-menu';
|
||||
import EllipsisVerticalIcon from '@lucide/svelte/icons/ellipsis-vertical';
|
||||
import MessageSquareIcon from '@lucide/svelte/icons/message-square';
|
||||
|
||||
interface Props {
|
||||
onReply?: () => void;
|
||||
canDelete?: boolean;
|
||||
onDelete?: () => void;
|
||||
replyLabel?: string;
|
||||
deleteLabel?: string;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
onReply,
|
||||
canDelete = false,
|
||||
onDelete,
|
||||
replyLabel = 'Reply',
|
||||
deleteLabel = 'Delete',
|
||||
class: className = ''
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="ui:flex ui:items-center ui:gap-2 {className}">
|
||||
{#if onReply}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
onclick={onReply}
|
||||
class="ui:gap-1.5 ui:text-xs ui:text-muted-foreground ui:hover:text-foreground"
|
||||
>
|
||||
<MessageSquareIcon size={14} />
|
||||
<span>{replyLabel}</span>
|
||||
</Button>
|
||||
{/if}
|
||||
|
||||
{#if canDelete && onDelete}
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger
|
||||
class="ui:inline-flex ui:h-7 ui:w-7 ui:items-center ui:justify-center ui:rounded-md ui:text-muted-foreground ui:hover:bg-muted ui:hover:text-foreground"
|
||||
>
|
||||
<EllipsisVerticalIcon class="ui:size-4" />
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content align="end">
|
||||
<DropdownMenu.Item class="ui:text-destructive ui:focus:text-destructive" onclick={onDelete}>
|
||||
{deleteLabel}
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,22 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import { sanitizeHtml } from '../../tools/sanitize';
|
||||
|
||||
interface Props {
|
||||
content?: string | null;
|
||||
class?: string;
|
||||
children?: Snippet;
|
||||
}
|
||||
|
||||
let { content, class: className = '', children }: Props = $props();
|
||||
|
||||
let formattedContent = $derived(content ? sanitizeHtml(content.replace(/(<\/blockquote>)\s+/gi, '$1')) : '');
|
||||
</script>
|
||||
|
||||
<div class="ui:text-sm ui:leading-relaxed ui:text-foreground/90 ui:whitespace-pre-line ui:break-words {className}">
|
||||
{#if children}
|
||||
{@render children()}
|
||||
{:else if formattedContent}
|
||||
{@html formattedContent}
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,91 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import { UserAvatar } from '../user-avatar';
|
||||
import { Badge } from '../../base/badge';
|
||||
import * as DropdownMenu from '../../base/dropdown-menu';
|
||||
import EllipsisVerticalIcon from '@lucide/svelte/icons/ellipsis-vertical';
|
||||
import PencilIcon from '@lucide/svelte/icons/pencil';
|
||||
import Trash2Icon from '@lucide/svelte/icons/trash-2';
|
||||
|
||||
interface Props {
|
||||
avatarUrl?: string | null;
|
||||
fullname?: string | null;
|
||||
createdAt?: string | null;
|
||||
dateLabel?: string | null;
|
||||
roleBadge?: string | null;
|
||||
avatarSize?: string;
|
||||
canEdit?: boolean;
|
||||
onEdit?: () => void;
|
||||
canDelete?: boolean;
|
||||
onDelete?: () => void;
|
||||
editLabel?: string;
|
||||
deleteLabel?: string;
|
||||
class?: string;
|
||||
actions?: Snippet;
|
||||
}
|
||||
|
||||
let {
|
||||
avatarUrl,
|
||||
fullname = '',
|
||||
createdAt,
|
||||
dateLabel,
|
||||
roleBadge,
|
||||
avatarSize = 'ui:size-8',
|
||||
canEdit = false,
|
||||
onEdit,
|
||||
canDelete = false,
|
||||
onDelete,
|
||||
editLabel = 'Edit',
|
||||
deleteLabel = 'Delete',
|
||||
class: className = '',
|
||||
actions
|
||||
}: Props = $props();
|
||||
|
||||
const displayDate = $derived(
|
||||
dateLabel || (createdAt && !isNaN(Date.parse(createdAt)) ? new Date(createdAt).toLocaleDateString() : '')
|
||||
);
|
||||
const hasActions = $derived((canEdit && Boolean(onEdit)) || (canDelete && Boolean(onDelete)));
|
||||
</script>
|
||||
|
||||
<div class="ui:flex ui:w-full ui:items-center ui:justify-between {className}">
|
||||
<div class="ui:flex ui:items-center ui:gap-2.5">
|
||||
<UserAvatar src={avatarUrl || undefined} alt={fullname || 'User Avatar'} class={avatarSize} />
|
||||
<div class="ui:flex ui:items-center ui:gap-2">
|
||||
{#if fullname}
|
||||
<span class="ui:text-sm ui:font-semibold ui:capitalize ui:text-foreground">{fullname}</span>
|
||||
{/if}
|
||||
{#if roleBadge}
|
||||
<Badge variant="secondary" class="ui:text-[10px] ui:px-1.5 ui:py-0.5">{roleBadge}</Badge>
|
||||
{/if}
|
||||
{#if displayDate}
|
||||
<span class="ui:text-xs ui:font-normal ui:text-muted-foreground">{displayDate}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if actions}
|
||||
{@render actions()}
|
||||
{:else if hasActions}
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger
|
||||
class="ui:inline-flex ui:h-7 ui:w-7 ui:items-center ui:justify-center ui:rounded-md ui:text-muted-foreground ui:hover:bg-muted ui:hover:text-foreground"
|
||||
>
|
||||
<EllipsisVerticalIcon class="ui:size-4" />
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content align="end">
|
||||
{#if canEdit && onEdit}
|
||||
<DropdownMenu.Item onclick={onEdit}>
|
||||
<PencilIcon class="ui:mr-2 ui:size-3.5" />
|
||||
<span>{editLabel}</span>
|
||||
</DropdownMenu.Item>
|
||||
{/if}
|
||||
{#if canDelete && onDelete}
|
||||
<DropdownMenu.Item class="ui:text-destructive ui:focus:text-destructive" onclick={onDelete}>
|
||||
<Trash2Icon class="ui:mr-2 ui:size-3.5" />
|
||||
<span>{deleteLabel}</span>
|
||||
</DropdownMenu.Item>
|
||||
{/if}
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,109 @@
|
||||
<script lang="ts">
|
||||
import { UserAvatar } from '../user-avatar';
|
||||
import { Button } from '../../base/button';
|
||||
import { Textarea } from '../../base/textarea';
|
||||
import SendHorizontalIcon from '@lucide/svelte/icons/send-horizontal';
|
||||
import XIcon from '@lucide/svelte/icons/x';
|
||||
|
||||
interface Props {
|
||||
authorAvatarUrl?: string | null;
|
||||
placeholder?: string;
|
||||
replyingToUser?: string | null;
|
||||
replyingToSnippet?: string | null;
|
||||
onCancelReply?: () => void;
|
||||
cancelLabel?: string;
|
||||
onSubmit: (content: string) => Promise<void> | void;
|
||||
isSubmitting?: boolean;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
authorAvatarUrl,
|
||||
placeholder = 'Add a comment...',
|
||||
replyingToUser = null,
|
||||
replyingToSnippet = null,
|
||||
onCancelReply,
|
||||
cancelLabel = 'Cancel',
|
||||
onSubmit,
|
||||
isSubmitting = false,
|
||||
class: className = ''
|
||||
}: Props = $props();
|
||||
|
||||
let value = $state('');
|
||||
|
||||
async function handleSend() {
|
||||
if (!value.trim() || isSubmitting) return;
|
||||
const text = value.trim();
|
||||
value = '';
|
||||
try {
|
||||
await onSubmit(text);
|
||||
} catch {
|
||||
value = text;
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
} else if (e.key === 'Escape' && replyingToUser && onCancelReply) {
|
||||
onCancelReply();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="ui:flex ui:gap-3 {className}">
|
||||
<UserAvatar src={authorAvatarUrl || undefined} class="ui:mt-1 ui:size-8 ui:shrink-0" />
|
||||
|
||||
<div
|
||||
class="ui:flex ui:w-full ui:flex-col ui:overflow-hidden ui:rounded-lg ui:border ui:border-input ui:shadow-xs ui:transition-[color,box-shadow] ui:focus-within:border-ring ui:focus-within:ring-[3px] ui:focus-within:ring-ring/50"
|
||||
>
|
||||
{#if replyingToUser}
|
||||
<div
|
||||
class="ui:flex ui:items-center ui:justify-between ui:bg-muted/30 ui:px-3 ui:py-1.5 ui:text-xs ui:text-muted-foreground ui:border-b ui:border-border/60"
|
||||
>
|
||||
<div class="ui:flex ui:max-w-[85%] ui:flex-col ui:gap-0.5 ui:truncate">
|
||||
<span>Replying to <strong class="ui:font-medium ui:text-foreground">@{replyingToUser}</strong></span>
|
||||
{#if replyingToSnippet}
|
||||
<span class="ui:truncate ui:italic ui:text-muted-foreground/80">"{replyingToSnippet}"</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if onCancelReply}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
onclick={onCancelReply}
|
||||
class="ui:h-auto ui:gap-1 ui:px-0 ui:text-xs ui:font-normal ui:text-muted-foreground ui:hover:bg-transparent ui:hover:text-foreground"
|
||||
>
|
||||
<XIcon size={12} />
|
||||
<span>{cancelLabel}</span>
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="ui:flex ui:flex-col">
|
||||
<Textarea
|
||||
bind:value
|
||||
onkeydown={handleKeyDown}
|
||||
{placeholder}
|
||||
disabled={isSubmitting}
|
||||
rows={1}
|
||||
class="ui:min-h-0 ui:max-h-32 ui:resize-none ui:border-0 ui:bg-transparent ui:px-3 ui:py-2.5 ui:text-sm ui:shadow-none ui:focus-visible:ring-0"
|
||||
/>
|
||||
<div class="ui:flex ui:items-center ui:justify-end ui:px-2 ui:pb-2">
|
||||
<Button
|
||||
variant={value.trim() ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
class="ui:h-8 ui:gap-1.5 ui:rounded-md"
|
||||
loading={isSubmitting}
|
||||
disabled={!value.trim() || isSubmitting}
|
||||
onclick={handleSend}
|
||||
>
|
||||
<span>Send</span>
|
||||
<SendHorizontalIcon size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,16 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
children?: Snippet;
|
||||
}
|
||||
|
||||
let { class: className = '', children }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="ui:group ui:relative ui:flex ui:w-full ui:flex-col ui:gap-1.5 ui:rounded-md ui:py-1 ui:transition-colors {className}"
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
@@ -0,0 +1,64 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import { Button } from '../../base/button';
|
||||
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down';
|
||||
import ChevronUpIcon from '@lucide/svelte/icons/chevron-up';
|
||||
|
||||
interface Props {
|
||||
replyCount?: number;
|
||||
showLabel?: string;
|
||||
hideLabel?: string;
|
||||
isExpanded?: boolean;
|
||||
onToggleExpand?: () => void;
|
||||
class?: string;
|
||||
children?: Snippet;
|
||||
}
|
||||
|
||||
let {
|
||||
replyCount = 0,
|
||||
showLabel,
|
||||
hideLabel = 'Hide replies',
|
||||
isExpanded = $bindable(false),
|
||||
onToggleExpand,
|
||||
class: className = '',
|
||||
children
|
||||
}: Props = $props();
|
||||
|
||||
const label = $derived(
|
||||
isExpanded ? hideLabel : (showLabel ?? `View ${replyCount} ${replyCount === 1 ? 'reply' : 'replies'}`)
|
||||
);
|
||||
|
||||
function handleToggle() {
|
||||
if (onToggleExpand) {
|
||||
onToggleExpand();
|
||||
} else {
|
||||
isExpanded = !isExpanded;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if replyCount > 0 || isExpanded || children}
|
||||
<div class="ui:flex ui:flex-col ui:gap-2 ui:pl-11 {className}">
|
||||
{#if replyCount > 0}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
onclick={handleToggle}
|
||||
class="ui:w-fit ui:gap-1.5 ui:px-0 ui:text-xs ui:font-medium ui:text-muted-foreground ui:hover:bg-transparent ui:hover:text-foreground"
|
||||
>
|
||||
{#if isExpanded}
|
||||
<ChevronUpIcon size={14} />
|
||||
{:else}
|
||||
<ChevronDownIcon size={14} />
|
||||
{/if}
|
||||
<span>{label}</span>
|
||||
</Button>
|
||||
{/if}
|
||||
|
||||
{#if isExpanded}
|
||||
<div class="ui:relative ui:flex ui:flex-col ui:gap-3 ui:border-l-2 ui:border-border/60 ui:pl-6">
|
||||
{@render children?.()}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,14 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
children?: Snippet;
|
||||
}
|
||||
|
||||
let { class: className = '', children }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="ui:flex ui:w-full ui:flex-col ui:gap-3 {className}">
|
||||
{@render children?.()}
|
||||
</div>
|
||||
@@ -0,0 +1,27 @@
|
||||
import Root from './comment-tree-root.svelte';
|
||||
import Item from './comment-tree-item.svelte';
|
||||
import Header from './comment-tree-header.svelte';
|
||||
import Content from './comment-tree-content.svelte';
|
||||
import Actions from './comment-tree-actions.svelte';
|
||||
import Input from './comment-tree-input.svelte';
|
||||
import Replies from './comment-tree-replies.svelte';
|
||||
|
||||
export * from './types';
|
||||
|
||||
export {
|
||||
Root,
|
||||
Item,
|
||||
Header,
|
||||
Content,
|
||||
Actions,
|
||||
Input,
|
||||
Replies,
|
||||
// Alias names
|
||||
Root as CommentRoot,
|
||||
Item as CommentItem,
|
||||
Header as CommentHeader,
|
||||
Content as CommentContent,
|
||||
Actions as CommentActions,
|
||||
Input as CommentInput,
|
||||
Replies as CommentReplies
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
export interface CommentAuthor {
|
||||
id: string;
|
||||
fullname?: string | null;
|
||||
username?: string | null;
|
||||
avatarUrl?: string | null;
|
||||
roleBadge?: string | null;
|
||||
}
|
||||
|
||||
export interface GenericComment {
|
||||
id: string | number;
|
||||
content: string | null;
|
||||
createdAt: string;
|
||||
authorProfileId?: string | null;
|
||||
authorFullname?: string | null;
|
||||
authorUsername?: string | null;
|
||||
authorAvatarUrl?: string | null;
|
||||
parentId?: number | null;
|
||||
replyCount?: number;
|
||||
}
|
||||
@@ -59,7 +59,7 @@
|
||||
{/snippet}
|
||||
</Popover.Trigger>
|
||||
|
||||
<Popover.Content side="top" align="start" sideOffset={10} class="ui:w-auto ui:max-w-none ui:p-1!">
|
||||
<Popover.Content side="bottom" align="start" sideOffset={10} class="ui:w-auto ui:max-w-none ui:p-1!">
|
||||
<div class="ui:grid ui:grid-cols-4 ui:gap-2">
|
||||
{#each NEWSFEED_REACTION_OPTIONS as reactionOption (reactionOption.type)}
|
||||
<Button
|
||||
|
||||
@@ -87,6 +87,7 @@ export { BackButton } from './custom/back-button';
|
||||
export { CourseCard, DEFAULT_COURSE_BANNER_IMAGE } from './custom/course-card';
|
||||
export { NewsfeedReactions } from './custom/newsfeed-reactions';
|
||||
export * as PublicCourse from './custom/public-course';
|
||||
export * as CommentTree from './custom/comment-tree';
|
||||
export * as Certificate from './custom/certificate-preview';
|
||||
export * as EmailPreview from './custom/email-preview';
|
||||
export * from './custom/animation';
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -12,3 +12,4 @@ export type WithoutChild<T> = T extends { child?: any } ? Omit<T, 'child'> : T;
|
||||
export type WithoutChildren<T> = T extends { children?: any } ? Omit<T, 'children'> : T;
|
||||
export type WithoutChildrenOrChild<T> = WithoutChildren<WithoutChild<T>>;
|
||||
export type WithElementRef<T, U extends HTMLElement = HTMLElement> = T & { ref?: U | null };
|
||||
// HMR trigger
|
||||
|
||||
@@ -28,7 +28,7 @@ export const FORBID_ATTR = [
|
||||
'onunload'
|
||||
] as const;
|
||||
|
||||
export const ADD_ATTR = ['data-type', 'data-latex', 'colwidth', 'target', 'rel'] as const;
|
||||
export const ADD_ATTR = ['data-type', 'data-latex', 'colwidth', 'target', 'rel', 'data-comment-id'] as const;
|
||||
|
||||
export const ALLOWED_URI_REGEXP =
|
||||
/^(?:(?:(?:f|ht)tps?|mailto|tel|callto|sms|cid|xmpp):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i;
|
||||
|
||||
@@ -32,7 +32,8 @@ export type TNewsfeedListQuery = z.infer<typeof ZNewsfeedListQuery>;
|
||||
// Newsfeed Comment Schemas
|
||||
export const ZNewsfeedCommentCreate = z.object({
|
||||
courseNewsfeedId: z.string().min(1),
|
||||
content: z.string().min(1)
|
||||
content: z.string().min(1),
|
||||
parentId: z.number().int().positive().optional()
|
||||
});
|
||||
export type TNewsfeedCommentCreate = z.infer<typeof ZNewsfeedCommentCreate>;
|
||||
|
||||
@@ -47,6 +48,7 @@ export const ZNewsfeedCommentUpdate = z.object({
|
||||
export type TNewsfeedCommentUpdate = z.infer<typeof ZNewsfeedCommentUpdate>;
|
||||
|
||||
export const ZNewsfeedCommentsQuery = z.object({
|
||||
parentId: z.coerce.number().int().positive().optional(),
|
||||
cursor: z.string().optional(),
|
||||
limit: z.coerce.number().int().min(1).max(50).optional().default(5)
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user