Files
chill-bundles/src/Bundle/ChillMainBundle/Resources/public/vuejs/HomepageWidget/DashboardWidgets/NewsItem.vue
2025-07-09 17:46:36 +02:00

192 lines
4.7 KiB
Vue

<template>
<li>
<h2>{{ props.item.title }}</h2>
<time class="createdBy" datetime="{{item.startDate.datetime}}">{{
$d(newsItemStartDate(), "text")
}}</time>
<div class="content" v-if="shouldTruncate(item.content)">
<div v-html="prepareContent(item.content)"></div>
<div class="float-end">
<button
class="btn btn-sm btn-show read-more"
@click="() => openModal(item)"
>
{{ $t("widget.news.readMore") }}
</button>
</div>
</div>
<div class="content" v-else>
<div v-html="convertMarkdownToHtml(item.content)"></div>
</div>
<modal v-if="showModal" @close="closeModal">
<template #header>
<p class="news-title">{{ item.title }}</p>
</template>
<template #body>
<p class="news-date">
<time class="createdBy" datetime="{{item.startDate.datetime}}">{{
$d(newsItemStartDate(), "text")
}}</time>
</p>
<div v-html="convertMarkdownToHtml(item.content)"></div>
</template>
</modal>
</li>
</template>
<script setup lang="ts">
import Modal from "ChillMainAssets/vuejs/_components/Modal.vue";
import { marked } from "marked";
import DOMPurify from "dompurify";
import { NewsItemType } from "../../../types";
import type { PropType } from "vue";
import { ref } from "vue";
import { ISOToDatetime } from "../../../chill/js/date";
const props = defineProps({
item: {
type: Object as PropType<NewsItemType>,
required: true,
},
maxLength: {
type: Number,
required: false,
default: 350,
},
maxLines: {
type: Number,
required: false,
default: 3,
},
});
const selectedArticle = ref<NewsItemType | null>(null);
const showModal = ref(false);
const openModal = (item: NewsItemType) => {
selectedArticle.value = item;
showModal.value = true;
};
const closeModal = () => {
selectedArticle.value = null;
showModal.value = false;
};
const shouldTruncate = (content: string): boolean => {
const lines = content.split("\n");
// Check if any line exceeds the maximum length
const tooManyLines = lines.length > props.maxLines;
return content.length > props.maxLength || tooManyLines;
};
const truncateContent = (content: string): string => {
let truncatedContent = content.slice(0, props.maxLength);
let linkDepth = 0;
let linkStartIndex = -1;
const lines = content.split("\n");
// Truncate if amount of lines are too many
if (lines.length > props.maxLines && content.length < props.maxLength) {
const truncatedContent = lines.slice(0, props.maxLines).join("\n").trim();
return truncatedContent + "...";
}
for (let i = 0; i < truncatedContent.length; i++) {
const char = truncatedContent[i];
if (char === "[") {
linkDepth++;
if (linkDepth === 1) {
linkStartIndex = i;
}
} else if (char === "]") {
linkDepth = Math.max(0, linkDepth - 1);
} else if (char === "(" && linkDepth === 0) {
truncatedContent = truncatedContent.slice(0, i);
break;
}
}
while (linkDepth > 0) {
truncatedContent += "]";
linkDepth--;
}
// If a link was found, append the URL inside the parentheses
if (linkStartIndex !== -1) {
const linkEndIndex = content.indexOf(")", linkStartIndex);
const url = content.slice(linkStartIndex + 1, linkEndIndex);
truncatedContent = truncatedContent.slice(0, linkStartIndex) + `(${url})`;
}
truncatedContent += "...";
return truncatedContent;
};
const preprocess = (markdown: string): string => {
return markdown;
};
const postprocess = (html: string): string => {
DOMPurify.addHook("afterSanitizeAttributes", (node: any) => {
if ("target" in node) {
node.setAttribute("target", "_blank");
node.setAttribute("rel", "noopener noreferrer");
}
if (
!node.hasAttribute("target") &&
(node.hasAttribute("xlink:href") || node.hasAttribute("href"))
) {
node.setAttribute("xlink:show", "new");
}
});
return DOMPurify.sanitize(html);
};
const convertMarkdownToHtml = (markdown: string): string => {
marked.use({ hooks: { postprocess, preprocess }, async: false });
const rawHtml = marked(markdown) as string;
return rawHtml;
};
const prepareContent = (content: string): string => {
const htmlContent = convertMarkdownToHtml(content);
return truncateContent(htmlContent);
};
const newsItemStartDate = (): null | Date => {
return ISOToDatetime(props.item?.startDate.datetime);
};
</script>
<style scoped>
li {
margin-bottom: 20px;
overflow: hidden;
padding: 0.8rem;
background-color: #fbfbfb;
border-radius: 4px;
}
h2 {
font-size: 1rem !important;
text-transform: uppercase;
}
.content {
overflow: hidden;
font-size: 0.9rem;
position: relative;
}
.news-title {
font-weight: bold;
}
</style>