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:
Rotimi Best
2026-08-02 23:23:18 +01:00
committed by GitHub
parent 0482790126
commit e79d920aeb
16 changed files with 176 additions and 47 deletions
+12 -2
View File
@@ -2,6 +2,7 @@ import * as schema from '@db/schema';
import type { TCourseNewsfeed, TCourseNewsfeedComment, TNewCourseNewsfeed, TNewCourseNewsfeedComment } from '@db/types';
import { and, db, desc, eq, isNull, lt, ne, sql } from '@db/drizzle';
import { alias } from 'drizzle-orm/pg-core';
import { ROLE } from '@cio/utils/constants';
/**
@@ -251,16 +252,21 @@ export async function getNewsfeedCommentsByFeedIdPaginated(
.where(and(...baseWhereConditions));
const totalCount = Number(totalCountResult[0]?.count || 0);
const replyTarget = alias(schema.courseNewsfeedComment, 'reply_target');
const replyTargetMember = alias(schema.groupmember, 'reply_target_member');
const replyTargetProfile = alias(schema.profile, 'reply_target_profile');
// Fetch comments with author profile & reply count
const comments = await db
.select({
comment: schema.courseNewsfeedComment,
groupmember: schema.groupmember,
profile: schema.profile,
replyToAuthorFullname: replyTargetProfile.fullname,
replyCount: sql<number>`
COALESCE(
(SELECT COUNT(*)::int
FROM ${schema.courseNewsfeedComment} c2
(SELECT COUNT(*)::int
FROM ${schema.courseNewsfeedComment} c2
WHERE c2.parent_id = ${schema.courseNewsfeedComment.id}),
0
)
@@ -269,6 +275,9 @@ export async function getNewsfeedCommentsByFeedIdPaginated(
.from(schema.courseNewsfeedComment)
.leftJoin(schema.groupmember, eq(schema.courseNewsfeedComment.authorId, schema.groupmember.id))
.leftJoin(schema.profile, eq(schema.groupmember.profileId, schema.profile.id))
.leftJoin(replyTarget, eq(schema.courseNewsfeedComment.replyToCommentId, replyTarget.id))
.leftJoin(replyTargetMember, eq(replyTarget.authorId, replyTargetMember.id))
.leftJoin(replyTargetProfile, eq(replyTargetMember.profileId, replyTargetProfile.id))
.where(and(...whereConditions))
.orderBy(desc(schema.courseNewsfeedComment.createdAt))
.limit(limit + 1);
@@ -285,6 +294,7 @@ export async function getNewsfeedCommentsByFeedIdPaginated(
authorFullname: row.profile?.fullname || null,
authorUsername: row.profile?.username || null,
authorAvatarUrl: row.profile?.avatarUrl || null,
replyToAuthorFullname: row.replyToAuthorFullname || null,
replyCount: Number(row.replyCount || 0)
})),
totalCount,
+7 -1
View File
@@ -1693,7 +1693,8 @@ export const courseNewsfeedComment = pgTable(
cache: 1
}),
courseNewsfeedId: uuid('course_newsfeed_id'),
parentId: bigint('parent_id', { mode: 'number' })
parentId: bigint('parent_id', { mode: 'number' }),
replyToCommentId: bigint('reply_to_comment_id', { mode: 'number' })
},
(table) => [
foreignKey({
@@ -1711,6 +1712,11 @@ export const courseNewsfeedComment = pgTable(
foreignColumns: [table.id],
name: 'course_newsfeed_comment_parent_id_fkey'
}).onDelete('cascade'),
foreignKey({
columns: [table.replyToCommentId],
foreignColumns: [table.id],
name: 'course_newsfeed_comment_reply_to_comment_id_fkey'
}).onDelete('set null'),
index('course_newsfeed_comment_parent_id_idx').on(table.parentId)
]
);
@@ -6,6 +6,10 @@
ref = $bindable(null),
sideOffset = 4,
portalProps,
// bits-ui defaults this to true, which locks body scroll on every open:
// a forced full-document style recalc + reflow, plus pointer-events: none.
// A menu doesn't need it; only modal layers do.
preventScroll = false,
class: className,
...restProps
}: DropdownMenuPrimitive.ContentProps & {
@@ -18,6 +22,7 @@
bind:ref
data-slot="dropdown-menu-content"
{sideOffset}
{preventScroll}
class={cn(
'ui:bg-popover ui:text-popover-foreground ui:data-[state=open]:animate-in ui:data-[state=closed]:animate-out ui:data-[state=closed]:fade-out-0 ui:data-[state=open]:fade-in-0 ui:data-[state=closed]:zoom-out-95 ui:data-[state=open]:zoom-in-95 ui:data-[side=bottom]:slide-in-from-top-2 ui:data-[side=left]:slide-in-from-right-2 ui:data-[side=right]:slide-in-from-left-2 ui:data-[side=top]:slide-in-from-bottom-2 ui:max-h-(--bits-dropdown-menu-content-available-height) ui:origin-(--bits-dropdown-menu-content-transform-origin) ui:z-50 ui:min-w-32 ui:overflow-y-auto ui:overflow-x-hidden ui:rounded-md ui:border ui:p-1 ui:shadow-md ui:outline-none',
className
@@ -56,7 +56,7 @@
<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"
class="ui:flex ui:w-full ui:flex-col ui:overflow-hidden ui:rounded-lg ui:border ui:border-input ui:transition-colors ui:focus-within:border-ring ui:focus-within:ring-[3px] ui:focus-within:ring-ring/50"
>
{#if replyingToUser}
<div
@@ -93,7 +93,7 @@
/>
<div class="ui:flex ui:items-center ui:justify-end ui:px-2 ui:pb-2">
<Button
variant={value.trim() ? 'default' : 'ghost'}
variant="secondary"
size="sm"
class="ui:h-8 ui:gap-1.5 ui:rounded-md"
loading={isSubmitting}
@@ -0,0 +1,15 @@
<script lang="ts">
import CornerDownRightIcon from '@lucide/svelte/icons/corner-down-right';
interface Props {
label: string;
class?: string;
}
let { label, class: className = '' }: Props = $props();
</script>
<p class="ui:flex ui:items-center ui:gap-1 ui:text-xs ui:text-muted-foreground {className}">
<CornerDownRightIcon class="ui:size-3 ui:shrink-0" aria-hidden="true" />
<span class="ui:truncate">{label}</span>
</p>
+4 -1
View File
@@ -5,6 +5,7 @@ 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';
import ReplyingTo from './comment-tree-replying-to.svelte';
export * from './types';
@@ -16,6 +17,7 @@ export {
Actions,
Input,
Replies,
ReplyingTo,
// Alias names
Root as CommentRoot,
Item as CommentItem,
@@ -23,5 +25,6 @@ export {
Content as CommentContent,
Actions as CommentActions,
Input as CommentInput,
Replies as CommentReplies
Replies as CommentReplies,
ReplyingTo as CommentReplyingTo
};
@@ -33,7 +33,8 @@ export type TNewsfeedListQuery = z.infer<typeof ZNewsfeedListQuery>;
export const ZNewsfeedCommentCreate = z.object({
courseNewsfeedId: z.string().min(1),
content: z.string().min(1),
parentId: z.number().int().positive().optional()
parentId: z.number().int().positive().optional(),
replyToCommentId: z.number().int().positive().optional()
});
export type TNewsfeedCommentCreate = z.infer<typeof ZNewsfeedCommentCreate>;