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:
co-authored by
Rotimi Best
parent
88c4cf03b3
commit
a1c4ee720f
@@ -80,13 +80,13 @@
|
||||
|
||||
<div
|
||||
id={feed.id}
|
||||
class="flex flex-col justify-between gap-2 {isActive
|
||||
? 'border-primary-700 border-2'
|
||||
: 'ui:border'} mb-7 w-[90%] rounded-md md:max-w-3xl"
|
||||
class="bg-card text-card-foreground mb-6 flex w-full max-w-3xl flex-col rounded-xl shadow-sm transition-all duration-200 hover:shadow-md {isActive
|
||||
? 'ring-primary border-primary ring-2'
|
||||
: 'border-border/60 border'}"
|
||||
>
|
||||
<Header {feed} {onPin} onEdit={openEditFeed} onRequestDelete={() => (isDeleteFeedModal = true)} />
|
||||
|
||||
<div class="px-3 pt-1 pb-2">
|
||||
<div class="px-4 pb-3">
|
||||
<NewsfeedReactions
|
||||
{reactionCounts}
|
||||
{selectedReactionType}
|
||||
@@ -100,11 +100,11 @@
|
||||
{feed}
|
||||
{author}
|
||||
{comments}
|
||||
onAddComment={async (content) => {
|
||||
await addNewComment(content, feed.id);
|
||||
onAddComment={async (content, parentId) => {
|
||||
await addNewComment(content, feed.id, parentId);
|
||||
}}
|
||||
onDeleteComment={(commentId) => {
|
||||
if (courseId) deleteComment(feed.id, String(commentId));
|
||||
onDeleteComment={(commentId, parentId) => {
|
||||
if (courseId) deleteComment(feed.id, String(commentId), parentId);
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
@@ -1,18 +1,15 @@
|
||||
<script lang="ts">
|
||||
import pluralize from 'pluralize';
|
||||
import UsersIcon from '@lucide/svelte/icons/users';
|
||||
import SendHorizontalIcon from '@lucide/svelte/icons/send-horizontal';
|
||||
import EllipsisVerticalIcon from '@lucide/svelte/icons/ellipsis-vertical';
|
||||
import * as DropdownMenu from '@cio/ui/base/dropdown-menu';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
import { isOrgAdmin } from '$lib/utils/store/org';
|
||||
import { calDateDiff } from '$lib/utils/functions/date';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { calDateDiff } from '$lib/utils/functions/date';
|
||||
import { newsfeedApi } from '$features/course/api';
|
||||
|
||||
import * as CommentTree from '@cio/ui/custom/comment-tree';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { UserAvatar } from '@cio/ui/custom/user-avatar';
|
||||
import { Textarea } from '@cio/ui/base/textarea';
|
||||
import type { Feed } from '$features/course/utils/types';
|
||||
import type { NewsfeedCommentsByFeedId } from '$features/course/api';
|
||||
|
||||
@@ -26,34 +23,61 @@
|
||||
avatarUrl: string;
|
||||
};
|
||||
comments?: NewsfeedCommentsByFeedId;
|
||||
onAddComment: (content: string) => Promise<void> | void;
|
||||
onDeleteComment: (commentId: string | number) => void;
|
||||
onAddComment: (content: string, parentId?: number) => Promise<void> | void;
|
||||
onDeleteComment: (commentId: string | number, parentId?: number) => void;
|
||||
}
|
||||
|
||||
let { courseId, feed, author, comments, onAddComment, onDeleteComment }: Props = $props();
|
||||
|
||||
let comment = $state('');
|
||||
let errors: { newComment: string } = $state({ newComment: '' });
|
||||
let activeReplyTarget = $state<{
|
||||
id: number;
|
||||
commentId: number;
|
||||
fullname: string;
|
||||
snippet: string;
|
||||
} | null>(null);
|
||||
let isSubmitting = $state(false);
|
||||
let isBootstrapping = $state(false);
|
||||
let didBootstrap = $state(false);
|
||||
let expandedRepliesMap = $state<Record<number, boolean>>({});
|
||||
|
||||
// Pagination / progressive reveal (newest-first)
|
||||
const PAGE_SIZE = 5;
|
||||
let visibleCount = $state(1);
|
||||
let editingCommentId = $state<number | null>(null);
|
||||
let editingText = $state('');
|
||||
let isSavingEdit = $state(false);
|
||||
|
||||
const PAGE_SIZE = 10;
|
||||
let visibleCount = $state(2);
|
||||
|
||||
const handleStartEdit = (commentId: number, currentContent: string) => {
|
||||
editingCommentId = commentId;
|
||||
editingText = currentContent;
|
||||
};
|
||||
|
||||
const handleCancelEdit = () => {
|
||||
editingCommentId = null;
|
||||
editingText = '';
|
||||
};
|
||||
|
||||
const handleSaveEdit = async (commentId: number, parentId?: number) => {
|
||||
if (!courseId || isSavingEdit || !editingText.trim()) return;
|
||||
isSavingEdit = true;
|
||||
try {
|
||||
await newsfeedApi.updateComment(courseId, feed.id, String(commentId), editingText.trim(), parentId);
|
||||
if (newsfeedApi.success) {
|
||||
editingCommentId = null;
|
||||
editingText = '';
|
||||
}
|
||||
} finally {
|
||||
isSavingEdit = false;
|
||||
}
|
||||
};
|
||||
|
||||
const loadedCount = $derived((comments?.items?.length ?? 0) as number);
|
||||
const totalCount = $derived(comments?.totalCount || feed.commentCount);
|
||||
const totalCount = $derived(comments?.totalCount ?? feed.commentCount ?? 0);
|
||||
const isLoading = $derived((comments?.isLoading ?? false) as boolean);
|
||||
const shownCount = $derived(Math.min(visibleCount, loadedCount));
|
||||
const shownCount = $derived(Math.min(visibleCount, Math.max(loadedCount, 0)));
|
||||
|
||||
// Fetch the minimum info needed to:
|
||||
// - know totalCount
|
||||
// - show the latest comment (1) by default
|
||||
const bootstrap = async () => {
|
||||
if (!courseId || !comments) return;
|
||||
if (didBootstrap) return;
|
||||
|
||||
if (!courseId || didBootstrap) return;
|
||||
didBootstrap = true;
|
||||
isBootstrapping = true;
|
||||
try {
|
||||
@@ -63,162 +87,245 @@
|
||||
}
|
||||
};
|
||||
|
||||
const submitComment = async () => {
|
||||
if (isSubmitting) return;
|
||||
|
||||
isSubmitting = true;
|
||||
try {
|
||||
await onAddComment(comment);
|
||||
comment = '';
|
||||
} finally {
|
||||
isSubmitting = false;
|
||||
onMount(async () => {
|
||||
if (totalCount > 0 && (!comments || comments.items.length === 0)) {
|
||||
await bootstrap();
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddNewComment = async (event: KeyboardEvent | MouseEvent) => {
|
||||
if (isSubmitting) return;
|
||||
|
||||
const maybeKey = event as KeyboardEvent & { key?: string };
|
||||
if (maybeKey.key === 'Enter' || event.type === 'click') {
|
||||
event.preventDefault();
|
||||
await submitComment();
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
const ensureLoaded = async (targetVisible: number) => {
|
||||
if (!courseId || !comments) return;
|
||||
|
||||
const target = Math.min(targetVisible, comments.totalCount);
|
||||
|
||||
// Load pages until we have enough locally to render `target` items.
|
||||
while (comments.hasMore && comments.items.length < target && !comments.isLoading) {
|
||||
while (true) {
|
||||
const current = newsfeedApi.commentsByFeedId[feed.id];
|
||||
if (!current || !current.hasMore || current.isLoading || current.items.length >= target) break;
|
||||
const prevLength = current.items.length;
|
||||
await newsfeedApi.loadMoreComments(courseId, feed.id, PAGE_SIZE);
|
||||
const updated = newsfeedApi.commentsByFeedId[feed.id];
|
||||
if (!updated || updated.items.length <= prevLength) break;
|
||||
}
|
||||
};
|
||||
|
||||
const showMore = async () => {
|
||||
if (!courseId || !comments) return;
|
||||
|
||||
// Ensure we know totalCount and have at least 1 comment (if it exists).
|
||||
if (!didBootstrap) {
|
||||
await bootstrap();
|
||||
}
|
||||
|
||||
const next = visibleCount <= 1 ? PAGE_SIZE : visibleCount + PAGE_SIZE;
|
||||
if (!didBootstrap) await bootstrap();
|
||||
const next = visibleCount + PAGE_SIZE;
|
||||
visibleCount = next;
|
||||
await ensureLoaded(next);
|
||||
};
|
||||
|
||||
const minimize = () => {
|
||||
visibleCount = visibleCount > 1 ? 1 : shownCount;
|
||||
const handleToggleReplies = async (parentId: number) => {
|
||||
const isCurrentlyExpanded = Boolean(expandedRepliesMap[parentId]);
|
||||
expandedRepliesMap[parentId] = !isCurrentlyExpanded;
|
||||
|
||||
if (!isCurrentlyExpanded && courseId) {
|
||||
await newsfeedApi.getReplies(courseId, feed.id, parentId);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleComments = async () => {
|
||||
// If already showing more than the latest comment, collapse back to 1.
|
||||
if (totalCount === shownCount) {
|
||||
minimize();
|
||||
return;
|
||||
}
|
||||
const handleReplyClick = (topLevelId: number, targetCommentId: number, authorName: string, content: string) => {
|
||||
const cleanText = content.replace(/<blockquote[\s\S]*?<\/blockquote>/gi, '').trim();
|
||||
const snippet = cleanText.length > 60 ? cleanText.slice(0, 60) + '...' : cleanText;
|
||||
activeReplyTarget = {
|
||||
id: topLevelId,
|
||||
commentId: targetCommentId,
|
||||
fullname: authorName,
|
||||
snippet
|
||||
};
|
||||
};
|
||||
|
||||
await showMore();
|
||||
const handleCancelReply = () => {
|
||||
activeReplyTarget = null;
|
||||
};
|
||||
|
||||
const handleSubmit = async (text: string) => {
|
||||
if (isSubmitting) return;
|
||||
isSubmitting = true;
|
||||
try {
|
||||
const parentId = activeReplyTarget?.id;
|
||||
let finalContent = text;
|
||||
|
||||
if (activeReplyTarget) {
|
||||
const quoteBlock = `<blockquote class="reply-quote ui:mt-0.5 ui:mb-1 ui:rounded-r-md ui:border-l-2 ui:border-primary ui:bg-muted/40 ui:px-2.5 ui:py-1.5 ui:text-xs ui:text-muted-foreground"><strong class="ui:font-medium ui:text-foreground">Replying to @${activeReplyTarget.fullname}</strong><br/><span class="ui:block ui:truncate ui:italic">"${activeReplyTarget.snippet}"</span></blockquote>`;
|
||||
finalContent = `${quoteBlock}${text}`;
|
||||
}
|
||||
|
||||
await onAddComment(finalContent, parentId);
|
||||
if (parentId) {
|
||||
expandedRepliesMap[parentId] = true;
|
||||
}
|
||||
activeReplyTarget = null;
|
||||
} finally {
|
||||
isSubmitting = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<section class="ui:border-t p-3">
|
||||
<div class="flex w-full items-center justify-between">
|
||||
<section class="ui:border-t ui:border-border/60 p-4 pt-3">
|
||||
{#if totalCount > shownCount}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onclick={toggleComments}
|
||||
loading={isBootstrapping || isLoading}
|
||||
class="justify-start"
|
||||
onclick={showMore}
|
||||
disabled={isBootstrapping || isLoading}
|
||||
class="text-muted-foreground hover:text-foreground ui:transition-colors ui:h-auto ui:p-0 ui:justify-start mb-3 text-sm font-medium"
|
||||
>
|
||||
<UsersIcon size={16} />
|
||||
<p class="py-2 text-sm">{pluralize($t('course.navItem.news_feed.comments.comment'), totalCount, true)}</p>
|
||||
|
||||
{#if totalCount > shownCount}
|
||||
<span class="ml-auto text-xs text-gray-600"
|
||||
>{$t('course.navItem.news_feed.comments.showing_of', { shown: shownCount, total: totalCount })}</span
|
||||
>
|
||||
{#if isBootstrapping || isLoading}
|
||||
{$t('course.navItem.news_feed.comments.loading')}
|
||||
{:else}
|
||||
{$t('course.navItem.news_feed.comments.view_more', { count: totalCount - shownCount })}
|
||||
{/if}
|
||||
</Button>
|
||||
{/if}
|
||||
|
||||
{#if shownCount > 1}
|
||||
<Button variant="ghost" size="sm" onclick={minimize}>
|
||||
<span class="ml-auto text-xs text-gray-600">{$t('course.navItem.news_feed.comments.hide')}</span>
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<CommentTree.Root class="max-h-[400px] overflow-y-auto py-2">
|
||||
{#if comments}
|
||||
{#each comments.items.slice(0, shownCount) as commentItem (commentItem.id)}
|
||||
{#if commentItem.content}
|
||||
<div class="group flex items-center justify-between py-2">
|
||||
<span class="flex items-center gap-3">
|
||||
<div class="h-9 w-9">
|
||||
<img
|
||||
src={commentItem.authorAvatarUrl || ''}
|
||||
alt={$t('course.navItem.news_feed.user_avatar_alt')}
|
||||
class="h-full w-full rounded-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
<span>
|
||||
<div class="flex items-center gap-2">
|
||||
<p class="text-sm font-medium capitalize">{commentItem.authorFullname || ''}</p>
|
||||
<p class="text-xs font-medium text-gray-600">{calDateDiff(commentItem.createdAt)}</p>
|
||||
</div>
|
||||
<p>{commentItem.content}</p>
|
||||
</span>
|
||||
</span>
|
||||
{@const commentIdNum = Number(commentItem.id)}
|
||||
{@const repliesState = newsfeedApi.repliesByParentId[commentIdNum]}
|
||||
|
||||
{#if commentItem.authorProfileId === $profile.id || $isOrgAdmin}
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger
|
||||
class="hidden h-8 w-8 items-center justify-center rounded-md group-hover:flex hover:bg-gray-100 dark:hover:bg-neutral-700"
|
||||
>
|
||||
<EllipsisVerticalIcon class="h-5 w-5" />
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content align="end">
|
||||
<DropdownMenu.Item class="text-red-600" onclick={() => onDeleteComment(commentItem.id)}>
|
||||
{$t('course.navItem.news_feed.comments.delete')}
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
{/if}
|
||||
</div>
|
||||
<CommentTree.Item>
|
||||
<div id="comment-{commentItem.id}" class="flex w-full items-start justify-between">
|
||||
<div class="flex w-full flex-col gap-1">
|
||||
<CommentTree.Header
|
||||
avatarUrl={commentItem.authorAvatarUrl}
|
||||
fullname={commentItem.authorFullname}
|
||||
dateLabel={calDateDiff(commentItem.createdAt)}
|
||||
canEdit={commentItem.authorProfileId === $profile.id}
|
||||
onEdit={() => handleStartEdit(commentIdNum, commentItem.content)}
|
||||
canDelete={commentItem.authorProfileId === $profile.id || $isOrgAdmin}
|
||||
onDelete={() => onDeleteComment(commentItem.id)}
|
||||
/>
|
||||
<div class="pl-10">
|
||||
{#if editingCommentId === commentIdNum}
|
||||
<div class="mt-1 flex flex-col gap-2">
|
||||
<Textarea bind:value={editingText} rows={2} class="text-sm" />
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<Button variant="ghost" size="xs" onclick={handleCancelEdit} disabled={isSavingEdit}>
|
||||
{$t('course.navItem.news_feed.heading_button.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
onclick={() => handleSaveEdit(commentIdNum)}
|
||||
disabled={isSavingEdit || !editingText.trim()}
|
||||
>
|
||||
{isSavingEdit
|
||||
? $t('course.navItem.news_feed.comments.saving')
|
||||
: $t('course.navItem.news_feed.comments.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<CommentTree.Content content={commentItem.content} />
|
||||
<CommentTree.Actions
|
||||
replyLabel={$t('course.navItem.news_feed.comments.reply')}
|
||||
deleteLabel={$t('course.navItem.news_feed.comments.delete')}
|
||||
onReply={() =>
|
||||
handleReplyClick(
|
||||
commentIdNum,
|
||||
commentIdNum,
|
||||
commentItem.authorFullname || $t('course.navItem.news_feed.user'),
|
||||
commentItem.content || ''
|
||||
)}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Nested Replies Thread -->
|
||||
<CommentTree.Replies
|
||||
replyCount={commentItem.replyCount || 0}
|
||||
isExpanded={Boolean(expandedRepliesMap[commentIdNum])}
|
||||
showLabel={$t('course.navItem.news_feed.comments.view_replies', { count: commentItem.replyCount || 0 })}
|
||||
hideLabel={$t('course.navItem.news_feed.comments.hide_replies')}
|
||||
onToggleExpand={() => handleToggleReplies(commentIdNum)}
|
||||
>
|
||||
{#if repliesState?.items}
|
||||
{#each repliesState.items as reply (reply.id)}
|
||||
{@const replyIdNum = Number(reply.id)}
|
||||
<CommentTree.Item>
|
||||
<div id="comment-{reply.id}" class="flex w-full flex-col gap-1">
|
||||
<CommentTree.Header
|
||||
avatarUrl={reply.authorAvatarUrl}
|
||||
fullname={reply.authorFullname}
|
||||
dateLabel={calDateDiff(reply.createdAt)}
|
||||
avatarSize="ui:size-7"
|
||||
canEdit={reply.authorProfileId === $profile.id}
|
||||
onEdit={() => handleStartEdit(replyIdNum, reply.content)}
|
||||
canDelete={reply.authorProfileId === $profile.id || $isOrgAdmin}
|
||||
onDelete={() => onDeleteComment(reply.id, commentIdNum)}
|
||||
/>
|
||||
<div class="pl-9">
|
||||
{#if editingCommentId === replyIdNum}
|
||||
<div class="mt-1 flex flex-col gap-2">
|
||||
<Textarea bind:value={editingText} rows={2} class="text-sm" />
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<Button variant="ghost" size="xs" onclick={handleCancelEdit} disabled={isSavingEdit}>
|
||||
{$t('course.navItem.news_feed.heading_button.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
onclick={() => handleSaveEdit(replyIdNum, commentIdNum)}
|
||||
disabled={isSavingEdit || !editingText.trim()}
|
||||
>
|
||||
{isSavingEdit
|
||||
? $t('course.navItem.news_feed.comments.saving')
|
||||
: $t('course.navItem.news_feed.comments.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<CommentTree.Content content={reply.content} />
|
||||
<CommentTree.Actions
|
||||
replyLabel={$t('course.navItem.news_feed.comments.reply')}
|
||||
deleteLabel={$t('course.navItem.news_feed.comments.delete')}
|
||||
onReply={() =>
|
||||
handleReplyClick(
|
||||
commentIdNum,
|
||||
replyIdNum,
|
||||
reply.authorFullname || $t('course.navItem.news_feed.user'),
|
||||
reply.content || ''
|
||||
)}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</CommentTree.Item>
|
||||
{/each}
|
||||
|
||||
{#if repliesState.hasMore}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onclick={() => courseId && newsfeedApi.loadMoreReplies(courseId, feed.id, commentIdNum)}
|
||||
disabled={repliesState.isLoading}
|
||||
class="text-muted-foreground hover:text-foreground ui:transition-colors ui:h-auto ui:p-0 ui:pl-9 ui:justify-start mt-1 text-sm font-medium"
|
||||
>
|
||||
{repliesState.isLoading
|
||||
? $t('course.navItem.news_feed.comments.loading')
|
||||
: $t('course.navItem.news_feed.comments.load_more_replies')}
|
||||
</Button>
|
||||
{/if}
|
||||
{/if}
|
||||
</CommentTree.Replies>
|
||||
</CommentTree.Item>
|
||||
{/if}
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</CommentTree.Root>
|
||||
|
||||
<!-- Input for new comment -->
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<UserAvatar src={author.avatarUrl} alt={$t('course.navItem.news_feed.user_avatar_alt')} class="size-7" />
|
||||
<div class="flex-1">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={comment}
|
||||
onkeydown={handleAddNewComment}
|
||||
placeholder={$t('course.navItem.news_feed.comments.placeholder')}
|
||||
class="ui:border w-full rounded-3xl bg-transparent p-1 pl-2 text-sm disabled:opacity-50"
|
||||
disabled={isSubmitting}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
class="rounded-full"
|
||||
loading={isSubmitting}
|
||||
onclick={handleAddNewComment}
|
||||
aria-label={$t('course.navItem.news_feed.comments.submit_aria')}
|
||||
>
|
||||
<SendHorizontalIcon size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{#if errors?.newComment}
|
||||
<p class="text-sm text-red-500">{errors?.newComment}</p>
|
||||
{/if}
|
||||
<!-- Comment Input Box -->
|
||||
<CommentTree.Input
|
||||
authorAvatarUrl={author.avatarUrl}
|
||||
placeholder={$t('course.navItem.news_feed.comments.placeholder')}
|
||||
replyingToUser={activeReplyTarget?.fullname || null}
|
||||
replyingToSnippet={activeReplyTarget?.snippet || null}
|
||||
onCancelReply={handleCancelReply}
|
||||
cancelLabel={$t('course.navItem.news_feed.comments.cancel_reply')}
|
||||
onSubmit={handleSubmit}
|
||||
{isSubmitting}
|
||||
class="mt-2"
|
||||
/>
|
||||
</section>
|
||||
|
||||
+7
-4
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import * as Dialog from '@cio/ui/base/dialog';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
|
||||
interface Props {
|
||||
openDeleteModal?: boolean;
|
||||
@@ -18,14 +19,16 @@
|
||||
<Dialog.Root bind:open={openDeleteModal}>
|
||||
<Dialog.Content class="w-96">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>Delete Feed</Dialog.Title>
|
||||
<Dialog.Title>{$t('course.navItem.news_feed.delete_modal.title')}</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
<div>
|
||||
<h1 class="text-lg dark:text-white">Are you sure you want to delete this feed?</h1>
|
||||
<h1 class="text-lg dark:text-white">{$t('course.navItem.news_feed.delete_modal.description')}</h1>
|
||||
|
||||
<div class="mt-5 flex items-center justify-between">
|
||||
<Button variant="outline" onclick={() => (openDeleteModal = false)}>No</Button>
|
||||
<Button variant="outline" onclick={handleDelete}>Yes</Button>
|
||||
<Button variant="outline" onclick={() => (openDeleteModal = false)}
|
||||
>{$t('course.navItem.news_feed.delete_modal.no')}</Button
|
||||
>
|
||||
<Button variant="outline" onclick={handleDelete}>{$t('course.navItem.news_feed.delete_modal.yes')}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
<script lang="ts">
|
||||
import * as DropdownMenu from '@cio/ui/base/dropdown-menu';
|
||||
import EllipsisVerticalIcon from '@lucide/svelte/icons/ellipsis-vertical';
|
||||
import PinIcon from '@lucide/svelte/icons/pin';
|
||||
import PencilIcon from '@lucide/svelte/icons/pencil';
|
||||
import Trash2Icon from '@lucide/svelte/icons/trash-2';
|
||||
|
||||
import { calDateDiff } from '$lib/utils/functions/date';
|
||||
import { isHtmlValueEmpty } from '$lib/utils/functions/toHtml';
|
||||
@@ -21,38 +24,57 @@
|
||||
let { feed, onPin, onEdit, onRequestDelete }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="px-3 pt-3 pb-0">
|
||||
<div class="mb-2 flex justify-between">
|
||||
<span class="flex items-center gap-3">
|
||||
<UserAvatar src={feed.authorAvatarUrl} />
|
||||
<span>
|
||||
<p class="text-base font-semibold capitalize">{feed.authorFullname || ''}</p>
|
||||
<p class="text-sm font-medium text-gray-600">{calDateDiff(feed.createdAt)}</p>
|
||||
</span>
|
||||
</span>
|
||||
<div class="p-4 pb-2">
|
||||
{#if feed.isPinned}
|
||||
<div class="text-muted-foreground mb-3 flex items-center gap-1.5">
|
||||
<PinIcon size={14} class="fill-current" />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<UserAvatar src={feed.authorAvatarUrl} class="size-10" />
|
||||
<div class="flex flex-col">
|
||||
<p class="text-foreground text-sm font-semibold capitalize">{feed.authorFullname || 'Anonymous'}</p>
|
||||
<p class="text-muted-foreground text-xs font-normal">{calDateDiff(feed.createdAt)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<RoleBasedSecurity allowedRoles={[1, 2]}>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger
|
||||
class="flex h-8 w-8 items-center justify-center rounded-md hover:bg-gray-100 dark:hover:bg-neutral-700"
|
||||
class="text-muted-foreground hover:bg-accent hover:text-accent-foreground flex h-8 w-8 items-center justify-center rounded-md transition-colors"
|
||||
>
|
||||
<EllipsisVerticalIcon class="h-5 w-5" />
|
||||
<EllipsisVerticalIcon class="h-4 w-4" />
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content align="end">
|
||||
<DropdownMenu.Item onclick={() => onPin(feed.id, feed.isPinned)}>
|
||||
{feed.isPinned ? $t('course.navItem.news_feed.card.unpin') : $t('course.navItem.news_feed.card.pin')}
|
||||
<span class="flex items-center gap-2">
|
||||
<PinIcon size={14} />
|
||||
{feed.isPinned ? $t('course.navItem.news_feed.card.unpin') : $t('course.navItem.news_feed.card.pin')}
|
||||
</span>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item onclick={onEdit}>
|
||||
<span class="flex items-center gap-2">
|
||||
<PencilIcon size={14} />
|
||||
{$t('course.navItem.news_feed.card.edit')}
|
||||
</span>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item class="text-destructive focus:text-destructive" onclick={onRequestDelete}>
|
||||
<span class="flex items-center gap-2">
|
||||
<Trash2Icon size={14} />
|
||||
{$t('course.navItem.news_feed.card.delete')}
|
||||
</span>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item onclick={onEdit}>{$t('course.navItem.news_feed.card.edit')}</DropdownMenu.Item>
|
||||
<DropdownMenu.Item class="text-red-600" onclick={onRequestDelete}
|
||||
>{$t('course.navItem.news_feed.card.delete')}</DropdownMenu.Item
|
||||
>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</RoleBasedSecurity>
|
||||
</div>
|
||||
|
||||
{#if !isHtmlValueEmpty(feed.content || '')}
|
||||
<HTMLRender className="w-[80%]">
|
||||
<HTMLRender
|
||||
className="w-full text-foreground text-sm font-normal md:text-sm prose-headings:text-base prose-headings:font-semibold prose-headings:mb-2 prose-p:mb-2 prose-a:text-primary"
|
||||
>
|
||||
<div>
|
||||
<SafeHtmlContent content={feed.content || ''} />
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user