feat: Refactor blog components and add pagination

This commit is contained in:
Wini_Fy
2026-02-03 17:05:09 +07:00
parent bf652a64b6
commit 29cc0bf2cd
27 changed files with 2051 additions and 429 deletions

21
utils/date.ts Normal file
View 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 Dateparsable 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
View 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 } = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#039;',
};
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
View 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
View File

@@ -0,0 +1,3 @@
export { editorjsToHtml, isEditorJSFormat } from "./editorjsToHtml";
export { formatLongDate } from "./date";
export { getCmsImageUrl } from "./image";

20
utils/slugify.ts Normal file
View 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, "");
}