no redux
This commit is contained in:
111
src/utils/categoryUtils.js
Normal file
111
src/utils/categoryUtils.js
Normal file
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* Pure category tree helper functions.
|
||||
* These operate on plain category maps and have no side-effects.
|
||||
*/
|
||||
|
||||
export const ROOT_CATEGORY_NAME = 'Home';
|
||||
|
||||
/**
|
||||
* @param {object} item
|
||||
* @returns {object}
|
||||
*/
|
||||
export function normalizeCategoryNode(item) {
|
||||
return {
|
||||
...item,
|
||||
id: item.id,
|
||||
parentId: item.parentId,
|
||||
name: item.name == null ? null : String(item.name).trim() || null,
|
||||
posterminalIds: Array.isArray(item.posterminalIds) ? item.posterminalIds : [],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the raw GET /api/category/tree response body into normalized state.
|
||||
* @param {object} data
|
||||
* @param {number|string|null} companyRootCategoryId
|
||||
*/
|
||||
export function parseCategoryTreeResponse(data, companyRootCategoryId) {
|
||||
const list = Array.isArray(data.tree) ? data.tree : [];
|
||||
const rootCategoryId = data.rootCategoryId != null ? data.rootCategoryId : companyRootCategoryId;
|
||||
|
||||
const categories = Object.fromEntries(
|
||||
list.map((item) => [String(item.id), normalizeCategoryNode(item)]),
|
||||
);
|
||||
|
||||
return {
|
||||
rootCategoryId,
|
||||
categories,
|
||||
rootArticles: Array.isArray(data.articles) ? data.articles : [],
|
||||
languageId: data.languageId,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Display name for a category node (root → "Home").
|
||||
*/
|
||||
export function categoryDisplayName(name, categoryId, rootCategoryId) {
|
||||
if (rootCategoryId != null && String(categoryId) === String(rootCategoryId)) {
|
||||
return ROOT_CATEGORY_NAME;
|
||||
}
|
||||
return name?.trim() || '';
|
||||
}
|
||||
|
||||
/**
|
||||
* URL slug for a category node (root → "Home").
|
||||
*/
|
||||
export function categorySlug(node, rootCategoryId) {
|
||||
if (rootCategoryId != null && String(node.id) === String(rootCategoryId)) {
|
||||
return ROOT_CATEGORY_NAME;
|
||||
}
|
||||
return node.slug || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Direct children of `parentId`, sorted by `sort` then `id`.
|
||||
*/
|
||||
export function getCategoryChildren(categories, parentId) {
|
||||
if (!categories) return [];
|
||||
const parentKey = String(parentId);
|
||||
return Object.values(categories)
|
||||
.filter((item) => item.parentId != null && String(item.parentId) === parentKey)
|
||||
.sort((a, b) => (a.sort ?? 0) - (b.sort ?? 0) || a.id - b.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Breadcrumb trail from root to `currentCategoryId`.
|
||||
*/
|
||||
export function getCategoryBreadcrumbs(categories, currentCategoryId, rootCategoryId) {
|
||||
if (!categories || currentCategoryId == null) return [];
|
||||
|
||||
const crumbs = [];
|
||||
const seen = new Set();
|
||||
let id = currentCategoryId;
|
||||
|
||||
while (id != null && !seen.has(String(id))) {
|
||||
seen.add(String(id));
|
||||
const node = categories[String(id)];
|
||||
if (!node) break;
|
||||
|
||||
crumbs.unshift({
|
||||
id: node.id,
|
||||
name: categoryDisplayName(node.name, node.id, rootCategoryId),
|
||||
slug: categorySlug(node, rootCategoryId),
|
||||
});
|
||||
|
||||
const parentId = node.parentId;
|
||||
if (parentId == null || parentId === 0) break;
|
||||
if (String(parentId) === String(id)) break;
|
||||
id = parentId;
|
||||
}
|
||||
|
||||
return crumbs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map of category.id → category node from a raw tree response.
|
||||
* @param {object} data
|
||||
* @param {number|string|null} companyRootCategoryId
|
||||
*/
|
||||
export function normalizeCategoryMap(data, companyRootCategoryId) {
|
||||
return parseCategoryTreeResponse(data, companyRootCategoryId).categories;
|
||||
}
|
||||
144
src/utils/textThumbnail.js
Normal file
144
src/utils/textThumbnail.js
Normal file
@@ -0,0 +1,144 @@
|
||||
let encodeAvif = null;
|
||||
|
||||
async function getAvifEncoder() {
|
||||
if (!encodeAvif) {
|
||||
const { default: encode } = await import('@jsquash/avif/encode');
|
||||
encodeAvif = encode;
|
||||
}
|
||||
return encodeAvif;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts an ArrayBuffer to a base64 string safely without stack overflow.
|
||||
* @param {ArrayBuffer} buffer
|
||||
* @returns {string}
|
||||
*/
|
||||
function arrayBufferToBase64(buffer) {
|
||||
const bytes = new Uint8Array(buffer);
|
||||
let binary = '';
|
||||
const chunkSize = 8192;
|
||||
for (let i = 0; i < bytes.length; i += chunkSize) {
|
||||
binary += String.fromCharCode.apply(
|
||||
null,
|
||||
bytes.subarray(i, i + chunkSize),
|
||||
);
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders text onto a 2D canvas styled like a document page thumbnail.
|
||||
* @param {string} text
|
||||
* @param {number} [width=200]
|
||||
* @param {number} [height=280]
|
||||
* @returns {HTMLCanvasElement}
|
||||
*/
|
||||
export function renderTextToCanvas(text = '', width = 200, height = 280) {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return canvas;
|
||||
|
||||
// Background
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
|
||||
// Subtle border
|
||||
ctx.strokeStyle = '#e2e8f0';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.strokeRect(0.5, 0.5, width - 1, height - 1);
|
||||
|
||||
// Top header accent line
|
||||
ctx.fillStyle = '#cbd5e1';
|
||||
ctx.fillRect(10, 8, 30, 2);
|
||||
|
||||
// Render text lines
|
||||
ctx.fillStyle = '#334155';
|
||||
ctx.font = '8px ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace';
|
||||
|
||||
const paddingX = 10;
|
||||
const startY = 22;
|
||||
const lineHeight = 11;
|
||||
const maxLines = Math.floor((height - startY - 8) / lineHeight);
|
||||
const maxWidth = width - paddingX * 2;
|
||||
|
||||
const rawLines = String(text || '').split(/\r?\n/);
|
||||
const formattedLines = [];
|
||||
|
||||
for (const line of rawLines) {
|
||||
if (formattedLines.length >= maxLines) break;
|
||||
const clean = line.replace(/\t/g, ' ');
|
||||
if (!clean.trim()) {
|
||||
formattedLines.push('');
|
||||
continue;
|
||||
}
|
||||
|
||||
// Wrap or clip long lines
|
||||
let current = '';
|
||||
for (let i = 0; i < clean.length; i++) {
|
||||
const candidate = current + clean[i];
|
||||
if (ctx.measureText(candidate).width > maxWidth) {
|
||||
formattedLines.push(current);
|
||||
current = clean[i];
|
||||
if (formattedLines.length >= maxLines) break;
|
||||
} else {
|
||||
current = candidate;
|
||||
}
|
||||
}
|
||||
if (current && formattedLines.length < maxLines) {
|
||||
formattedLines.push(current);
|
||||
}
|
||||
}
|
||||
|
||||
for (let idx = 0; idx < formattedLines.length; idx++) {
|
||||
const y = startY + idx * lineHeight;
|
||||
ctx.fillText(formattedLines[idx], paddingX, y);
|
||||
}
|
||||
|
||||
return canvas;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates an AVIF thumbnail data URL for text content.
|
||||
* @param {string|File|Blob} textSource
|
||||
* @param {{ width?: number, height?: number, quality?: number }} [options]
|
||||
* @returns {Promise<string|null>} Base64 Data URL (data:image/avif;base64,... or data:image/png;base64,...)
|
||||
*/
|
||||
export async function generateTextThumbnail(textSource, { width = 200, height = 280, quality = 70 } = {}) {
|
||||
let text = '';
|
||||
if (textSource instanceof Blob) {
|
||||
text = await textSource.text();
|
||||
} else if (typeof textSource === 'string') {
|
||||
text = textSource;
|
||||
} else if (textSource != null) {
|
||||
text = String(textSource);
|
||||
}
|
||||
|
||||
if (typeof document === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const canvas = renderTextToCanvas(text, width, height);
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return null;
|
||||
|
||||
try {
|
||||
const imageData = ctx.getImageData(0, 0, width, height);
|
||||
const encoder = await getAvifEncoder();
|
||||
const avifBuffer = await encoder(imageData, {
|
||||
quality,
|
||||
speed: 9,
|
||||
});
|
||||
const base64 = arrayBufferToBase64(avifBuffer);
|
||||
return `data:image/avif;base64,${base64}`;
|
||||
} catch (err) {
|
||||
console.warn('generateTextThumbnail: @jsquash/avif encode failed, falling back to PNG:', err);
|
||||
try {
|
||||
return canvas.toDataURL('image/png');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user