fix(newsfeed): tighten card spacing and reposition pinned icon (#818)
* fix(newsfeed): tighten card spacing and reposition pinned icon - Move pinned icon to top-left corner with a tilted rotation - Remove the extra pin row that added excess top padding - Reduce spacing between post content, reactions, and comments - Remove card box shadow Co-authored-by: Rotimi Best <[email protected]> * fix(newsfeed): place pinned icon on card corner instead of avatar Co-authored-by: Rotimi Best <[email protected]> * fix(newsfeed): stop dropdown menus locking body scroll bits-ui defaults DropdownMenu.Content's preventScroll to true, so every open ran a body scroll lock: a forced full-document style recalc and reflow, a horizontal shift by the scrollbar width, and pointer-events: none on the body. On the newsfeed page — a long list of cards each with a scrollable comment thread — that made the comment ellipsis menu feel sluggish to open. Menus don't need scroll locking; only modal layers do. Also tighten card spacing, use the primary color for the pinned icon, give the card menu trigger a pointer cursor, and switch the comment save and submit buttons to the loading and secondary variants. * refactor(newsfeed): store reply targets as data, not markup Replies used to prepend a presentational <blockquote> — Tailwind classes, hardcoded English and a copied snippet of the parent — onto the comment body, and that whole string was persisted to course_newsfeed_comment.content. Editing a reply therefore showed raw markup in the textarea, the quoted snippet froze at write time, the label could never be localized, and two of its ui: utilities never compiled because they appeared only in dashboard code. Add a replyToCommentId column, distinct from parentId (the thread root), so a reply can name the comment it answers. The service verifies the target belongs to the same feed and the same thread; the paginated query joins it to return replyToAuthorFullname, so the label is rendered from live data by the new CommentTree.ReplyingTo component using the existing replying_to key. Rows written the old way still display their stored quote, and stripLegacyReplyQuote keeps that markup out of the edit textarea. * refactor(newsfeed): drop redundant comments on reply target plumbing * fix(newsfeed): address review feedback on pinned icon and quote stripping - The pin icon carried an unprefixed `text-muted-foreground` plus `ui:stroke-primary`, which is emitted nowhere because it appears only in dashboard code, so the outline fell back to the muted colour. Use `ui:text-primary` and let the stroke inherit currentColor. - Give the pinned state an accessible name: the icon is `aria-hidden`, and header.svelte no longer carries an indicator, so screen readers had nothing. - Filter feeds on `isPinned` alone. `is_pinned` is only the database column name; drizzle maps it to `isPinned` and the API never returns the snake_case form, so the `as any` fallback was dead and let the page and card disagree. - `ui:transition-color` is not a Tailwind utility and emitted nothing; the plural `ui:transition-colors` is what applies the transition. - Strip a legacy reply quote only when the blockquote carries the `reply-quote` class the old code always wrote, so a blockquote the author typed is kept. * docs(agents): forbid presentational markup in persisted content columns ---------
This commit is contained in:
@@ -489,9 +489,15 @@ export class NewsfeedApi extends BaseApiWithErrors {
|
||||
feedId: string,
|
||||
content: string,
|
||||
author: { id: string; username: string; fullname: string; avatarUrl: string },
|
||||
parentId?: number
|
||||
parentId?: number,
|
||||
replyTo?: { commentId: number; authorFullname: string }
|
||||
) {
|
||||
const result = ZNewsfeedCommentCreate.safeParse({ courseNewsfeedId: feedId, content, parentId });
|
||||
const result = ZNewsfeedCommentCreate.safeParse({
|
||||
courseNewsfeedId: feedId,
|
||||
content,
|
||||
parentId,
|
||||
replyToCommentId: replyTo?.commentId
|
||||
});
|
||||
if (!result.success) {
|
||||
this.errors = mapZodErrorsToTranslations(result.error, 'newsfeed');
|
||||
return;
|
||||
@@ -514,6 +520,7 @@ export class NewsfeedApi extends BaseApiWithErrors {
|
||||
authorFullname: author.fullname,
|
||||
authorUsername: author.username,
|
||||
authorAvatarUrl: author.avatarUrl,
|
||||
replyToAuthorFullname: replyTo?.authorFullname ?? null,
|
||||
replyCount: 0
|
||||
};
|
||||
|
||||
|
||||
@@ -2,9 +2,12 @@
|
||||
import { onMount, tick } from 'svelte';
|
||||
|
||||
import type { Feed } from '$features/course/utils/types';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { newsfeedApi } from '$features/course/api';
|
||||
import { getNewsfeedReactionCounts, getSelectedNewsfeedReactionType } from '@cio/ui/custom/newsfeed-reactions';
|
||||
|
||||
import PinIcon from '@lucide/svelte/icons/pin';
|
||||
|
||||
import DeleteConfirmation from './delete-confirmation.svelte';
|
||||
import type { NewsfeedCommentsByFeedId } from '$features/course/api';
|
||||
import Header from './header.svelte';
|
||||
@@ -80,13 +83,22 @@
|
||||
|
||||
<div
|
||||
id={feed.id}
|
||||
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
|
||||
class="bg-card text-card-foreground relative mb-6 flex w-full max-w-3xl flex-col overflow-visible rounded-xl {isActive
|
||||
? 'ring-primary border-primary ring-2'
|
||||
: 'border-border/60 border'}"
|
||||
>
|
||||
{#if feed.isPinned}
|
||||
<PinIcon
|
||||
size={24}
|
||||
class="ui:text-primary ui:fill-primary pointer-events-none absolute -top-3 -left-2 z-10 -rotate-40"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span class="sr-only">{$t('course.navItem.news_feed.pinned')}</span>
|
||||
{/if}
|
||||
|
||||
<Header {feed} {onPin} onEdit={openEditFeed} onRequestDelete={() => (isDeleteFeedModal = true)} />
|
||||
|
||||
<div class="px-4 pb-3">
|
||||
<div class="px-4 pb-2">
|
||||
<NewsfeedReactions
|
||||
{reactionCounts}
|
||||
{selectedReactionType}
|
||||
@@ -100,8 +112,8 @@
|
||||
{feed}
|
||||
{author}
|
||||
{comments}
|
||||
onAddComment={async (content, parentId) => {
|
||||
await addNewComment(content, feed.id, parentId);
|
||||
onAddComment={async (content, parentId, replyTo) => {
|
||||
await addNewComment(content, feed.id, parentId, replyTo);
|
||||
}}
|
||||
onDeleteComment={(commentId, parentId) => {
|
||||
if (courseId) deleteComment(feed.id, String(commentId), parentId);
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { calDateDiff } from '$lib/utils/functions/date';
|
||||
import { newsfeedApi } from '$features/course/api';
|
||||
import { getReplySnippet, stripLegacyReplyQuote } from '$features/course/utils/newsfeed-comment-utils';
|
||||
|
||||
import * as CommentTree from '@cio/ui/custom/comment-tree';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
@@ -23,7 +24,11 @@
|
||||
avatarUrl: string;
|
||||
};
|
||||
comments?: NewsfeedCommentsByFeedId;
|
||||
onAddComment: (content: string, parentId?: number) => Promise<void> | void;
|
||||
onAddComment: (
|
||||
content: string,
|
||||
parentId?: number,
|
||||
replyTo?: { commentId: number; authorFullname: string }
|
||||
) => Promise<void> | void;
|
||||
onDeleteComment: (commentId: string | number, parentId?: number) => void;
|
||||
}
|
||||
|
||||
@@ -49,7 +54,7 @@
|
||||
|
||||
const handleStartEdit = (commentId: number, currentContent: string) => {
|
||||
editingCommentId = commentId;
|
||||
editingText = currentContent;
|
||||
editingText = stripLegacyReplyQuote(currentContent);
|
||||
};
|
||||
|
||||
const handleCancelEdit = () => {
|
||||
@@ -124,8 +129,7 @@
|
||||
};
|
||||
|
||||
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;
|
||||
const snippet = getReplySnippet(content);
|
||||
activeReplyTarget = {
|
||||
id: topLevelId,
|
||||
commentId: targetCommentId,
|
||||
@@ -143,14 +147,11 @@
|
||||
isSubmitting = true;
|
||||
try {
|
||||
const parentId = activeReplyTarget?.id;
|
||||
let finalContent = text;
|
||||
const replyTo = activeReplyTarget
|
||||
? { commentId: activeReplyTarget.commentId, authorFullname: activeReplyTarget.fullname }
|
||||
: undefined;
|
||||
|
||||
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);
|
||||
await onAddComment(text, parentId, replyTo);
|
||||
if (parentId) {
|
||||
expandedRepliesMap[parentId] = true;
|
||||
}
|
||||
@@ -161,7 +162,7 @@
|
||||
};
|
||||
</script>
|
||||
|
||||
<section class="ui:border-t ui:border-border/60 p-4 pt-3">
|
||||
<section class="ui:border-t ui:border-border/60 px-4 pb-4">
|
||||
{#if totalCount > shownCount}
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -209,10 +210,9 @@
|
||||
size="xs"
|
||||
onclick={() => handleSaveEdit(commentIdNum)}
|
||||
disabled={isSavingEdit || !editingText.trim()}
|
||||
loading={isSavingEdit}
|
||||
>
|
||||
{isSavingEdit
|
||||
? $t('course.navItem.news_feed.comments.saving')
|
||||
: $t('course.navItem.news_feed.comments.save')}
|
||||
{$t('course.navItem.news_feed.comments.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -277,6 +277,14 @@
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
{#if reply.replyToAuthorFullname}
|
||||
<CommentTree.ReplyingTo
|
||||
label={$t('course.navItem.news_feed.comments.replying_to', {
|
||||
name: reply.replyToAuthorFullname
|
||||
})}
|
||||
class="mb-0.5"
|
||||
/>
|
||||
{/if}
|
||||
<CommentTree.Content content={reply.content} />
|
||||
<CommentTree.Actions
|
||||
replyLabel={$t('course.navItem.news_feed.comments.reply')}
|
||||
|
||||
@@ -24,14 +24,8 @@
|
||||
let { feed, onPin, onEdit, onRequestDelete }: Props = $props();
|
||||
</script>
|
||||
|
||||
<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="relative px-4 pt-3 pb-0">
|
||||
<div class="mb-2 flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<UserAvatar src={feed.authorAvatarUrl} class="size-10" />
|
||||
<div class="flex flex-col">
|
||||
@@ -43,7 +37,7 @@
|
||||
<RoleBasedSecurity allowedRoles={[1, 2]}>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger
|
||||
class="text-muted-foreground hover:bg-accent hover:text-accent-foreground flex h-8 w-8 items-center justify-center rounded-md transition-colors"
|
||||
class="text-muted-foreground hover:bg-accent hover:text-accent-foreground flex h-8 w-8 cursor-pointer items-center justify-center rounded-md transition-colors"
|
||||
>
|
||||
<EllipsisVerticalIcon class="h-4 w-4" />
|
||||
</DropdownMenu.Trigger>
|
||||
|
||||
@@ -69,8 +69,13 @@
|
||||
}
|
||||
};
|
||||
|
||||
const addNewComment = async (comment: string, feedId: string, parentId?: number) => {
|
||||
await newsfeedApi.createComment(courseId, feedId, comment, author, parentId);
|
||||
const addNewComment = async (
|
||||
comment: string,
|
||||
feedId: string,
|
||||
parentId?: number,
|
||||
replyTo?: { commentId: number; authorFullname: string }
|
||||
) => {
|
||||
await newsfeedApi.createComment(courseId, feedId, comment, author, parentId, replyTo);
|
||||
|
||||
if (!newsfeedApi.success) {
|
||||
return snackbar.error('snackbar.course.error.commenting_error');
|
||||
@@ -97,11 +102,11 @@
|
||||
};
|
||||
|
||||
const pinnedFeeds = $derived.by(() => {
|
||||
return newsfeedApi.feeds.filter((feed) => Boolean((feed as any).isPinned || (feed as any).is_pinned));
|
||||
return newsfeedApi.feeds.filter((feed) => feed.isPinned);
|
||||
});
|
||||
|
||||
const unpinnedFeeds = $derived.by(() => {
|
||||
return newsfeedApi.feeds.filter((feed) => !((feed as any).isPinned || (feed as any).is_pinned));
|
||||
return newsfeedApi.feeds.filter((feed) => !feed.isPinned);
|
||||
});
|
||||
|
||||
function getPageRoles(org: AccountOrg) {
|
||||
@@ -147,7 +152,7 @@
|
||||
/>
|
||||
{:else}
|
||||
{#if pinnedFeeds.length > 0}
|
||||
<div class="text-muted-foreground mb-3 flex items-center gap-1.5">
|
||||
<div class="text-muted-foreground mb-1.5 flex items-center gap-1.5">
|
||||
<span class="text-xs font-medium tracking-wider uppercase">
|
||||
{$t('course.navItem.news_feed.pinned') || 'Pinned'}
|
||||
</span>
|
||||
@@ -172,7 +177,7 @@
|
||||
{/if}
|
||||
|
||||
{#if pinnedFeeds.length > 0 && unpinnedFeeds.length > 0}
|
||||
<div class="text-muted-foreground mb-3 flex items-center gap-1.5">
|
||||
<div class="text-muted-foreground mb-1.5 flex items-center gap-1.5">
|
||||
<span class="text-xs font-medium tracking-wider uppercase">
|
||||
{$t('course.navItem.news_feed.other_posts')}
|
||||
</span>
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
// Replies used to persist their "Replying to @someone" chrome as a leading
|
||||
// <blockquote> inside the comment body. New replies carry `replyToCommentId`
|
||||
// instead, so these helpers exist only to read rows written the old way.
|
||||
// Matches on the `reply-quote` class the old code always emitted, so a
|
||||
// blockquote the author typed themselves is left alone.
|
||||
const LEADING_REPLY_QUOTE =
|
||||
/^\s*<blockquote[^>]*class=(?:"[^"]*\breply-quote\b[^"]*"|'[^']*\breply-quote\b[^']*')[\s\S]*?<\/blockquote>/i;
|
||||
|
||||
const SNIPPET_MAX_LENGTH = 60;
|
||||
|
||||
export function stripLegacyReplyQuote(content: string) {
|
||||
return content.replace(LEADING_REPLY_QUOTE, '').trim();
|
||||
}
|
||||
|
||||
export function getReplySnippet(content: string) {
|
||||
const body = stripLegacyReplyQuote(content);
|
||||
if (body.length <= SNIPPET_MAX_LENGTH) return body;
|
||||
|
||||
return `${body.slice(0, SNIPPET_MAX_LENGTH)}...`;
|
||||
}
|
||||
Reference in New Issue
Block a user