forked from UKSOURCE/hailearning.edu.vn
feat: Refactor blog components and add pagination
This commit is contained in:
21
utils/date.ts
Normal file
21
utils/date.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Format a date to `20 March 2025` style.
|
||||
*
|
||||
* Accepts:
|
||||
* - ISO string: "2025-03-20T07:59:53.219Z"
|
||||
* - "2025-03-20" or other Date‑parsable strings
|
||||
* - Date instance
|
||||
*/
|
||||
export function formatLongDate(input: string | Date): string {
|
||||
if (!input) return "";
|
||||
|
||||
const date = input instanceof Date ? input : new Date(input);
|
||||
if (Number.isNaN(date.getTime())) return "";
|
||||
|
||||
return date.toLocaleDateString("en-GB", {
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
397
utils/editorjsToHtml.ts
Normal file
397
utils/editorjsToHtml.ts
Normal file
@@ -0,0 +1,397 @@
|
||||
/**
|
||||
* Convert EditorJS JSON data to HTML
|
||||
* Supports: header, paragraph, list, image, quote, marker, embed, code, table, delimiter,
|
||||
* checklist, link, inlineCode, warning, alert, raw, personality, and more
|
||||
*/
|
||||
|
||||
interface EditorJSBlockData {
|
||||
text?: string;
|
||||
level?: number;
|
||||
items?: string[] | Array<{ text?: string; checked?: boolean }>;
|
||||
style?: 'ordered' | 'unordered';
|
||||
file?: { url?: string };
|
||||
url?: string;
|
||||
caption?: string;
|
||||
withBorder?: boolean;
|
||||
withBackground?: boolean;
|
||||
stretched?: boolean;
|
||||
alignment?: 'left' | 'center' | 'right';
|
||||
service?: string;
|
||||
source?: string;
|
||||
embed?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
code?: string;
|
||||
language?: string;
|
||||
content?: string[][];
|
||||
link?: string;
|
||||
meta?: { url?: string; title?: string; description?: string; image?: string };
|
||||
title?: string;
|
||||
message?: string;
|
||||
html?: string;
|
||||
htmlCode?: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
photo?: string;
|
||||
linkText?: string;
|
||||
linkUrl?: string;
|
||||
type?: string;
|
||||
}
|
||||
|
||||
interface EditorJSBlock {
|
||||
id?: string;
|
||||
type: string;
|
||||
data: EditorJSBlockData;
|
||||
}
|
||||
|
||||
interface EditorJSData {
|
||||
time?: number;
|
||||
blocks: EditorJSBlock[];
|
||||
version?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert EditorJS block to HTML
|
||||
*/
|
||||
function blockToHtml(block: EditorJSBlock, baseUrl?: string): string {
|
||||
const { type, data } = block;
|
||||
|
||||
switch (type) {
|
||||
case 'header': {
|
||||
const level = data.level || 2;
|
||||
const text = escapeHtml(data.text || '');
|
||||
return `<h${level}>${text}</h${level}>`;
|
||||
}
|
||||
|
||||
case 'paragraph': {
|
||||
let text = data.text || '';
|
||||
// Convert markdown-style links to HTML
|
||||
text = text.replace(
|
||||
/\[([^\]]+)\]\(([^)]+)\)/g,
|
||||
'<a href="$2">$1</a>'
|
||||
);
|
||||
// Convert **bold** to <strong>
|
||||
text = text.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
|
||||
// Convert *italic* to <em>
|
||||
text = text.replace(/\*([^*]+)\*/g, '<em>$1</em>');
|
||||
// Convert `inline code` to <code>
|
||||
text = text.replace(/`([^`]+)`/g, '<code>$1</code>');
|
||||
// Convert line breaks
|
||||
text = text.replace(/\n/g, '<br>');
|
||||
return `<p>${text}</p>`;
|
||||
}
|
||||
|
||||
case 'list': {
|
||||
const items = data.items || [];
|
||||
const style = data.style || 'unordered';
|
||||
const tag = style === 'ordered' ? 'ol' : 'ul';
|
||||
const itemsHtml = items
|
||||
.map((item: { text?: string; checked?: boolean }) => {
|
||||
const escapedItem = escapeHtml(item.text || '');
|
||||
return `<li>${escapedItem}</li>`;
|
||||
})
|
||||
.join('');
|
||||
return `<${tag}>${itemsHtml}</${tag}>`;
|
||||
}
|
||||
|
||||
case 'image': {
|
||||
const url = data.file?.url || data.url || '';
|
||||
const caption = data.caption || '';
|
||||
const withBorder = data.withBorder || false;
|
||||
const withBackground = data.withBackground || false;
|
||||
const stretched = data.stretched || false;
|
||||
|
||||
let imageUrl = url;
|
||||
if (baseUrl && imageUrl && !imageUrl.startsWith('http')) {
|
||||
if (imageUrl.startsWith('/')) {
|
||||
imageUrl = `${baseUrl}${imageUrl}`;
|
||||
} else {
|
||||
imageUrl = `${baseUrl}/${imageUrl}`;
|
||||
}
|
||||
}
|
||||
|
||||
const classes: string[] = [];
|
||||
if (withBorder) classes.push('with-border');
|
||||
if (withBackground) classes.push('with-background');
|
||||
if (stretched) classes.push('stretched');
|
||||
|
||||
const classAttr = classes.length > 0 ? ` class="${classes.join(' ')}"` : '';
|
||||
const captionHtml = caption ? `<figcaption>${escapeHtml(caption)}</figcaption>` : '';
|
||||
|
||||
return `<figure${classAttr}>
|
||||
<img src="${escapeHtml(imageUrl)}" alt="${escapeHtml(caption)}" />
|
||||
${captionHtml}
|
||||
</figure>`;
|
||||
}
|
||||
|
||||
case 'quote': {
|
||||
const text = escapeHtml(data.text || '');
|
||||
const caption = escapeHtml(data.caption || '');
|
||||
const alignment = data.alignment || 'left';
|
||||
const captionHtml = caption ? `<cite>${caption}</cite>` : '';
|
||||
|
||||
return `<blockquote class="quote-${alignment}">
|
||||
<p>${text}</p>
|
||||
${captionHtml}
|
||||
</blockquote>`;
|
||||
}
|
||||
|
||||
case 'marker': {
|
||||
const text = data.text || '';
|
||||
// Marker typically highlights text, so we'll use <mark>
|
||||
return `<mark>${escapeHtml(text)}</mark>`;
|
||||
}
|
||||
|
||||
case 'embed': {
|
||||
const service = data.service || '';
|
||||
const embed = data.embed || '';
|
||||
const width = data.width || 600;
|
||||
const height = data.height || 400;
|
||||
const caption = data.caption || '';
|
||||
|
||||
if (service === 'youtube' || service === 'vimeo') {
|
||||
const captionHtml = caption ? `<figcaption>${escapeHtml(caption)}</figcaption>` : '';
|
||||
return `<figure class="embed-${service}">
|
||||
<iframe src="${escapeHtml(embed)}" width="${width}" height="${height}" frameborder="0" allowfullscreen></iframe>
|
||||
${captionHtml}
|
||||
</figure>`;
|
||||
}
|
||||
|
||||
// Fallback for other embed types
|
||||
return `<div class="embed embed-${service}">
|
||||
${embed}
|
||||
${caption ? `<p class="embed-caption">${escapeHtml(caption)}</p>` : ''}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
case 'code': {
|
||||
const code = data.code || '';
|
||||
const language = data.language || '';
|
||||
const langAttr = language ? ` class="language-${language}"` : '';
|
||||
return `<pre><code${langAttr}>${escapeHtml(code)}</code></pre>`;
|
||||
}
|
||||
|
||||
case 'delimiter': {
|
||||
return '<div class="delimiter">***</div>';
|
||||
}
|
||||
|
||||
case 'table': {
|
||||
const content = data.content || [];
|
||||
if (content.length === 0) return '';
|
||||
|
||||
// First row is usually header
|
||||
const headerRow = content[0] || [];
|
||||
const headerCells = headerRow
|
||||
.map((cell: string) => `<th>${escapeHtml(cell)}</th>`)
|
||||
.join('');
|
||||
|
||||
// Rest are body rows
|
||||
const bodyRows = content.slice(1)
|
||||
.map((row: string[]) => {
|
||||
const cells = row
|
||||
.map((cell: string) => `<td>${escapeHtml(cell)}</td>`)
|
||||
.join('');
|
||||
return `<tr>${cells}</tr>`;
|
||||
})
|
||||
.join('');
|
||||
|
||||
return `<table>
|
||||
${headerCells ? `<thead><tr>${headerCells}</tr></thead>` : ''}
|
||||
<tbody>${bodyRows}</tbody>
|
||||
</table>`;
|
||||
}
|
||||
|
||||
case 'checklist': {
|
||||
const items = data.items || [];
|
||||
const itemsHtml = items
|
||||
.map((item: { text?: string; checked?: boolean }) => {
|
||||
const text = escapeHtml(item.text || '');
|
||||
const checked = item.checked ? ' checked' : '';
|
||||
return `<div class="checklist-item">
|
||||
<input type="checkbox"${checked} disabled>
|
||||
<label>${text}</label>
|
||||
</div>`;
|
||||
})
|
||||
.join('');
|
||||
return `<div class="checklist">${itemsHtml}</div>`;
|
||||
}
|
||||
|
||||
case 'linkTool':
|
||||
case 'link': {
|
||||
const link = data.link || data.url || '';
|
||||
const meta = data.meta || {};
|
||||
const title = meta.title || data.title || link;
|
||||
const description = meta.description || '';
|
||||
const image = meta.image || '';
|
||||
|
||||
let imageHtml = '';
|
||||
if (image) {
|
||||
let imageUrl = image;
|
||||
if (baseUrl && imageUrl && !imageUrl.startsWith('http')) {
|
||||
imageUrl = imageUrl.startsWith('/') ? `${baseUrl}${imageUrl}` : `${baseUrl}/${imageUrl}`;
|
||||
}
|
||||
imageHtml = `<div class="link-image">
|
||||
<img src="${escapeHtml(imageUrl)}" alt="${escapeHtml(title)}" />
|
||||
</div>`;
|
||||
}
|
||||
|
||||
return `<div class="link-block">
|
||||
<a href="${escapeHtml(link)}" target="_blank" rel="noopener noreferrer">
|
||||
${imageHtml}
|
||||
<div class="link-content">
|
||||
<div class="link-title">${escapeHtml(title)}</div>
|
||||
${description ? `<div class="link-description">${escapeHtml(description)}</div>` : ''}
|
||||
<div class="link-url">${escapeHtml(link)}</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
case 'inlineCode': {
|
||||
const code = data.text || '';
|
||||
return `<code class="inline-code">${escapeHtml(code)}</code>`;
|
||||
}
|
||||
|
||||
case 'warning': {
|
||||
const title = data.title || 'Warning';
|
||||
const message = data.message || '';
|
||||
return `<div class="warning-block">
|
||||
<div class="warning-title">⚠️ ${escapeHtml(title)}</div>
|
||||
<div class="warning-message">${escapeHtml(message)}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
case 'alert': {
|
||||
const type = data.type || 'info';
|
||||
const title = data.title || '';
|
||||
const message = data.message || '';
|
||||
return `<div class="alert alert-${type}">
|
||||
${title ? `<div class="alert-title">${escapeHtml(title)}</div>` : ''}
|
||||
<div class="alert-message">${escapeHtml(message)}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
case 'raw': {
|
||||
const html = data.html || data.htmlCode || '';
|
||||
// Return raw HTML (be careful with XSS)
|
||||
return html;
|
||||
}
|
||||
|
||||
case 'personality': {
|
||||
const name = data.name || '';
|
||||
const description = data.description || '';
|
||||
const photo = data.photo || '';
|
||||
const linkText = data.linkText || '';
|
||||
const linkUrl = data.linkUrl || '';
|
||||
|
||||
let photoHtml = '';
|
||||
if (photo) {
|
||||
let photoUrl = photo;
|
||||
if (baseUrl && photoUrl && !photoUrl.startsWith('http')) {
|
||||
photoUrl = photoUrl.startsWith('/') ? `${baseUrl}${photoUrl}` : `${baseUrl}/${photoUrl}`;
|
||||
}
|
||||
photoHtml = `<div class="personality-photo">
|
||||
<img src="${escapeHtml(photoUrl)}" alt="${escapeHtml(name)}" />
|
||||
</div>`;
|
||||
}
|
||||
|
||||
const linkHtml = linkUrl && linkText
|
||||
? `<a href="${escapeHtml(linkUrl)}" class="personality-link">${escapeHtml(linkText)}</a>`
|
||||
: '';
|
||||
|
||||
return `<div class="personality-block">
|
||||
${photoHtml}
|
||||
<div class="personality-content">
|
||||
<div class="personality-name">${escapeHtml(name)}</div>
|
||||
${description ? `<div class="personality-description">${escapeHtml(description)}</div>` : ''}
|
||||
${linkHtml}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
default:
|
||||
// Unknown block type - return empty or log warning
|
||||
console.warn(`Unknown EditorJS block type: ${type}`);
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape HTML special characters
|
||||
*/
|
||||
function escapeHtml(text: string): string {
|
||||
const map: { [key: string]: string } = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": ''',
|
||||
};
|
||||
|
||||
return text.replace(/[&<>"']/g, (m) => map[m]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert EditorJS JSON data to HTML string
|
||||
* @param editorData - EditorJS JSON data (can be string or object)
|
||||
* @param baseUrl - Base URL for images (optional)
|
||||
* @returns HTML string
|
||||
*/
|
||||
export function editorjsToHtml(
|
||||
editorData: string | EditorJSData | null | undefined,
|
||||
baseUrl?: string
|
||||
): string {
|
||||
if (!editorData) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// If it's a string, try to parse it as JSON
|
||||
let parsedData: EditorJSData;
|
||||
if (typeof editorData === 'string') {
|
||||
try {
|
||||
parsedData = JSON.parse(editorData);
|
||||
} catch {
|
||||
// If parsing fails, assume it's already HTML
|
||||
return editorData;
|
||||
}
|
||||
} else {
|
||||
parsedData = editorData;
|
||||
}
|
||||
|
||||
// If it doesn't have blocks, assume it's already HTML
|
||||
if (!parsedData.blocks || !Array.isArray(parsedData.blocks)) {
|
||||
if (typeof editorData === 'string') {
|
||||
return editorData;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
// Convert each block to HTML
|
||||
const htmlBlocks = parsedData.blocks
|
||||
.map((block) => blockToHtml(block, baseUrl))
|
||||
.filter((html) => html.length > 0);
|
||||
|
||||
return htmlBlocks.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if content is EditorJS format
|
||||
*/
|
||||
export function isEditorJSFormat(
|
||||
content: string | EditorJSData | null | undefined
|
||||
): boolean {
|
||||
if (!content) return false;
|
||||
|
||||
try {
|
||||
const parsed =
|
||||
typeof content === 'string' ? JSON.parse(content) : content;
|
||||
return (
|
||||
parsed &&
|
||||
typeof parsed === 'object' &&
|
||||
Array.isArray(parsed.blocks) &&
|
||||
parsed.blocks.length > 0
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
28
utils/image.ts
Normal file
28
utils/image.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Build a safe image URL for assets coming from the CMS.
|
||||
*
|
||||
* Rules:
|
||||
* - If already a full URL (http/https) → return as is
|
||||
* - If starts with `/uploads/` → prepend API URL (NEXT_PUBLIC_API_URL or default localhost)
|
||||
* - If starts with `/` → use as-is (served by Next/public)
|
||||
* - Otherwise → treat as relative path under `/`
|
||||
*/
|
||||
export function getCmsImageUrl(imagePath: string | undefined): string {
|
||||
if (!imagePath) return "";
|
||||
|
||||
if (imagePath.startsWith("http://") || imagePath.startsWith("https://")) {
|
||||
return imagePath;
|
||||
}
|
||||
|
||||
if (imagePath.startsWith("/uploads/")) {
|
||||
const apiUrl = process.env.NEXT_PUBLIC_API_URL || "http://localhost:3001";
|
||||
return `${apiUrl}${imagePath}`;
|
||||
}
|
||||
|
||||
if (imagePath.startsWith("/")) {
|
||||
return imagePath;
|
||||
}
|
||||
|
||||
return `/${imagePath}`;
|
||||
}
|
||||
|
||||
3
utils/index.ts
Normal file
3
utils/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { editorjsToHtml, isEditorJSFormat } from "./editorjsToHtml";
|
||||
export { formatLongDate } from "./date";
|
||||
export { getCmsImageUrl } from "./image";
|
||||
20
utils/slugify.ts
Normal file
20
utils/slugify.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Simple slugify helper (Vietnamese-friendly) to match CMS behavior:
|
||||
* - lowercased
|
||||
* - diacritics removed (NFD)
|
||||
* - "đ" -> "d"
|
||||
* - strict: keep [a-z0-9 -] only, then collapse spaces/hyphens
|
||||
*/
|
||||
export function toSlug(input: string): string {
|
||||
return (input || "")
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.normalize("NFD")
|
||||
.replace(/[\u0300-\u036f]/g, "")
|
||||
.replace(/đ/g, "d")
|
||||
.replace(/[^a-z0-9\s-]/g, "")
|
||||
.replace(/\s+/g, "-")
|
||||
.replace(/-+/g, "-")
|
||||
.replace(/^-|-$/g, "");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user