This commit is contained in:
seb
2026-09-22 07:20:31 +02:00
parent 536e7b1e2a
commit 5e13b8f9cd
32 changed files with 2485 additions and 1908 deletions

111
src/utils/categoryUtils.js Normal file
View 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
View 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;
}
}
}