fix: render newsfeed content as html

This commit is contained in:
Rotimi Best
2026-06-11 16:01:10 +01:00
parent 52d9bf18c3
commit bbd243d214
10 changed files with 106 additions and 35 deletions
@@ -23,7 +23,7 @@ import {
} from '@cio/utils/validation/newsfeed'; } from '@cio/utils/validation/newsfeed';
import { get } from 'svelte/store'; import { get } from 'svelte/store';
import { getTextFromHTML } from '$lib/utils/functions/toHtml'; import { isHtmlValueEmpty } from '$lib/utils/functions/toHtml';
import { mapZodErrorsToTranslations } from '$lib/utils/validation'; import { mapZodErrorsToTranslations } from '$lib/utils/validation';
import { profile } from '$lib/utils/store/user'; import { profile } from '$lib/utils/store/user';
import { snackbar } from '$features/ui/snackbar/store'; import { snackbar } from '$features/ui/snackbar/store';
@@ -109,7 +109,15 @@ export class NewsfeedApi extends BaseApiWithErrors {
* Creates a new newsfeed item * Creates a new newsfeed item
*/ */
async create(courseId: string, fields: TNewsfeedCreate) { async create(courseId: string, fields: TNewsfeedCreate) {
const result = ZNewsfeedCreate.safeParse({ ...fields, content: getTextFromHTML(fields.content), courseId }); if (isHtmlValueEmpty(fields.content)) {
this.errors = mapZodErrorsToTranslations(
ZNewsfeedCreate.safeParse({ ...fields, content: '', courseId }).error!,
'newsfeed'
);
return;
}
const result = ZNewsfeedCreate.safeParse({ ...fields, courseId });
if (!result.success) { if (!result.success) {
this.errors = mapZodErrorsToTranslations(result.error, 'newsfeed'); this.errors = mapZodErrorsToTranslations(result.error, 'newsfeed');
return; return;
@@ -160,10 +168,15 @@ export class NewsfeedApi extends BaseApiWithErrors {
* Updates a newsfeed item (content and isPinned only) * Updates a newsfeed item (content and isPinned only)
*/ */
async update(courseId: string, feedId: string, fields: TNewsfeedUpdate) { async update(courseId: string, feedId: string, fields: TNewsfeedUpdate) {
const result = ZNewsfeedUpdate.safeParse({ if (fields.content !== undefined && isHtmlValueEmpty(fields.content)) {
...fields, this.errors = mapZodErrorsToTranslations(
content: getTextFromHTML(fields.content ?? '') ZNewsfeedUpdate.safeParse({ ...fields, content: '' }).error!,
}); 'newsfeed'
);
return;
}
const result = ZNewsfeedUpdate.safeParse(fields);
if (!result.success) { if (!result.success) {
this.errors = mapZodErrorsToTranslations(result.error, 'newsfeed'); this.errors = mapZodErrorsToTranslations(result.error, 'newsfeed');
@@ -24,7 +24,7 @@ import {
ZUpdateProgramReaction ZUpdateProgramReaction
} from '@cio/utils/validation/program'; } from '@cio/utils/validation/program';
import { get } from 'svelte/store'; import { get } from 'svelte/store';
import { getTextFromHTML } from '$lib/utils/functions/toHtml'; import { isHtmlValueEmpty } from '$lib/utils/functions/toHtml';
import { mapZodErrorsToTranslations } from '$lib/utils/validation'; import { mapZodErrorsToTranslations } from '$lib/utils/validation';
import { profile } from '$lib/utils/store/user'; import { profile } from '$lib/utils/store/user';
import { snackbar } from '$features/ui/snackbar/store'; import { snackbar } from '$features/ui/snackbar/store';
@@ -77,10 +77,15 @@ export class ProgramNewsfeedApi extends BaseApiWithErrors {
async create(programId: string, fields: TCreateProgramNewsfeed) { async create(programId: string, fields: TCreateProgramNewsfeed) {
this.success = false; this.success = false;
const validationResult = ZCreateProgramNewsfeed.safeParse({ if (isHtmlValueEmpty(fields.content)) {
...fields, this.errors = mapZodErrorsToTranslations(
content: getTextFromHTML(fields.content) ZCreateProgramNewsfeed.safeParse({ ...fields, content: '' }).error!,
}); 'newsfeed'
);
return;
}
const validationResult = ZCreateProgramNewsfeed.safeParse(fields);
if (!validationResult.success) { if (!validationResult.success) {
this.errors = mapZodErrorsToTranslations(validationResult.error, 'newsfeed'); this.errors = mapZodErrorsToTranslations(validationResult.error, 'newsfeed');
@@ -115,10 +120,15 @@ export class ProgramNewsfeedApi extends BaseApiWithErrors {
async update(programId: string, feedId: string, fields: TUpdateProgramNewsfeed) { async update(programId: string, feedId: string, fields: TUpdateProgramNewsfeed) {
this.success = false; this.success = false;
const validationResult = ZUpdateProgramNewsfeed.safeParse({ if (fields.content !== undefined && isHtmlValueEmpty(fields.content)) {
...fields, this.errors = mapZodErrorsToTranslations(
...(fields.content ? { content: getTextFromHTML(fields.content) } : {}) ZUpdateProgramNewsfeed.safeParse({ ...fields, content: '' }).error!,
}); 'newsfeed'
);
return;
}
const validationResult = ZUpdateProgramNewsfeed.safeParse(fields);
if (!validationResult.success) { if (!validationResult.success) {
this.errors = mapZodErrorsToTranslations(validationResult.error, 'newsfeed'); this.errors = mapZodErrorsToTranslations(validationResult.error, 'newsfeed');
@@ -23,6 +23,7 @@ export { default as Search } from './search.svelte';
export { default as SenjaEmbed } from './senja-embed.svelte'; export { default as SenjaEmbed } from './senja-embed.svelte';
export { default as Snackbar } from './snackbar/snackbar.svelte'; export { default as Snackbar } from './snackbar/snackbar.svelte';
export { default as TextEditor } from './text-editor.svelte'; export { default as TextEditor } from './text-editor.svelte';
export { preloadTextEditor } from './text-editor.svelte';
export { default as UnsavedChanges } from './unsaved-changes.svelte'; export { default as UnsavedChanges } from './unsaved-changes.svelte';
export { default as UploadImage } from './upload-image.svelte'; export { default as UploadImage } from './upload-image.svelte';
export { default as UpgradeBanner } from './upgrade-banner.svelte'; export { default as UpgradeBanner } from './upgrade-banner.svelte';
@@ -8,11 +8,17 @@
pending ??= import('@cio/ui/custom/editor'); pending ??= import('@cio/ui/custom/editor');
return pending; return pending;
} }
/** Warm the editor chunk without mounting an instance (e.g. on newsfeed page load). */
export function preloadTextEditor() {
return loadEditor();
}
</script> </script>
<script lang="ts"> <script lang="ts">
// Type-only imports are erased at build time — no static TipTap dependency. // Type-only imports are erased at build time — no static TipTap dependency.
import type { HTMLContent, TiptapEditor } from '@cio/ui/custom/editor'; import type { HTMLContent, TiptapEditor } from '@cio/ui/custom/editor';
import { cn } from '@cio/ui/tools';
interface Props { interface Props {
placeholder?: string | ((node: any) => string); placeholder?: string | ((node: any) => string);
@@ -46,7 +52,21 @@
</script> </script>
{#await loadEditor()} {#await loadEditor()}
<div class="ui:animate-pulse ui:rounded-md ui:bg-muted ui:h-32 ui:w-full" /> <div
class={cn(
'ui:relative ui:bg-background ui:flex ui:w-full ui:flex-col ui:rounded-md ui:border ui:border-dashed',
className
)}
aria-busy="true"
aria-hidden="true"
>
{#if showToolBar}
<div class="ui:h-9 ui:shrink-0 ui:border-b ui:border-dashed ui:bg-muted/50" />
{/if}
<div
class={cn('ui:relative ui:h-128 ui:w-full ui:overflow-auto ui:p-4 ui:animate-pulse ui:bg-muted/50', editorClass)}
/>
</div>
{:then { Editor }} {:then { Editor }}
<Editor <Editor
{content} {content}
@@ -1,10 +1,11 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { resolve } from '$app/paths'; import { resolve } from '$app/paths';
import { NewsFeedPage } from '$features/course/pages'; import { NewsFeedPage } from '$features/course/pages';
import { Button } from '@cio/ui/base/button'; import { Button } from '@cio/ui/base/button';
import * as Page from '@cio/ui/base/page'; import * as Page from '@cio/ui/base/page';
import { RefreshPageData, RoleBasedSecurity } from '$features/ui'; import { RefreshPageData, RoleBasedSecurity, preloadTextEditor } from '$features/ui';
import { currentOrg } from '$lib/utils/store/org'; import { currentOrg } from '$lib/utils/store/org';
import { t } from '$lib/utils/functions/translations'; import { t } from '$lib/utils/functions/translations';
import type { AccountOrg } from '$features/app/types'; import type { AccountOrg } from '$features/app/types';
@@ -21,6 +22,10 @@
return roles; return roles;
} }
onMount(() => {
void preloadTextEditor();
});
</script> </script>
<RoleBasedSecurity <RoleBasedSecurity
@@ -38,7 +43,7 @@
</Page.HeaderContent> </Page.HeaderContent>
<Page.Action> <Page.Action>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<Button onclick={() => newsfeedApi.openNewFeedModal()}> <Button onmouseenter={() => preloadTextEditor()} onclick={() => newsfeedApi.openNewFeedModal()}>
{$t('course.navItem.news_feed.heading_button.title')} {$t('course.navItem.news_feed.heading_button.title')}
</Button> </Button>
<RefreshPageData onRefresh={() => newsfeedApi.list(data.courseId)} /> <RefreshPageData onRefresh={() => newsfeedApi.list(data.courseId)} />
@@ -16,6 +16,7 @@
import PinIcon from '@lucide/svelte/icons/pin'; import PinIcon from '@lucide/svelte/icons/pin';
import { BookIcon } from '@lucide/svelte'; import { BookIcon } from '@lucide/svelte';
import { buildNextNewsfeedReaction, type NewsfeedReactionType } from '@cio/ui/custom/newsfeed-reactions'; import { buildNextNewsfeedReaction, type NewsfeedReactionType } from '@cio/ui/custom/newsfeed-reactions';
import { preloadTextEditor } from '$features/ui';
interface Props { interface Props {
data: { data: {
@@ -106,6 +107,7 @@
}; };
onMount(async () => { onMount(async () => {
void preloadTextEditor();
await loadNewsfeed(); await loadNewsfeed();
}); });
</script> </script>
@@ -117,7 +119,7 @@
</Page.HeaderContent> </Page.HeaderContent>
<Page.Action> <Page.Action>
{#if canCreateNewsfeed} {#if canCreateNewsfeed}
<Button onclick={() => programNewsfeedApi.openNewFeedModal()}> <Button onmouseenter={() => preloadTextEditor()} onclick={() => programNewsfeedApi.openNewFeedModal()}>
{$t('programs.newsfeed.post')} {$t('programs.newsfeed.post')}
</Button> </Button>
{/if} {/if}
@@ -143,7 +145,7 @@
variant="page" variant="page"
> >
{#if canCreateNewsfeed} {#if canCreateNewsfeed}
<Button onclick={() => programNewsfeedApi.openNewFeedModal()}> <Button onmouseenter={() => preloadTextEditor()} onclick={() => programNewsfeedApi.openNewFeedModal()}>
{$t('programs.newsfeed.post')} {$t('programs.newsfeed.post')}
</Button> </Button>
{/if} {/if}
+5 -1
View File
@@ -37,7 +37,11 @@ export default (
link: { link: {
openOnClick: false, openOnClick: false,
autolink: true, autolink: true,
linkOnPaste: true linkOnPaste: true,
HTMLAttributes: {
target: '_blank',
rel: 'noopener noreferrer nofollow'
}
}, },
codeBlock: false codeBlock: false
}), }),
@@ -12,7 +12,31 @@
const { editor }: Props = $props(); const { editor }: Props = $props();
let link = $derived.by(() => editor.getAttributes('link').href); let href = $state('');
$effect(() => {
const syncHref = () => {
href = editor.getAttributes('link').href ?? '';
};
syncHref();
editor.on('selectionUpdate', syncHref);
editor.on('transaction', syncHref);
return () => {
editor.off('selectionUpdate', syncHref);
editor.off('transaction', syncHref);
};
});
function copyLink(event: MouseEvent) {
event.preventDefault();
const url = editor.getAttributes('link').href;
if (!url) return;
void navigator.clipboard.writeText(url);
}
</script> </script>
<BubbleMenu <BubbleMenu
@@ -27,20 +51,12 @@
}} }}
class="ui:bg-popover ui:flex ui:h-fit ui:w-fit ui:items-center ui:gap-1 ui:rounded ui:border ui:p-1 ui:shadow-lg" class="ui:bg-popover ui:flex ui:h-fit ui:w-fit ui:items-center ui:gap-1 ui:rounded ui:border ui:p-1 ui:shadow-lg"
> >
<Button variant="link" href={link} class="ui:max-w-80 ui:p-1" target="_blank"> <Button variant="link" {href} class="ui:max-w-80 ui:p-1" target="_blank">
<span class="ui:w-full ui:overflow-hidden ui:text-ellipsis"> <span class="ui:w-full ui:overflow-hidden ui:text-ellipsis">
{link} {href}
</span> </span>
</Button> </Button>
<Button <Button variant="ghost" title="Copy Link" size="icon" class="ui:z-50" onmousedown={copyLink}>
variant="ghost"
title="Copy Link"
size="icon"
class="ui:z-50"
onclick={() => {
navigator.clipboard.writeText(link);
}}
>
<Copy /> <Copy />
</Button> </Button>
<Button <Button
@@ -99,7 +99,7 @@
<BubbleMenu <BubbleMenu
{editor} {editor}
pluginKey="link-bubble-menu" pluginKey="format-bubble-menu"
{shouldShow} {shouldShow}
class={cn( class={cn(
'edra-bubble-menu ui:bg-popover ui:flex ui:h-fit ui:w-fit ui:items-center ui:rounded-lg ui:p-0.5 ui:shadow-lg', 'edra-bubble-menu ui:bg-popover ui:flex ui:h-fit ui:w-fit ui:items-center ui:rounded-lg ui:p-0.5 ui:shadow-lg',
+1 -1
View File
@@ -28,7 +28,7 @@ export const FORBID_ATTR = [
'onunload' 'onunload'
] as const; ] as const;
export const ADD_ATTR = ['data-type', 'data-latex', 'colwidth'] as const; export const ADD_ATTR = ['data-type', 'data-latex', 'colwidth', 'target', 'rel'] as const;
export const ALLOWED_URI_REGEXP = export const ALLOWED_URI_REGEXP =
/^(?:(?:(?:f|ht)tps?|mailto|tel|callto|sms|cid|xmpp):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i; /^(?:(?:(?:f|ht)tps?|mailto|tel|callto|sms|cid|xmpp):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i;