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 <[email protected]>
This commit is contained in:
Nwosu Emmanuel
2026-07-31 00:26:59 +01:00
committed by GitHub
co-authored by Rotimi Best
parent 88c4cf03b3
commit a1c4ee720f
40 changed files with 13639 additions and 280 deletions
@@ -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