@underpostnet/underpost
Version:
Underpost Platform — end-to-end CI/CD and application-delivery toolchain CLI. Covers bare metal, Kubernetes, K3s, kubeadm, LXD, container/image orchestration, secrets, databases, cron jobs, monitoring, SSH, runners, PWA + Workbox delivery, and release orc
916 lines (915 loc) • 40.5 kB
JavaScript
import { getCapVariableName, newInstance, random, range, timer, uniqueArray } from './CommonJs.js';
import { marked } from 'marked';
import { append, getBlobFromUint8ArrayFile, getDataFromInputFile, getRawContentFile, htmls, s } from './VanillaJs.js';
import { Panel } from './Panel.js';
import { NotificationManager } from './NotificationManager.js';
import { DocumentService } from '../../services/document/document.service.js';
import { FileService } from '../../services/file/file.service.js';
import { getSrcFromFileData } from './Input.js';
import { imageShimmer, renderCssAttr, darkTheme, ThemeEvents, subThemeManager, lightenHex, darkenHex } from './Css.js';
import { Translate } from './Translate.js';
import { Modal } from './Modal.js';
import { closeModalRouteChangeEvents, listenQueryPathInstance, setQueryPath, getQueryParams } from './Router.js';
import { Scroll } from './Scroll.js';
import { LoadingAnimation } from './LoadingAnimation.js';
import { loggerFactory } from './Logger.js';
import { getApiBaseUrl } from '../../services/core/core.service.js';
const logger = loggerFactory(import.meta, { trace: true });
function sanitizeFilename(title, options = {}) {
const { replacement = '-', maxLength = 255, preserveExtension = true } = options;
if (typeof title !== 'string' || title.trim() === '') {
return 'untitled';
}
// 1) Extract extension (optional)
let name = title;
let ext = '';
if (preserveExtension) {
const match = title.match(/(\.[^.\s]{1,10})$/u);
if (match) {
ext = match[1];
name = title.slice(0, -ext.length);
}
}
// 2) Normalize Unicode and remove diacritics
name = name.normalize('NFKD').replace(/[\u0300-\u036f]/g, '');
// 3) Remove control characters and null bytes
name = name.replace(/[\x00-\x1f\x7f]/g, '');
// 4) Remove forbidden filename characters (Windows / POSIX)
name = name.replace(/[<>:"/\\|?*\u0000]/g, '');
// 5) Collapse whitespace and replace with separator
name = name.replace(/\s+/g, replacement);
// 6) Collapse multiple separators
const escaped = replacement.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&');
name = name.replace(new RegExp(`${escaped}{2,}`, 'g'), replacement);
// 7) Trim dots and separators from edges
name = name.replace(new RegExp(`^[\\.${escaped}]+|[\\.${escaped}]+$`, 'g'), '');
// 8) Protect against Windows reserved names
if (/^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i.test(name)) {
name = '_' + name;
}
// 9) Enforce max length
const maxNameLength = Math.max(1, maxLength - ext.length);
if (name.length > maxNameLength) {
name = name.slice(0, maxNameLength);
}
// 10) Fallback
if (!name) name = 'untitled';
return name + ext;
}
const userInfoFactory = (userDoc) => ({
username: userDoc.userId.username,
email: userDoc.userId.email,
_id: userDoc.userId._id,
profileImageId: userDoc.userId.profileImageId,
briefDescription: userDoc.userId.briefDescription,
});
class PanelForm {
static Data = {};
static async instance(
options = {
idPanel: '',
defaultUrlImage: '',
appStore: {},
parentIdModal: undefined,
route: 'home',
htmlFormHeader: async () => '',
firsUpdateEvent: async () => {},
share: {
copyLink: false,
copySourceMd: false,
},
showCreatorProfile: false,
},
) {
const { idPanel, defaultUrlImage, appStore } = options;
// Authenticated users don't need 'public' tag - they see all their own posts
// Only include 'public' for unauthenticated users (handled by backend)
let prefixTags = [idPanel];
PanelForm.Data[idPanel] = {
originData: [],
data: [],
filesData: [],
skip: 0,
limit: 3, // Load 5 items per page
hasMore: true,
loading: false,
lasIdAvailable: null,
};
const formData = [
{
id: 'panel-title',
model: 'title',
inputType: 'text',
rules: [],
panel: { type: 'title' },
},
{
id: 'panel-createdAt',
model: 'createdAt',
inputType: 'datetime-local',
panel: { type: 'subtitle' },
rules: [{ type: 'isEmpty' }],
disableRender: true,
},
{
id: 'panel-fileId',
model: 'fileId',
inputType: 'file',
rules: [],
panel: {},
},
{
id: 'panel-tags',
model: 'tags',
label: {
disabled: true,
},
inputType: 'text',
panel: { type: 'tags' },
// panel: {
// type: 'info-row-pin',
// icon: {
// value: html``,
// },
// },
rules: [],
},
{
id: 'panel-mdFileId',
model: 'mdFileId',
inputType: 'md',
panel: { type: 'info-row' },
rules: [],
label: {
disabled: true,
},
},
];
const titleIcon = html`<i class="fa-solid fa-quote-left title-icon-${idPanel}"></i>`;
const panelRender = async ({ data }) =>
await Panel.instance({
idPanel,
formData,
data,
htmlFormHeader: options.htmlFormHeader,
parentIdModal: options.parentIdModal,
originData: () => PanelForm.Data[idPanel].originData,
filesData: () => PanelForm.Data[idPanel].filesData,
scrollClassContainer: options.scrollClassContainer ? options.scrollClassContainer : 'main-body',
titleIcon,
route: options.route,
formContainerClass: 'session-in-log-in',
share: options.share,
showCreatorProfile: options.showCreatorProfile,
onClick: async function ({ payload }) {
if (options.route) {
setQueryPath({ path: options.route, queryPath: payload._id });
if (options.parentIdModal) Modal.Data[options.parentIdModal].query = `${window.location.search}`;
if (PanelForm.Data[idPanel].updatePanel) await PanelForm.Data[idPanel].updatePanel();
}
},
callBackPanelRender: async function (options) {
if (options.data.ssr) {
return await options.htmlRender({
render: imageShimmer(),
});
}
// Get the filesData for this item
const filesDataItem = PanelForm.Data[idPanel].filesData.find((f) => f._id === options.data._id);
// Priority 1: Check if there's an actual file (not markdown content)
// fileId array defaults to [null] for batch upload logic
const fileBlob = filesDataItem?.fileId?.fileBlob;
if (fileBlob) {
return await options.fileRender({
file: fileBlob,
style: {
overflow: 'auto',
width: '100%',
height: 'auto',
},
});
}
// Priority 2: If no actual file, show default image
// (Don't show markdown content in file area - mdFileId stays in content area)
return await options.htmlRender({
render: html`
<img
class="abs center"
style="${renderCssAttr({
style: {
width: '100px',
height: '100px',
opacity: 0.2,
},
})}"
src="${defaultUrlImage}"
/>
`,
});
},
on: {
remove: async function ({ e, data }) {
e.preventDefault();
const confirmResult = await Modal.RenderConfirm({
html: async () => {
return html`
<div class="in section-mp" style="text-align: center">
${Translate.instance('confirm-delete-item')}
<br />
"${data.title}"
</div>
`;
},
id: `delete-${idPanel}`,
});
if (confirmResult.status === 'confirm') {
const { status, message } = await DocumentService.delete({
id: data._id,
});
NotificationManager.Push({
html: status,
status,
});
// Handle cid query param update (supports comma-separated list)
if (status === 'success') {
const currentCid = getQueryParams().cid;
if (currentCid) {
// Parse cid as comma-separated list
const cidList = currentCid
.split(',')
.map((id) => id.trim())
.filter((id) => id);
// Remove the deleted panel's id from the list
const updatedCidList = cidList.filter((id) => id !== data.id);
if (updatedCidList.length !== cidList.length) {
// Wait for DOM cleanup before updating query
if (updatedCidList.length === 0) {
// No cids remain, clear query and reload panels with limit
logger.warn('All cids removed, clearing query');
setQueryPath({ path: options.route, queryPath: '' });
if (options.parentIdModal) Modal.Data[options.parentIdModal].query = window.location.search;
if (PanelForm.Data[idPanel].updatePanel) await PanelForm.Data[idPanel].updatePanel();
} else {
// Update query params with remaining cids only (without ?cid= prefix)
const cidValue = updatedCidList.join(',');
setQueryPath({ path: options.route, queryPath: cidValue });
const actualQuery = window.location.search;
if (options.parentIdModal) Modal.Data[options.parentIdModal].query = actualQuery;
}
}
// Return early to skip smart deletion logic when cid is present
return { status };
}
}
// Smart deletion: remove from arrays and intelligently load more if needed
if (status === 'success') {
const panelData = PanelForm.Data[idPanel];
// Remove the deleted item from all data arrays
const indexInOrigin = panelData.originData.findIndex((d) => d._id === data._id);
const indexInData = panelData.data.findIndex((d) => d._id === data._id);
const indexInFiles = panelData.filesData.findIndex((d) => d._id === data._id);
if (indexInOrigin > -1) panelData.originData.splice(indexInOrigin, 1);
if (indexInData > -1) panelData.data.splice(indexInData, 1);
if (indexInFiles > -1) panelData.filesData.splice(indexInFiles, 1);
// Adjust skip count since we removed an item
if (panelData.skip > 0) panelData.skip--;
// If panels are below limit and there might be more, load them
if (panelData.data.length < panelData.limit && panelData.hasMore && !panelData.loading) {
const oldDataCount = panelData.data.length;
const needed = panelData.limit - panelData.data.length; // Calculate exact number needed
const originalLimit = panelData.limit;
// Temporarily set limit to only fetch what's needed (1-to-1 replacement)
panelData.limit = needed;
await getPanelData(true); // Load only the needed items
panelData.limit = originalLimit; // Restore original limit
const newItems = panelData.data.slice(oldDataCount);
if (oldDataCount === 0) {
// List was empty, render all panels
if (panelData.data.length > 0) {
const containerSelector = `.${options.parentIdModal ? 'html-' + options.parentIdModal : 'main-body'}`;
htmls(
containerSelector,
html`
<div class="in">${await panelRender({ data: panelData.data })}</div>
<div class="in panel-placeholder-bottom panel-placeholder-bottom-${idPanel}"></div>
`,
);
// Show spinner if there's potentially more data
const lastOriginItem = panelData.originData[panelData.originData.length - 1];
if (
!panelData.lasIdAvailable ||
!lastOriginItem ||
panelData.lasIdAvailable !== lastOriginItem._id
)
LoadingAnimation.spinner.play(`.panel-placeholder-bottom-${idPanel}`, 'dual-ring-mini');
} else {
// No more data available, show empty state
const containerSelector = `.${options.parentIdModal ? 'html-' + options.parentIdModal : 'main-body'}`;
htmls(
containerSelector,
html`
<div class="in">${await panelRender({ data: [] })}</div>
<div class="in panel-placeholder-bottom panel-placeholder-bottom-${idPanel}"></div>
`,
);
}
} else {
// List had some panels, append new ones
if (newItems.length > 0) {
for (const item of newItems)
append(`.${idPanel}-render`, await Panel.Tokens[idPanel].renderPanel(item));
}
}
}
}
return { status };
}
return { status: 'error' };
},
initAdd: async function () {
setTimeout(() => {
s(`.modal-${options.route}`).scrollTo({ top: 0, behavior: 'smooth' });
}, 50);
},
initEdit: async function ({ data }) {
// Do NOT clear the file input here - the file should remain as-is when entering edit mode.
// If user wants to remove the file, they use the "clean file" button.
// If user wants to replace the file, they select a new file.
// Unconditionally clearing the file here would cause the server to receive fileId: null on save.
setTimeout(() => {
s(`.modal-${options.route}`).scrollTo({ top: 0, behavior: 'smooth' });
}, 50);
},
noResultFound: async function () {
LoadingAnimation.spinner.stop(`.panel-placeholder-bottom-${idPanel}`);
},
add: async function ({ data, editId }) {
// Validate that either mdFileId has content OR fileId has files
const hasMdContent = data.mdFileId && data.mdFileId.trim().length > 0;
const hasFiles = data.fileId && data.fileId.length > 0;
if (!data.title || (!hasMdContent && !hasFiles)) {
NotificationManager.Push({
html: Translate.instance('require-title-and-content-or-file'),
status: 'error',
});
return { data: [], status: 'error', message: 'Must provide either content or attach a file' };
}
// Sanitize title for filename - normalize UTF-8 string
// In browser, strings are already UTF-16, just ensure valid characters
const sanitizedTitle = sanitizeFilename(data.title);
let mdFileId;
const mdFileName = `${getCapVariableName(sanitizedTitle)}.md`;
const location = `${prefixTags.join('/')}`;
// Only create markdown file if there's actual content
let md = null;
let mdBlob = null;
let mdPlain = null;
if (hasMdContent) {
// Markdown content is already UTF-16 in browser, use as-is
const blob = new Blob([data.mdFileId], { type: 'text/markdown' });
md = new File([blob], mdFileName, { type: 'text/markdown' });
mdBlob = {
data: {
data: await getDataFromInputFile(md),
},
mimetype: md.type,
name: md.name,
};
mdPlain = await getRawContentFile(getBlobFromUint8ArrayFile(mdBlob.data.data, mdBlob.mimetype));
}
// Parse and normalize tags
// Note: 'public' tag is automatically extracted by the backend and converted to isPublic field
// It will be filtered from the tags array to keep visibility control separate from content tags
const tags = data.tags
? uniqueArray(
data.tags
.replaceAll('/', ',')
.replaceAll('-', ',')
.replaceAll(' ', ',')
.split(',')
.map((t) => t.trim())
.filter((t) => t)
.concat(prefixTags),
)
: prefixTags;
let originObj, originFileObj, indexOriginObj;
if (editId) {
indexOriginObj = PanelForm.Data[idPanel].originData.findIndex((d) => d._id === editId);
if (indexOriginObj > -1) {
originObj = PanelForm.Data[idPanel].originData[indexOriginObj];
originFileObj = PanelForm.Data[idPanel].filesData.find((d) => d._id === editId);
}
}
const baseNewDoc = newInstance(data);
baseNewDoc.tags = tags.filter((t) => !prefixTags.includes(t));
baseNewDoc.mdFileId = hasMdContent
? `<div class="markdown-content">${marked.parse(data.mdFileId)}</div>`
: null;
baseNewDoc.userId = appStore.Data.user?.main?.model?.user?._id;
// Ensure profileImageId is properly formatted as object with _id property
const profileImageIdValue = appStore.Data.user?.main?.model?.user?.profileImageId;
const formattedProfileImageId = profileImageIdValue
? typeof profileImageIdValue === 'string'
? { _id: profileImageIdValue }
: profileImageIdValue
: null;
baseNewDoc.userInfo = {
username: appStore.Data.user?.main?.model?.user?.username,
email: appStore.Data.user?.main?.model?.user?.email,
_id: appStore.Data.user?.main?.model?.user?._id,
profileImageId: formattedProfileImageId,
};
baseNewDoc.tools = true;
const documents = [];
let message = '';
let status = 'success';
let indexFormDoc = -1;
const inputFiles = data.fileId ? data.fileId : [null];
for (const file of inputFiles) {
indexFormDoc++;
let fileId = undefined; // Reset for each iteration - only set if user uploaded a file
// Track whether the file input was explicitly cleared (null) vs never had a file (undefined)
// In edit mode, null means user cleared the file - we need to tell server to remove it
const isFileCleared = data.fileId === null && editId;
await (async () => {
// When file is null, no markdown content, and not clearing a file, skip upload
if (!file && !isFileCleared && !hasMdContent) return;
// When user cleared file in edit mode, set fileId=null so server removes the reference
if (isFileCleared) {
fileId = null;
return;
}
const body = new FormData();
// Only append md file if it was created (has content)
if (md) body.append('md', md);
if (file) body.append('file', file);
const { status, data: uploadedFiles } = await FileService.post({ body });
// await timer(3000);
NotificationManager.Push({
html: Translate.instance(`${status}-upload-file`),
status,
});
if (status === 'success' && uploadedFiles && Array.isArray(uploadedFiles)) {
// CRITICAL DIFFERENTIATION:
// - mdFileId: markdown file GENERATED FROM rich text editor content
// - fileId: file UPLOADED BY USER (could be .md, .pdf, image, etc.)
//
// Both can be markdown files, but we must distinguish:
// Rich text editor content → mdFileId
// User-uploaded file → fileId
for (const uploadedFile of uploadedFiles) {
if (hasMdContent && uploadedFile.name === mdFileName) {
// This is the markdown file created FROM rich text editor
mdFileId = uploadedFile._id;
logger.info(`Assigned rich text markdown to mdFileId: ${mdFileName}`);
} else if (!hasMdContent || uploadedFile.name !== mdFileName) {
// This is a file uploaded by user (even if it's an .md file)
fileId = uploadedFile._id;
logger.info(`Assigned user-uploaded file to fileId: ${uploadedFile.name}`);
}
}
// Validation: mdFileId should exist only if rich text content was provided
if (hasMdContent && !mdFileId) {
logger.error(
`ERROR: No markdown content file found. Expected: ${mdFileName}, Got: ${uploadedFiles.map((f) => f.name).join(', ')}`,
);
}
}
})();
// Backend will automatically extract 'public' from tags and set isPublic field
const body = {
location,
tags,
fileId,
mdFileId,
title: data.title,
};
const {
status: documentStatus,
message: documentMessage,
data: documentData,
} = originObj && indexFormDoc === 0
? await DocumentService.put({ id: originObj._id, body })
: await DocumentService.post({
body,
});
const newDoc = {
...baseNewDoc,
fileId: file ? URL.createObjectURL(file) : undefined,
_id: documentData._id,
id: documentData._id,
createdAt: documentData.createdAt,
// Use server response data - backend has already processed tags and isPublic
isPublic: documentData.isPublic || false,
tags: (documentData.tags || []).filter((t) => !prefixTags.includes(t)),
// Ensure userInfo is present for profile header rendering
userInfo:
baseNewDoc.userInfo ||
(documentData.userId && typeof documentData.userId === 'object'
? userInfoFactory(documentData)
: null),
};
if (documentStatus === 'error') status = 'error';
if (message) message += `${indexFormDoc === 0 ? '' : ', '}${documentMessage}`;
const filesData = {
id: documentData._id,
_id: documentData._id,
mdFileId: mdBlob && mdPlain ? { mdBlob, mdPlain } : null,
fileId: {
fileBlob: file
? {
data: {
data: await getDataFromInputFile(file),
},
mimetype: file.type,
name: file.name,
}
: undefined,
filePlain: undefined,
},
};
if (originObj && indexFormDoc === 0) {
PanelForm.Data[idPanel].originData[indexOriginObj] = documentData;
PanelForm.Data[idPanel].data[indexOriginObj] = newDoc;
PanelForm.Data[idPanel].filesData[indexOriginObj] = filesData;
} else {
PanelForm.Data[idPanel].originData.push(documentData);
PanelForm.Data[idPanel].data.push(newDoc);
PanelForm.Data[idPanel].filesData.push(filesData);
}
documents.push(newDoc);
}
NotificationManager.Push({
html:
status === 'success'
? originObj
? Translate.instance('success-edit-post')
: Translate.instance('success-add-post')
: message,
status: status,
});
setQueryPath({ path: options.route, queryPath: documents.map((d) => d._id).join(',') });
if (options.parentIdModal) Modal.Data[options.parentIdModal].query = `${window.location.search}`;
return { data: documents, status, message };
},
},
});
const getPanelData = async (isLoadMore = false) => {
const panelData = PanelForm.Data[idPanel];
logger.warn('getPanelData called, isLoadMore:', isLoadMore);
try {
const cidQuery = getQueryParams().cid;
// When cid query exists, bypass pagination and loading checks
if (!cidQuery) {
if (panelData.loading || !panelData.hasMore) {
logger.warn('getPanelData early return - loading:', panelData.loading, 'hasMore:', panelData.hasMore);
return;
}
}
panelData.loading = true;
if (!isLoadMore) {
// Reset for a fresh load
panelData.skip = 0;
panelData.hasMore = true;
}
// When cid query exists, don't apply skip/limit pagination
const params = {
tags: prefixTags.join(','),
...(cidQuery && { cid: cidQuery }),
};
// Only apply pagination when there's no cid query
if (!cidQuery) {
params.skip = panelData.skip;
params.limit = panelData.limit;
}
const result = await DocumentService.get({
params,
id: 'public/',
});
if (result.status === 'success') {
if (!isLoadMore) {
panelData.originData = [];
panelData.filesData = [];
panelData.data = [];
}
panelData.originData.push(...newInstance(result.data.data));
panelData.lasIdAvailable = result.data.lastId;
for (const documentObject of result.data.data) {
let mdFileId, fileId;
let mdBlob, fileBlob;
let mdPlain, filePlain;
let parsedMarkdown = '';
try {
// Fetch markdown content if mdFileId exists
if (documentObject.mdFileId) {
const mdFileIdValue = documentObject.mdFileId._id || documentObject.mdFileId;
try {
// Get markdown content from blob endpoint using FileService
const { data: blobArray, status } = await FileService.get({ id: `blob/${mdFileIdValue}` });
if (status === 'success' && blobArray && blobArray[0]) {
mdPlain = await blobArray[0].text();
// Parse markdown with proper error handling
try {
parsedMarkdown = mdPlain ? `<div class="markdown-content">${marked.parse(mdPlain)}</div>` : '';
} catch (parseError) {
logger.error('Error parsing markdown for document:', documentObject._id, parseError);
parsedMarkdown = `<p><strong>Error rendering markdown:</strong> ${parseError.message}</p>`;
}
} else {
logger.warn('Failed to fetch markdown blob content');
parsedMarkdown = '';
}
} catch (fetchError) {
logger.error('Error fetching markdown content:', mdFileIdValue, fetchError);
parsedMarkdown = '';
}
}
// Handle optional fileId
if (documentObject.fileId) {
const fileIdValue = documentObject.fileId._id || documentObject.fileId;
try {
// Get file metadata for display
const { data: fileArray } = await FileService.get({ id: fileIdValue });
if (fileArray && fileArray[0]) {
fileBlob = fileArray[0];
fileId = getSrcFromFileData(fileArray[0]);
}
} catch (fetchError) {
logger.error('Error fetching file metadata:', fileIdValue, fetchError);
}
}
// Store file metadata and references
panelData.filesData.push({
id: documentObject._id,
_id: documentObject._id,
mdFileId: { mdBlob, mdPlain },
fileId: { fileBlob, filePlain },
});
// Add to data array for display - use pre-parsed markdown
panelData.data.push({
id: documentObject._id,
title: documentObject.title,
createdAt: documentObject.createdAt,
// Backend filters 'public' tag automatically - it's converted to isPublic field
tags: documentObject.tags.filter((t) => !prefixTags.includes(t)),
mdFileId: parsedMarkdown,
userId:
documentObject.userId && typeof documentObject.userId === 'object'
? documentObject.userId._id
: documentObject.userId,
userInfo:
documentObject.userId && typeof documentObject.userId === 'object'
? userInfoFactory(documentObject)
: null,
fileId,
tools:
documentObject.userId &&
typeof documentObject.userId === 'object' &&
appStore.Data.user?.main?.model?.user?._id &&
documentObject.userId._id === appStore.Data.user.main.model.user._id,
_id: documentObject._id,
totalCopyShareLinkCount: documentObject.totalCopyShareLinkCount || 0,
isPublic: documentObject.isPublic || false,
});
} catch (fileError) {
logger.error('Error processing files for document:', documentObject._id, fileError);
// Still add the document to originData even if file fetching fails
// Add minimal data without file references
panelData.filesData.push({
id: documentObject._id,
_id: documentObject._id,
mdFileId: { mdBlob: null, mdPlain: '' },
fileId: { fileBlob: null, filePlain: undefined },
});
panelData.data.push({
id: documentObject._id,
title: documentObject.title,
createdAt: documentObject.createdAt,
tags: documentObject.tags.filter((t) => !prefixTags.includes(t)),
mdFileId: '',
userId:
documentObject.userId && typeof documentObject.userId === 'object'
? documentObject.userId._id
: documentObject.userId,
userInfo:
documentObject.userId && typeof documentObject.userId === 'object'
? userInfoFactory(documentObject)
: null,
fileId: null,
tools:
documentObject.userId &&
typeof documentObject.userId === 'object' &&
appStore.Data.user?.main?.model?.user?._id &&
documentObject.userId._id === appStore.Data.user.main.model.user._id,
_id: documentObject._id,
totalCopyShareLinkCount: documentObject.totalCopyShareLinkCount || 0,
isPublic: documentObject.isPublic || false,
});
}
}
// Only update pagination when not using cid query
if (!cidQuery) {
panelData.skip += result.data.data.length;
panelData.hasMore = result.data.data.length === panelData.limit;
} else {
// When cid query is used, disable infinite scroll
panelData.hasMore = false;
}
const lastItem = result.data.data[result.data.data.length - 1];
if (result.data.data.length === 0 || (lastItem && lastItem._id === panelData.lasIdAvailable)) {
LoadingAnimation.spinner.stop(`.panel-placeholder-bottom-${idPanel}`);
panelData.hasMore = false;
}
} else {
NotificationManager.Push({
html: result.message,
status: result.status,
});
panelData.hasMore = false;
}
} catch (error) {
logger.error(error);
}
await timer(250);
panelData.loading = false;
LoadingAnimation.spinner.stop(`.panel-placeholder-bottom-${idPanel}`);
};
const renderSrrPanelData = async () =>
await panelRender({
data: range(0, 0).map((i) => ({
id: i,
title: html`<div class="fl">
<div
class="in fll ssr-shimmer-search-box"
style="${renderCssAttr({
style: {
width: '80%',
height: '30px',
top: '-13px',
left: '10px',
},
})}"
></div>
</div>`,
createdAt: html`<div class="fl">
<div
class="in fll ssr-shimmer-search-box"
style="${renderCssAttr({
style: {
width: '50%',
height: '30px',
left: '-5px',
},
})}"
></div>
</div>`,
mdFileId: html`<div class="fl section-mp">
<div
class="in fll ssr-shimmer-search-box"
style="${renderCssAttr({
style: {
width: '80%',
height: '30px',
},
})}"
></div>
</div>`.repeat(random(2, 4)),
ssr: true,
})),
});
let firsUpdateEvent = false;
let lastCid;
let lastUserId;
let loadingGetData = false;
closeModalRouteChangeEvents[idPanel] = () => {
setTimeout(() => {
PanelForm.Data[idPanel].updatePanel();
});
};
PanelForm.Data[idPanel].updatePanel = async (...args) => {
const _updatePanel = async (...args) => {
try {
const cid = getQueryParams().cid ? getQueryParams().cid : '';
const forceUpdate =
appStore.Data.user.main.model &&
appStore.Data.user.main.model.user &&
appStore.Data.user.main.model.user._id &&
lastUserId !== appStore.Data.user.main.model.user._id;
logger.warn(
{
idPanel,
cid,
forceUpdate,
},
appStore.Data.user?.main?.model?.user
? JSON.stringify(appStore.Data.user.main.model.user, null, 4)
: 'No user data',
);
// Normalize empty values for comparison (undefined, null, '' should all be treated as empty)
const normalizedCid = cid || '';
const normalizedLastCid = lastCid || '';
if (loadingGetData || (normalizedLastCid === normalizedCid && !forceUpdate)) return;
loadingGetData = true;
lastUserId = appStore.Data.user?.main?.model?.user?._id
? newInstance(appStore.Data.user.main.model.user._id)
: null;
lastCid = cid;
logger.warn('instance render panel data');
PanelForm.Data[idPanel] = {
...PanelForm.Data[idPanel],
originData: [],
data: [],
filesData: [],
limit: 3, // Load 5 items per page
hasMore: true,
loading: false,
};
// Always reset skip to 0 when reloading (whether cid exists or not)
PanelForm.Data[idPanel].skip = 0;
const containerSelector = `.${options.parentIdModal ? 'html-' + options.parentIdModal : 'main-body'}`;
htmls(containerSelector, await renderSrrPanelData());
await getPanelData();
htmls(
containerSelector,
html`
<div class="in">${await panelRender({ data: PanelForm.Data[idPanel].data })}</div>
<div class="in panel-placeholder-bottom panel-placeholder-bottom-${idPanel}"></div>
`,
);
const lastOriginItem = PanelForm.Data[idPanel].originData[PanelForm.Data[idPanel].originData.length - 1];
if (
!PanelForm.Data[idPanel].lasIdAvailable ||
!lastOriginItem ||
PanelForm.Data[idPanel].lasIdAvailable !== lastOriginItem._id
)
LoadingAnimation.spinner.play(`.panel-placeholder-bottom-${idPanel}`, 'dual-ring-mini');
const scrollContainerSelector = `.modal-${options.route}`;
// Always remove old scroll event before setting new one
if (PanelForm.Data[idPanel].removeScrollEvent) {
PanelForm.Data[idPanel].removeScrollEvent();
}
if (cid) {
LoadingAnimation.spinner.stop(`.panel-placeholder-bottom-${idPanel}`);
return;
}
const { removeEvent } = Scroll.setEvent(scrollContainerSelector, async (payload) => {
const panelData = PanelForm.Data[idPanel];
if (!panelData) return;
// Infinite scroll: load more items at bottom
if (payload.atBottom && panelData.hasMore && !panelData.loading) {
const oldDataCount = panelData.data.length;
await getPanelData(true); // isLoadMore = true
const newItems = panelData.data.slice(oldDataCount);
if (newItems.length > 0) {
for (const item of newItems)
append(`.${idPanel}-render`, await Panel.Tokens[idPanel].renderPanel(item));
}
}
});
PanelForm.Data[idPanel].removeScrollEvent = removeEvent;
if (!firsUpdateEvent && options.firsUpdateEvent) {
firsUpdateEvent = true;
await options.firsUpdateEvent();
}
} catch (error) {
logger.error(error);
}
};
await _updatePanel(...args);
loadingGetData = false;
};
if (options.route) {
listenQueryPathInstance({
id: options.parentIdModal ? 'html-' + options.parentIdModal : 'main-body',
routeId: options.route,
event: async (path) => {
// Don't manually clear arrays - updatePanel() will handle it if needed
await PanelForm.Data[idPanel].updatePanel();
},
});
if (!options.parentIdModal)
Modal.Data['modal-menu'].onHome[idPanel] = async () => {
lastCid = undefined;
lastUserId = undefined;
PanelForm.Data[idPanel] = {
...PanelForm.Data[idPanel],
originData: [],
data: [],
filesData: [],
skip: 0,
limit: 3, // Load 5 items per page
hasMore: true,
loading: false,
};
setQueryPath({ path: options.route, queryPath: options.route === 'home' ? '?' : '' });
await PanelForm.Data[idPanel].updatePanel();
};
}
if (options.parentIdModal) {
htmls(`.html-${options.parentIdModal}`, await renderSrrPanelData());
return '';
}
return await renderSrrPanelData();
}
}
export { PanelForm };