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:
@@ -58,6 +58,27 @@ Also run `pnpm format:check` (see Translation, Formatting, and Git Workflow abov
|
|||||||
org.limits = { students: toResourceUsage(studentsUsed, studentsLimit) };
|
org.limits = { students: toResourceUsage(studentsUsed, studentsLimit) };
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Persisted columns store data, not presentation
|
||||||
|
|
||||||
|
A user-content column (`content`, `body`, `description`, …) stores what the author typed and nothing else. Check this **at the write site** — the component or service building the value — not at the schema.
|
||||||
|
|
||||||
|
Never write into such a column:
|
||||||
|
|
||||||
|
- **Presentational markup** — wrapper elements, Tailwind classes, inline styles. Render that in a component instead.
|
||||||
|
- **Localized strings** — copy baked in at write time can never be re-translated, and it freezes the author's locale for every future reader.
|
||||||
|
- **Copied values from another row** — a quoted snippet, an author name, a title. Store the relationship (a real FK column) and join or derive the display value at render time, so it stays correct when the source row is edited or deleted.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// ❌ don't — markup, English copy and a copy of the parent all land in the column
|
||||||
|
const quote = `<blockquote class="...">Replying to @${target.fullname}"${target.snippet}"</blockquote>`;
|
||||||
|
await createComment(`${quote}${text}`, parentId);
|
||||||
|
|
||||||
|
// ✅ do — persist the relationship, render the label from live data
|
||||||
|
await createComment(text, parentId, target.commentId);
|
||||||
|
```
|
||||||
|
|
||||||
|
The tell that this went wrong: an edit form shows raw markup, because the markup *is* the content.
|
||||||
|
|
||||||
## Creating a New Route
|
## Creating a New Route
|
||||||
|
|
||||||
### Step 1: Validation Schema
|
### Step 1: Validation Schema
|
||||||
|
|||||||
@@ -152,14 +152,14 @@ export const newsfeedRouter = new Hono()
|
|||||||
const user = c.get('user')!;
|
const user = c.get('user')!;
|
||||||
const courseId = c.req.param('courseId')!;
|
const courseId = c.req.param('courseId')!;
|
||||||
const { feedId } = c.req.valid('param');
|
const { feedId } = c.req.valid('param');
|
||||||
const { content, parentId } = c.req.valid('json');
|
const { content, parentId, replyToCommentId } = c.req.valid('json');
|
||||||
|
|
||||||
const groupMemberId = await getGroupMemberIdByCourseAndProfile(courseId, user.id);
|
const groupMemberId = await getGroupMemberIdByCourseAndProfile(courseId, user.id);
|
||||||
if (!groupMemberId) {
|
if (!groupMemberId) {
|
||||||
return c.json({ success: false, error: 'User is not a member of this course' }, 403);
|
return c.json({ success: false, error: 'User is not a member of this course' }, 403);
|
||||||
}
|
}
|
||||||
|
|
||||||
const comment = await createNewsfeedCommentService(feedId, groupMemberId, content, parentId);
|
const comment = await createNewsfeedCommentService(feedId, groupMemberId, content, parentId, replyToCommentId);
|
||||||
|
|
||||||
return c.json({ success: true, data: comment }, 201);
|
return c.json({ success: true, data: comment }, 201);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -265,13 +265,15 @@ export async function getNewsfeedCommentsService(
|
|||||||
* @param authorId Group member ID (author)
|
* @param authorId Group member ID (author)
|
||||||
* @param content Comment content
|
* @param content Comment content
|
||||||
* @param parentId Optional parent comment ID for replies
|
* @param parentId Optional parent comment ID for replies
|
||||||
|
* @param replyToCommentId Optional comment being answered, within the same thread
|
||||||
* @returns Created comment
|
* @returns Created comment
|
||||||
*/
|
*/
|
||||||
export async function createNewsfeedCommentService(
|
export async function createNewsfeedCommentService(
|
||||||
feedId: string,
|
feedId: string,
|
||||||
authorId: string,
|
authorId: string,
|
||||||
content: string,
|
content: string,
|
||||||
parentId?: number
|
parentId?: number,
|
||||||
|
replyToCommentId?: number
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
// Validate parentId when provided
|
// Validate parentId when provided
|
||||||
@@ -295,13 +297,33 @@ export async function createNewsfeedCommentService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (replyToCommentId !== undefined) {
|
||||||
|
const targetComment = await getNewsfeedCommentById(replyToCommentId);
|
||||||
|
|
||||||
|
if (!targetComment) {
|
||||||
|
throw new AppError('Comment being replied to not found', ErrorCodes.COMMENT_NOT_FOUND, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetComment.courseNewsfeedId !== feedId) {
|
||||||
|
throw new AppError('Comment being replied to does not belong to this feed', ErrorCodes.VALIDATION_ERROR, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isThreadRoot = targetComment.id === parentId;
|
||||||
|
const isSiblingReply = targetComment.parentId === parentId;
|
||||||
|
|
||||||
|
if (!isThreadRoot && !isSiblingReply) {
|
||||||
|
throw new AppError('Comment being replied to is not part of this thread', ErrorCodes.VALIDATION_ERROR, 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const sanitizedContent = sanitizeHtml(content);
|
const sanitizedContent = sanitizeHtml(content);
|
||||||
|
|
||||||
const commentData: TNewCourseNewsfeedComment = {
|
const commentData: TNewCourseNewsfeedComment = {
|
||||||
courseNewsfeedId: feedId,
|
courseNewsfeedId: feedId,
|
||||||
authorId,
|
authorId,
|
||||||
content: sanitizedContent,
|
content: sanitizedContent,
|
||||||
parentId: parentId ?? null
|
parentId: parentId ?? null,
|
||||||
|
replyToCommentId: replyToCommentId ?? null
|
||||||
};
|
};
|
||||||
|
|
||||||
const comment = await createNewsfeedComment(commentData);
|
const comment = await createNewsfeedComment(commentData);
|
||||||
|
|||||||
@@ -489,9 +489,15 @@ export class NewsfeedApi extends BaseApiWithErrors {
|
|||||||
feedId: string,
|
feedId: string,
|
||||||
content: string,
|
content: string,
|
||||||
author: { id: string; username: string; fullname: string; avatarUrl: 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) {
|
if (!result.success) {
|
||||||
this.errors = mapZodErrorsToTranslations(result.error, 'newsfeed');
|
this.errors = mapZodErrorsToTranslations(result.error, 'newsfeed');
|
||||||
return;
|
return;
|
||||||
@@ -514,6 +520,7 @@ export class NewsfeedApi extends BaseApiWithErrors {
|
|||||||
authorFullname: author.fullname,
|
authorFullname: author.fullname,
|
||||||
authorUsername: author.username,
|
authorUsername: author.username,
|
||||||
authorAvatarUrl: author.avatarUrl,
|
authorAvatarUrl: author.avatarUrl,
|
||||||
|
replyToAuthorFullname: replyTo?.authorFullname ?? null,
|
||||||
replyCount: 0
|
replyCount: 0
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -2,9 +2,12 @@
|
|||||||
import { onMount, tick } from 'svelte';
|
import { onMount, tick } from 'svelte';
|
||||||
|
|
||||||
import type { Feed } from '$features/course/utils/types';
|
import type { Feed } from '$features/course/utils/types';
|
||||||
|
import { t } from '$lib/utils/functions/translations';
|
||||||
import { newsfeedApi } from '$features/course/api';
|
import { newsfeedApi } from '$features/course/api';
|
||||||
import { getNewsfeedReactionCounts, getSelectedNewsfeedReactionType } from '@cio/ui/custom/newsfeed-reactions';
|
import { getNewsfeedReactionCounts, getSelectedNewsfeedReactionType } from '@cio/ui/custom/newsfeed-reactions';
|
||||||
|
|
||||||
|
import PinIcon from '@lucide/svelte/icons/pin';
|
||||||
|
|
||||||
import DeleteConfirmation from './delete-confirmation.svelte';
|
import DeleteConfirmation from './delete-confirmation.svelte';
|
||||||
import type { NewsfeedCommentsByFeedId } from '$features/course/api';
|
import type { NewsfeedCommentsByFeedId } from '$features/course/api';
|
||||||
import Header from './header.svelte';
|
import Header from './header.svelte';
|
||||||
@@ -80,13 +83,22 @@
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
id={feed.id}
|
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'
|
? 'ring-primary border-primary ring-2'
|
||||||
: 'border-border/60 border'}"
|
: '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)} />
|
<Header {feed} {onPin} onEdit={openEditFeed} onRequestDelete={() => (isDeleteFeedModal = true)} />
|
||||||
|
|
||||||
<div class="px-4 pb-3">
|
<div class="px-4 pb-2">
|
||||||
<NewsfeedReactions
|
<NewsfeedReactions
|
||||||
{reactionCounts}
|
{reactionCounts}
|
||||||
{selectedReactionType}
|
{selectedReactionType}
|
||||||
@@ -100,8 +112,8 @@
|
|||||||
{feed}
|
{feed}
|
||||||
{author}
|
{author}
|
||||||
{comments}
|
{comments}
|
||||||
onAddComment={async (content, parentId) => {
|
onAddComment={async (content, parentId, replyTo) => {
|
||||||
await addNewComment(content, feed.id, parentId);
|
await addNewComment(content, feed.id, parentId, replyTo);
|
||||||
}}
|
}}
|
||||||
onDeleteComment={(commentId, parentId) => {
|
onDeleteComment={(commentId, parentId) => {
|
||||||
if (courseId) deleteComment(feed.id, String(commentId), parentId);
|
if (courseId) deleteComment(feed.id, String(commentId), parentId);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
import { t } from '$lib/utils/functions/translations';
|
import { t } from '$lib/utils/functions/translations';
|
||||||
import { calDateDiff } from '$lib/utils/functions/date';
|
import { calDateDiff } from '$lib/utils/functions/date';
|
||||||
import { newsfeedApi } from '$features/course/api';
|
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 * as CommentTree from '@cio/ui/custom/comment-tree';
|
||||||
import { Button } from '@cio/ui/base/button';
|
import { Button } from '@cio/ui/base/button';
|
||||||
@@ -23,7 +24,11 @@
|
|||||||
avatarUrl: string;
|
avatarUrl: string;
|
||||||
};
|
};
|
||||||
comments?: NewsfeedCommentsByFeedId;
|
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;
|
onDeleteComment: (commentId: string | number, parentId?: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,7 +54,7 @@
|
|||||||
|
|
||||||
const handleStartEdit = (commentId: number, currentContent: string) => {
|
const handleStartEdit = (commentId: number, currentContent: string) => {
|
||||||
editingCommentId = commentId;
|
editingCommentId = commentId;
|
||||||
editingText = currentContent;
|
editingText = stripLegacyReplyQuote(currentContent);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCancelEdit = () => {
|
const handleCancelEdit = () => {
|
||||||
@@ -124,8 +129,7 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleReplyClick = (topLevelId: number, targetCommentId: number, authorName: string, content: string) => {
|
const handleReplyClick = (topLevelId: number, targetCommentId: number, authorName: string, content: string) => {
|
||||||
const cleanText = content.replace(/<blockquote[\s\S]*?<\/blockquote>/gi, '').trim();
|
const snippet = getReplySnippet(content);
|
||||||
const snippet = cleanText.length > 60 ? cleanText.slice(0, 60) + '...' : cleanText;
|
|
||||||
activeReplyTarget = {
|
activeReplyTarget = {
|
||||||
id: topLevelId,
|
id: topLevelId,
|
||||||
commentId: targetCommentId,
|
commentId: targetCommentId,
|
||||||
@@ -143,14 +147,11 @@
|
|||||||
isSubmitting = true;
|
isSubmitting = true;
|
||||||
try {
|
try {
|
||||||
const parentId = activeReplyTarget?.id;
|
const parentId = activeReplyTarget?.id;
|
||||||
let finalContent = text;
|
const replyTo = activeReplyTarget
|
||||||
|
? { commentId: activeReplyTarget.commentId, authorFullname: activeReplyTarget.fullname }
|
||||||
|
: undefined;
|
||||||
|
|
||||||
if (activeReplyTarget) {
|
await onAddComment(text, parentId, replyTo);
|
||||||
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) {
|
if (parentId) {
|
||||||
expandedRepliesMap[parentId] = true;
|
expandedRepliesMap[parentId] = true;
|
||||||
}
|
}
|
||||||
@@ -161,7 +162,7 @@
|
|||||||
};
|
};
|
||||||
</script>
|
</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}
|
{#if totalCount > shownCount}
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -209,10 +210,9 @@
|
|||||||
size="xs"
|
size="xs"
|
||||||
onclick={() => handleSaveEdit(commentIdNum)}
|
onclick={() => handleSaveEdit(commentIdNum)}
|
||||||
disabled={isSavingEdit || !editingText.trim()}
|
disabled={isSavingEdit || !editingText.trim()}
|
||||||
|
loading={isSavingEdit}
|
||||||
>
|
>
|
||||||
{isSavingEdit
|
{$t('course.navItem.news_feed.comments.save')}
|
||||||
? $t('course.navItem.news_feed.comments.saving')
|
|
||||||
: $t('course.navItem.news_feed.comments.save')}
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -277,6 +277,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{: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.Content content={reply.content} />
|
||||||
<CommentTree.Actions
|
<CommentTree.Actions
|
||||||
replyLabel={$t('course.navItem.news_feed.comments.reply')}
|
replyLabel={$t('course.navItem.news_feed.comments.reply')}
|
||||||
|
|||||||
@@ -24,14 +24,8 @@
|
|||||||
let { feed, onPin, onEdit, onRequestDelete }: Props = $props();
|
let { feed, onPin, onEdit, onRequestDelete }: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="p-4 pb-2">
|
<div class="relative px-4 pt-3 pb-0">
|
||||||
{#if feed.isPinned}
|
<div class="mb-2 flex items-center justify-between">
|
||||||
<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">
|
<div class="flex items-center gap-3">
|
||||||
<UserAvatar src={feed.authorAvatarUrl} class="size-10" />
|
<UserAvatar src={feed.authorAvatarUrl} class="size-10" />
|
||||||
<div class="flex flex-col">
|
<div class="flex flex-col">
|
||||||
@@ -43,7 +37,7 @@
|
|||||||
<RoleBasedSecurity allowedRoles={[1, 2]}>
|
<RoleBasedSecurity allowedRoles={[1, 2]}>
|
||||||
<DropdownMenu.Root>
|
<DropdownMenu.Root>
|
||||||
<DropdownMenu.Trigger
|
<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" />
|
<EllipsisVerticalIcon class="h-4 w-4" />
|
||||||
</DropdownMenu.Trigger>
|
</DropdownMenu.Trigger>
|
||||||
|
|||||||
@@ -69,8 +69,13 @@
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const addNewComment = async (comment: string, feedId: string, parentId?: number) => {
|
const addNewComment = async (
|
||||||
await newsfeedApi.createComment(courseId, feedId, comment, author, parentId);
|
comment: string,
|
||||||
|
feedId: string,
|
||||||
|
parentId?: number,
|
||||||
|
replyTo?: { commentId: number; authorFullname: string }
|
||||||
|
) => {
|
||||||
|
await newsfeedApi.createComment(courseId, feedId, comment, author, parentId, replyTo);
|
||||||
|
|
||||||
if (!newsfeedApi.success) {
|
if (!newsfeedApi.success) {
|
||||||
return snackbar.error('snackbar.course.error.commenting_error');
|
return snackbar.error('snackbar.course.error.commenting_error');
|
||||||
@@ -97,11 +102,11 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
const pinnedFeeds = $derived.by(() => {
|
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(() => {
|
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) {
|
function getPageRoles(org: AccountOrg) {
|
||||||
@@ -147,7 +152,7 @@
|
|||||||
/>
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
{#if pinnedFeeds.length > 0}
|
{#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">
|
<span class="text-xs font-medium tracking-wider uppercase">
|
||||||
{$t('course.navItem.news_feed.pinned') || 'Pinned'}
|
{$t('course.navItem.news_feed.pinned') || 'Pinned'}
|
||||||
</span>
|
</span>
|
||||||
@@ -172,7 +177,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if pinnedFeeds.length > 0 && unpinnedFeeds.length > 0}
|
{#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">
|
<span class="text-xs font-medium tracking-wider uppercase">
|
||||||
{$t('course.navItem.news_feed.other_posts')}
|
{$t('course.navItem.news_feed.other_posts')}
|
||||||
</span>
|
</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)}...`;
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import * as schema from '@db/schema';
|
|||||||
|
|
||||||
import type { TCourseNewsfeed, TCourseNewsfeedComment, TNewCourseNewsfeed, TNewCourseNewsfeedComment } from '@db/types';
|
import type { TCourseNewsfeed, TCourseNewsfeedComment, TNewCourseNewsfeed, TNewCourseNewsfeedComment } from '@db/types';
|
||||||
import { and, db, desc, eq, isNull, lt, ne, sql } from '@db/drizzle';
|
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';
|
import { ROLE } from '@cio/utils/constants';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -251,12 +252,17 @@ export async function getNewsfeedCommentsByFeedIdPaginated(
|
|||||||
.where(and(...baseWhereConditions));
|
.where(and(...baseWhereConditions));
|
||||||
const totalCount = Number(totalCountResult[0]?.count || 0);
|
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
|
// Fetch comments with author profile & reply count
|
||||||
const comments = await db
|
const comments = await db
|
||||||
.select({
|
.select({
|
||||||
comment: schema.courseNewsfeedComment,
|
comment: schema.courseNewsfeedComment,
|
||||||
groupmember: schema.groupmember,
|
groupmember: schema.groupmember,
|
||||||
profile: schema.profile,
|
profile: schema.profile,
|
||||||
|
replyToAuthorFullname: replyTargetProfile.fullname,
|
||||||
replyCount: sql<number>`
|
replyCount: sql<number>`
|
||||||
COALESCE(
|
COALESCE(
|
||||||
(SELECT COUNT(*)::int
|
(SELECT COUNT(*)::int
|
||||||
@@ -269,6 +275,9 @@ export async function getNewsfeedCommentsByFeedIdPaginated(
|
|||||||
.from(schema.courseNewsfeedComment)
|
.from(schema.courseNewsfeedComment)
|
||||||
.leftJoin(schema.groupmember, eq(schema.courseNewsfeedComment.authorId, schema.groupmember.id))
|
.leftJoin(schema.groupmember, eq(schema.courseNewsfeedComment.authorId, schema.groupmember.id))
|
||||||
.leftJoin(schema.profile, eq(schema.groupmember.profileId, schema.profile.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))
|
.where(and(...whereConditions))
|
||||||
.orderBy(desc(schema.courseNewsfeedComment.createdAt))
|
.orderBy(desc(schema.courseNewsfeedComment.createdAt))
|
||||||
.limit(limit + 1);
|
.limit(limit + 1);
|
||||||
@@ -285,6 +294,7 @@ export async function getNewsfeedCommentsByFeedIdPaginated(
|
|||||||
authorFullname: row.profile?.fullname || null,
|
authorFullname: row.profile?.fullname || null,
|
||||||
authorUsername: row.profile?.username || null,
|
authorUsername: row.profile?.username || null,
|
||||||
authorAvatarUrl: row.profile?.avatarUrl || null,
|
authorAvatarUrl: row.profile?.avatarUrl || null,
|
||||||
|
replyToAuthorFullname: row.replyToAuthorFullname || null,
|
||||||
replyCount: Number(row.replyCount || 0)
|
replyCount: Number(row.replyCount || 0)
|
||||||
})),
|
})),
|
||||||
totalCount,
|
totalCount,
|
||||||
|
|||||||
@@ -1693,7 +1693,8 @@ export const courseNewsfeedComment = pgTable(
|
|||||||
cache: 1
|
cache: 1
|
||||||
}),
|
}),
|
||||||
courseNewsfeedId: uuid('course_newsfeed_id'),
|
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) => [
|
(table) => [
|
||||||
foreignKey({
|
foreignKey({
|
||||||
@@ -1711,6 +1712,11 @@ export const courseNewsfeedComment = pgTable(
|
|||||||
foreignColumns: [table.id],
|
foreignColumns: [table.id],
|
||||||
name: 'course_newsfeed_comment_parent_id_fkey'
|
name: 'course_newsfeed_comment_parent_id_fkey'
|
||||||
}).onDelete('cascade'),
|
}).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)
|
index('course_newsfeed_comment_parent_id_idx').on(table.parentId)
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,6 +6,10 @@
|
|||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
sideOffset = 4,
|
sideOffset = 4,
|
||||||
portalProps,
|
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,
|
class: className,
|
||||||
...restProps
|
...restProps
|
||||||
}: DropdownMenuPrimitive.ContentProps & {
|
}: DropdownMenuPrimitive.ContentProps & {
|
||||||
@@ -18,6 +22,7 @@
|
|||||||
bind:ref
|
bind:ref
|
||||||
data-slot="dropdown-menu-content"
|
data-slot="dropdown-menu-content"
|
||||||
{sideOffset}
|
{sideOffset}
|
||||||
|
{preventScroll}
|
||||||
class={cn(
|
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',
|
'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
|
className
|
||||||
|
|||||||
@@ -56,7 +56,7 @@
|
|||||||
<UserAvatar src={authorAvatarUrl || undefined} class="ui:mt-1 ui:size-8 ui:shrink-0" />
|
<UserAvatar src={authorAvatarUrl || undefined} class="ui:mt-1 ui:size-8 ui:shrink-0" />
|
||||||
|
|
||||||
<div
|
<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}
|
{#if replyingToUser}
|
||||||
<div
|
<div
|
||||||
@@ -93,7 +93,7 @@
|
|||||||
/>
|
/>
|
||||||
<div class="ui:flex ui:items-center ui:justify-end ui:px-2 ui:pb-2">
|
<div class="ui:flex ui:items-center ui:justify-end ui:px-2 ui:pb-2">
|
||||||
<Button
|
<Button
|
||||||
variant={value.trim() ? 'default' : 'ghost'}
|
variant="secondary"
|
||||||
size="sm"
|
size="sm"
|
||||||
class="ui:h-8 ui:gap-1.5 ui:rounded-md"
|
class="ui:h-8 ui:gap-1.5 ui:rounded-md"
|
||||||
loading={isSubmitting}
|
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>
|
||||||
@@ -5,6 +5,7 @@ import Content from './comment-tree-content.svelte';
|
|||||||
import Actions from './comment-tree-actions.svelte';
|
import Actions from './comment-tree-actions.svelte';
|
||||||
import Input from './comment-tree-input.svelte';
|
import Input from './comment-tree-input.svelte';
|
||||||
import Replies from './comment-tree-replies.svelte';
|
import Replies from './comment-tree-replies.svelte';
|
||||||
|
import ReplyingTo from './comment-tree-replying-to.svelte';
|
||||||
|
|
||||||
export * from './types';
|
export * from './types';
|
||||||
|
|
||||||
@@ -16,6 +17,7 @@ export {
|
|||||||
Actions,
|
Actions,
|
||||||
Input,
|
Input,
|
||||||
Replies,
|
Replies,
|
||||||
|
ReplyingTo,
|
||||||
// Alias names
|
// Alias names
|
||||||
Root as CommentRoot,
|
Root as CommentRoot,
|
||||||
Item as CommentItem,
|
Item as CommentItem,
|
||||||
@@ -23,5 +25,6 @@ export {
|
|||||||
Content as CommentContent,
|
Content as CommentContent,
|
||||||
Actions as CommentActions,
|
Actions as CommentActions,
|
||||||
Input as CommentInput,
|
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({
|
export const ZNewsfeedCommentCreate = z.object({
|
||||||
courseNewsfeedId: z.string().min(1),
|
courseNewsfeedId: z.string().min(1),
|
||||||
content: 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>;
|
export type TNewsfeedCommentCreate = z.infer<typeof ZNewsfeedCommentCreate>;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user