@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
896 lines (820 loc) • 30.5 kB
JavaScript
import { renderWave } from './Css.js';
import { Translate } from './Translate.js';
import { s, htmls } from './VanillaJs.js';
import { UserService } from '../../services/user/user.service.js';
import { ThemeEvents, darkTheme, subThemeManager, lightenHex, darkenHex } from './Css.js';
import { Modal } from './Modal.js';
import { getId } from './CommonJs.js';
import { setPath, getProxyPath, getQueryParams, extractUsernameFromPath, RouterEvents } from './Router.js';
class PublicProfile {
static Data = {};
/** @type {string|null} Track the currently displayed username for back/forward navigation */
static currentUsername = null;
static async Update(options = { idModal: '', user: {} }) {
const { idModal, user } = options;
const username = user.username || 'Unknown User';
// Check if modal exists and is registered in Modal.Data
if (!Modal.Data[idModal]) {
return await this.instance(options);
}
try {
// Track the username we're updating to
this.currentUsername = username;
// Ensure modal is in correct state
this._ensureModalState(idModal);
// Show loading state using Modal.writeHTML
const loadingHtml = this._getLoadingHtml(username);
Modal.writeHTML({ idModal, html: loadingHtml });
// Clean up existing profile data to avoid conflicts
this._cleanupProfileData({ idModal });
// Re-render the profile content with new user
const newContent = await this.instance({ ...options, disableUpdate: true });
// Update modal content using Modal.writeHTML with smooth transition
Modal.writeHTML({ idModal, html: newContent });
Modal.zIndexSync({ idModal });
if (Modal.mobileModal()) setTimeout(() => s(`.btn-close-modal-menu`).click());
this._addTransitionEffect(idModal);
return newContent;
} catch (error) {
console.error('Error updating profile:', error);
// Show error state using Modal.writeHTML
const errorHtml = this._getErrorHtml(username, error.message);
Modal.writeHTML({ idModal, html: errorHtml });
throw error;
}
}
static _getLoadingHtml(username) {
return html`
<div class="profile-loading-container">
<div class="profile-loading-spinner"></div>
<p class="profile-loading-text">Loading profile for @${username}...</p>
<style>
.profile-loading-container {
display: flex;
justify-content: center;
align-items: center;
height: 400px;
flex-direction: column;
gap: 20px;
}
.profile-loading-spinner {
width: 40px;
height: 40px;
border: 4px solid #f3f3f3;
border-top: 4px solid #3498db;
border-radius: 50%;
animation: profile-spin 1s linear infinite;
}
.profile-loading-text {
margin: 0;
color: #666;
font-size: 14px;
}
@keyframes profile-spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
</style>
</div>
`;
}
static _addTransitionEffect(idModal) {
// Add smooth transition effect to modal content
const modalContent = s(`.html-${idModal}`);
if (modalContent) {
modalContent.style.transition = 'opacity 0.3s ease-in-out';
modalContent.style.opacity = '0';
// Fade in after a brief delay
setTimeout(() => {
modalContent.style.opacity = '1';
}, 50);
}
}
static _getErrorHtml(username, errorMessage) {
return html`
<div class="profile-error-container">
<div class="profile-error-icon">
<i class="fas fa-exclamation-triangle"></i>
</div>
<h3 class="profile-error-title">Failed to load profile</h3>
<p class="profile-error-message">Could not load profile for @${username}</p>
<p class="profile-error-details">${errorMessage}</p>
<button class="profile-error-retry btn-retry-profile" onclick="location.reload()">
<i class="fas fa-redo"></i> Retry
</button>
<style>
.profile-error-container {
display: flex;
justify-content: center;
align-items: center;
height: 400px;
flex-direction: column;
gap: 15px;
text-align: center;
padding: 20px;
}
.profile-error-icon {
font-size: 48px;
color: #e74c3c;
}
.profile-error-title {
margin: 0;
color: #333;
font-size: 18px;
}
.profile-error-message {
margin: 0;
color: #666;
font-size: 14px;
}
.profile-error-details {
margin: 0;
color: #999;
font-size: 12px;
font-style: italic;
}
.profile-error-retry {
padding: 8px 16px;
background: #3498db;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
transition: background-color 0.3s;
}
.profile-error-retry:hover {
background: #2980b9;
}
</style>
</div>
`;
}
static _cleanupProfileData({ idModal }) {
delete ThemeEvents[`error-state-${idModal}`];
delete ThemeEvents[`profile-${idModal}`];
delete this.Data[idModal];
}
static _ensureModalState(idModal) {
// Ensure modal is in the correct state for content updates
if (Modal.Data[idModal]) {
// Reset any modal-specific states that might interfere
Modal.Data[idModal].updated = true;
Modal.Data[idModal].lastUpdated = Date.now();
}
}
/**
* @param {{ idModal?: string, user: { _id?: string, username?: string } }} options
*/
static async instance(
options = {
idModal: '',
user: {},
},
) {
let {
user: { _id: userId, username },
} = options;
const idModal = options.idModal || getId();
const profileId = `public-profile-${username}`;
const waveAnimationId = `${profileId}-wave`;
const profileImageClass = `${profileId}-image`;
const profileContainerId = `${profileId}-container`;
const cardId = `${profileId}-card`;
if (!options.disableUpdate) {
const queryParams = getQueryParams();
const usernameFromPath = extractUsernameFromPath();
const cid = usernameFromPath || queryParams.cid || username;
const existingModal = s(`.${idModal}`);
if (existingModal && Modal.Data[idModal]) {
await PublicProfile.Update({
idModal,
user: { username: cid },
});
return;
} else username = cid;
}
// Initialize data structure (Modal.Data pattern)
if (!PublicProfile.Data[profileId]) {
PublicProfile.Data[profileId] = {
userData: null,
colors: {},
updated: true,
lastUpdated: Date.now(),
};
} // Setup observer callback using Modal.Data pattern
// Get primary theme color for secondary colors
const getPrimaryThemeColor = () => {
const primaryColor = darkTheme ? subThemeManager.darkColor : subThemeManager.lightColor;
return primaryColor || (darkTheme ? '#3498db' : '#3498db');
};
// Theme-aware color palette with subtheme secondary colors
const getThemeColors = () => {
const isDark = darkTheme;
const primaryColor = getPrimaryThemeColor();
const primaryColorLight = lightenHex(primaryColor, 0.8);
const primaryColorDark = darkenHex(primaryColor, 0.75);
return {
// Background colors
bgPrimary: isDark ? 'rgba(25, 25, 30, 0.95)' : 'rgba(255, 255, 255, 0.95)',
bgSecondary: isDark ? 'rgba(35, 35, 40, 0.95)' : 'rgba(248, 249, 250, 0.95)',
bgGradient: isDark
? `linear-gradient(135deg, ${darkenHex(primaryColor, 0.9)}, ${darkenHex(primaryColor, 0.95)})`
: `linear-gradient(135deg, ${lightenHex(primaryColor, 0.92)}, ${lightenHex(primaryColor, 0.88)})`,
// Text colors
textPrimary: isDark ? '#e8e8e8' : '#1a1a1a',
textSecondary: isDark ? '#b0b0b0' : '#555555',
textTertiary: isDark ? '#808080' : '#999999',
// Border and divider
border: isDark ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.08)',
divider: isDark ? 'rgba(255, 255, 255, 0.08)' : 'rgba(0, 0, 0, 0.08)',
// Badge with subtheme
badgeBg: isDark ? `${darkenHex(primaryColor, 0.85)}33` : `${lightenHex(primaryColor, 0.92)}66`,
badgeBorder: isDark ? `${primaryColorLight}4d` : `${primaryColor}26`,
badgeText: isDark ? primaryColorLight : primaryColor,
// Button with subtheme gradient
buttonGradient: isDark
? `linear-gradient(135deg, ${primaryColorLight}, ${primaryColor})`
: `linear-gradient(135deg, ${primaryColor}, ${primaryColorDark})`,
buttonShadow: isDark ? `${primaryColor}40` : `${primaryColor}4d`,
buttonShadowHover: isDark ? `${primaryColor}66` : `${primaryColor}80`,
// Error
error: isDark ? '#ff6b6b' : '#e74c3c',
// Shadows
shadowSmall: isDark ? 'rgba(0, 0, 0, 0.3)' : 'rgba(0, 0, 0, 0.08)',
shadowMedium: isDark ? 'rgba(0, 0, 0, 0.4)' : 'rgba(0, 0, 0, 0.12)',
shadowLarge: isDark ? 'rgba(0, 0, 0, 0.5)' : 'rgba(0, 0, 0, 0.15)',
// Primary colors
primaryColor,
primaryColorLight,
primaryColorDark,
};
};
// Update error state theme colors dynamically
const updateErrorStateTheme = () => {
const errorStateEl = s(`.${profileContainerId}-error-state`);
if (!errorStateEl) return;
const colors = getThemeColors();
const primaryColor = getPrimaryThemeColor();
errorStateEl.style.background = `linear-gradient(135deg, ${colors.bgPrimary} 0%, ${colors.bgSecondary} 100%)`;
// Update icon container
const iconContainer = errorStateEl.querySelector('div[style*="border-radius: 50%"]');
if (iconContainer) {
iconContainer.style.background = `${colors.error}15`;
iconContainer.style.borderColor = `${colors.error}30`;
}
// Update icon
const icon = errorStateEl.querySelector('i');
if (icon) {
icon.style.color = colors.error;
}
// Update heading
const heading = errorStateEl.querySelector('h2');
if (heading) {
heading.style.color = colors.textPrimary;
}
// Update description
const description = errorStateEl.querySelector('p');
if (description) {
description.style.color = colors.textSecondary;
}
// Update buttons
const buttons = errorStateEl.querySelectorAll('a');
buttons.forEach((btn, index) => {
if (index === 0) {
// Home button
btn.style.background = colors.buttonGradient;
btn.style.boxShadow = `0 4px 12px ${colors.buttonShadow}`;
} else {
// Go Back button
btn.style.color = colors.primaryColor;
btn.style.borderColor = `${colors.primaryColor}40`;
}
});
};
// Register theme change handler
ThemeEvents[`error-state-${idModal}`] = updateErrorStateTheme;
setTimeout(updateErrorStateTheme);
const renderErrorState = (message, icon = 'fa-exclamation-circle', description = '') => {
const colors = getThemeColors();
return html`
<div
class="${profileContainerId}-error-state"
style="
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 40px;
background: linear-gradient(135deg, ${colors.bgPrimary} 0%, ${colors.bgSecondary} 100%);
border-radius: 20px;
text-align: center;
"
>
<!-- Icon Container -->
<div
style="
width: 120px;
height: 120px;
border-radius: 50%;
background: ${colors.error}15;
border: 2px solid ${colors.error}30;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 32px;
"
>
<i
class="fa-solid ${icon}"
style="
font-size: 56px;
color: ${colors.error};
opacity: 0.9;
"
></i>
</div>
<!-- Main Message -->
<h2
style="
margin: 0 0 16px 0;
font-size: 24px;
font-weight: 600;
color: ${colors.textPrimary};
letter-spacing: -0.5px;
"
>
${message}
</h2>
<!-- Description/Documentation -->
${description
? html`<p
style="
margin: 0 0 32px 0;
font-size: 16px;
color: ${colors.textSecondary};
line-height: 1.6;
max-width: 500px;
"
>
${description}
</p>`
: ''}
<!-- Helpful Actions -->
<div
style="
display: flex;
gap: 12px;
flex-wrap: wrap;
justify-content: center;
margin-top: 24px;
"
>
<a
href="/"
style="
padding: 12px 28px;
background: ${colors.buttonGradient};
color: white;
text-decoration: none;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
transition: all 0.3s ease;
box-shadow: 0 4px 12px ${colors.buttonShadow};
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 8px;
"
onmouseover="this.style.transform='translateY(-2px)'; this.style.boxShadow='0 6px 16px ${colors.buttonShadowHover}';"
onmouseout="this.style.transform='translateY(0)'; this.style.boxShadow='0 4px 12px ${colors.buttonShadow}';"
>
<i class="fa-solid fa-home"></i>
${Translate.instance('go-home')}
</a>
<a
href="javascript:history.back()"
style="
padding: 12px 28px;
background: transparent;
color: ${colors.primaryColor};
text-decoration: none;
border: 2px solid ${colors.primaryColor}40;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
transition: all 0.3s ease;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 8px;
"
onmouseover="this.style.background='${colors.primaryColor}08'; this.style.borderColor='${colors.primaryColor}60';"
onmouseout="this.style.background='transparent'; this.style.borderColor='${colors.primaryColor}40';"
>
<i class="fa-solid fa-arrow-left"></i>
${Translate.instance('go-back')}
</a>
</div>
</div>
`;
};
if (!userId && !username) {
return renderErrorState(
Translate.instance('user-not-found'),
'fa-user-slash',
"The user you're looking for could not be found. Please check the username and try again.",
);
}
// Fetch public user data
let userData = null;
try {
const result = await UserService.get({ id: `u/${username}` });
setTimeout(() => {
Modal.Data[idModal].onObserverListener['profile-card-observer'] = () => {
const modalHeight = s(`.${idModal}`).offsetHeight;
if (s(`.${profileContainerId}`)) s(`.${profileContainerId}`).style.height = `${modalHeight - 110}px`;
if (s(`.${profileContainerId}-error-state`))
s(`.${profileContainerId}-error-state`).style.height = `${modalHeight - 160}px`;
};
Modal.Data[idModal].onObserverListener['profile-card-observer']();
});
if (result.status === 'success') {
userData = result.data;
PublicProfile.Data[profileId].userData = userData;
// Track the currently displayed username for back/forward navigation
PublicProfile.currentUsername = userData.username || username;
// Update browser history to show clean URL after successful data fetch
if (userData.username) {
const cleanPath = `${getProxyPath()}u/${username}`;
setPath(cleanPath, { replace: true });
}
} else {
if (result.message && result.message.toLowerCase().match('private'))
return renderErrorState(
Translate.instance('profile-is-private'),
'fa-lock',
'This user has chosen to keep their profile private. Respect their privacy and check back later if they change their settings.',
);
else
return renderErrorState(
Translate.instance('user-not-found'),
'fa-user-slash',
'This user profile does not exist or has been removed. Please verify the username.',
);
}
} catch (error) {
console.error('Error fetching public profile:', error);
return renderErrorState(
Translate.instance('error-loading-profile'),
'fa-circle-exclamation',
'We encountered an issue loading this profile. Please try again in a moment or contact support if the problem persists.',
);
}
// If user doesn't have public profile enabled
if (!userData.publicProfile) {
return renderErrorState(
Translate.instance('profile-is-private'),
'fa-lock',
'This user has chosen to keep their profile private. Respect their privacy and check back later if they change their settings.',
);
}
// Function to update profile card styles based on theme changes
const updateProfileCardTheme = () => {
const container = s(`.${profileContainerId}`);
if (!container) return;
const colors = getThemeColors();
PublicProfile.Data[profileId].colors = colors;
// Update container background
container.style.background = colors.bgGradient;
// Update card
const card = s(`.${cardId}`);
if (card) {
card.style.backgroundColor = colors.bgPrimary;
card.style.borderColor = colors.border;
card.style.boxShadow = `0 12px 48px ${colors.shadowMedium}`;
}
// Update text colors
const username_el = s(`.${profileId}-username`);
if (username_el) username_el.style.color = colors.textPrimary;
const description_els = document.querySelectorAll(`.${profileId}-description`);
description_els.forEach((el) => {
el.style.color = colors.textSecondary;
});
const badge_el = s(`.${profileId}-badge`);
if (badge_el) {
badge_el.style.backgroundColor = colors.badgeBg;
badge_el.style.borderColor = colors.badgeBorder;
const badgeText = badge_el.querySelector('span');
if (badgeText) badgeText.style.color = colors.badgeText;
}
const divider = s(`.${profileId}-divider`);
if (divider) {
divider.style.background = `linear-gradient(90deg, transparent, ${colors.divider}, transparent)`;
}
const button = s(`.${profileId}-button`);
if (button) {
button.style.background = colors.buttonGradient;
button.style.boxShadow = `0 6px 16px ${colors.buttonShadow}`;
}
const imageContainer = s(`.${profileId}-image-container`);
if (imageContainer) {
imageContainer.style.borderColor = colors.bgPrimary;
imageContainer.style.backgroundColor = colors.bgSecondary;
}
};
// Register theme change listener
ThemeEvents[`profile-${idModal}`] = updateProfileCardTheme;
// Schedule rendering of profile image after DOM is ready
setTimeout(async () => {
if (userData.profileImageId) {
try {
const imageSrc = `${getProxyPath()}api/file/blob/${userData.profileImageId}`;
const imgElement = s(`.${profileImageClass}`);
if (imgElement) {
imgElement.src = imageSrc;
imgElement.style.opacity = '1';
const loadingEl = s(`.${profileImageClass}-loading`);
if (loadingEl) {
loadingEl.style.display = 'none';
}
}
} catch (error) {
console.warn('Could not load profile image:', error);
}
}
});
const colors = getThemeColors();
PublicProfile.Data[profileId].colors = colors;
return html`
<!-- Hero Section with Wave Background -->
<div
class="${profileContainerId}"
style="
position: relative;
background: ${colors.bgGradient};
overflow: visible;
display: flex;
align-items: flex-start;
justify-content: center;
padding-top: 40px;
padding-bottom: 20px;
padding-left: 20px;
padding-right: 20px;
transition: background 0.3s ease-in-out;
"
>
<!-- Wave Animation Background - Fixed Height -->
<div
class="${waveAnimationId}"
style="
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
opacity: 0.5;
overflow: hidden;
"
>
${renderWave({ id: waveAnimationId })}
</div>
<!-- Profile Card Container - Half Overlapping -->
<div
class="${cardId}"
style="
position: relative;
top: 60px;
background: ${colors.bgPrimary};
backdrop-filter: blur(10px);
border-radius: 20px;
padding: 48px 32px 40px;
max-width: 520px;
width: 100%;
box-shadow: 0 12px 48px ${colors.shadowMedium};
border: 1px solid ${colors.border};
transition: all 0.3s ease-in-out;
margin-top: -40px;
"
>
<!-- Profile Image Container -->
<div
style="
display: flex;
justify-content: center;
margin-bottom: 36px;
"
>
<div
class="${profileId}-image-container public-profile-image-container"
style="
position: relative;
width: 160px;
height: 160px;
flex-shrink: 0;
border-radius: 50%;
border: 6px solid ${colors.bgPrimary};
background-color: ${colors.bgSecondary};
transition: all 0.3s ease-in-out;
overflow: hidden;
box-shadow: 0 12px 40px ${colors.shadowLarge};
"
>
<img
class="${profileImageClass} public-profile-image"
style="
width: 100%;
height: 100%;
border-radius: 50%;
opacity: 0;
transition: opacity 0.4s ease-in-out;
object-fit: cover;
"
alt="${userData.username}"
/>
<div
class="${profileImageClass}-loading"
style="
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: ${colors.textTertiary};
font-size: 48px;
transition: display 0.3s ease-in-out;
background: linear-gradient(135deg, ${colors.bgSecondary}, ${colors.bgPrimary});
"
>
<i class="fa-solid fa-user" style="opacity: 0.4;"></i>
</div>
</div>
</div>
<!-- Content Section -->
<div style="text-align: center;">
<!-- Username -->
<h1
class="${profileId}-username"
style="
margin: 0 0 12px 0;
font-size: 28px;
font-weight: 700;
color: ${colors.textPrimary};
letter-spacing: -0.5px;
transition: color 0.3s ease-in-out;
"
>
${userData.username}
</h1>
<!-- Brief Description / Bio -->
<div style="margin: 16px 0 28px 0;">
${userData.briefDescription
? html`<p
class="${profileId}-description"
style="
margin: 0;
font-size: 14px;
color: ${colors.textSecondary};
line-height: 1.6;
font-weight: 500;
transition: color 0.3s ease-in-out;
"
>
${userData.briefDescription}
</p>`
: html`<p
class="${profileId}-description"
style="
margin: 0;
font-size: 14px;
color: ${colors.textTertiary};
font-style: italic;
transition: color 0.3s ease-in-out;
"
>
${Translate.instance('no-description')}
</p>`}
</div>
<!-- Member Since Badge -->
<div
class="${profileId}-badge"
style="
display: inline-block;
background: ${colors.badgeBg};
border: 1px solid ${colors.badgeBorder};
border-radius: 24px;
padding: 8px 16px;
margin-bottom: 28px;
transition: all 0.3s ease-in-out;
"
>
<span
style="
font-size: 12px;
color: ${colors.badgeText};
font-weight: 600;
letter-spacing: 0.5px;
transition: color 0.3s ease-in-out;
"
>
<i class="fa-solid fa-calendar" style="margin-right: 6px;"></i>
${Translate.instance('member-since')}: ${new Date(userData.createdAt).toLocaleDateString()}
</span>
</div>
<!-- Divider -->
<div
class="${profileId}-divider"
style="
margin: 24px 0;
height: 1px;
background: linear-gradient(
90deg,
transparent,
${colors.divider},
transparent
);
transition: background 0.3s ease-in-out;
"
></div>
<!-- Action Links -->
<div style="display: flex; justify-content: center; gap: 12px; flex-wrap: wrap;">
<a
class="${profileId}-button"
href="javascript:void(0)"
title="${Translate.instance('view-profile')}"
style="
display: inline-flex;
align-items: center;
justify-content: center;
width: 50px;
height: 50px;
border-radius: 50%;
background: ${colors.buttonGradient};
color: white;
text-decoration: none;
transition: all 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
cursor: pointer;
box-shadow: 0 6px 16px ${colors.buttonShadow};
font-size: 18px;
border: none;
"
onmouseover="
this.style.transform = 'translateY(-4px) scale(1.1)';
this.style.boxShadow = '0 10px 24px ${colors.buttonShadowHover}';
"
onmouseout="
this.style.transform = 'translateY(0) scale(1)';
this.style.boxShadow = '0 6px 16px ${colors.buttonShadow}';
"
>
<i class="fa-solid fa-user"></i>
</a>
</div>
</div>
</div>
</div>
`;
}
/**
* @param {{ idModal?: string }} options
*/
static async Router(options = { idModal: '' }) {
const idModal = options.idModal || 'modal-public-profile';
// Register RouterEvents listener for back/forward navigation between profiles
// This ensures the profile updates when the user navigates through browser history
// Note: route id is 'u', modal id is 'modal-public-profile', button class is 'main-btn-public-profile'
RouterEvents[`${idModal}-navigation`] = async ({ route }) => {
if (route === 'u') {
const usernameFromPath = extractUsernameFromPath();
const queryParams = getQueryParams();
const cid = usernameFromPath || queryParams.cid;
if (!cid) return;
// Check if modal exists (could be behind another view modal like settings)
if (s(`.${idModal}`) && Modal.Data[idModal]) {
// Modal exists - bring to front and update if username changed
const currentUsername = PublicProfile.currentUsername;
if (currentUsername !== cid)
await PublicProfile.Update({
idModal,
user: { username: cid },
});
} else {
// Modal doesn't exist - open it
if (s('.main-btn-public-profile')) {
s('.main-btn-public-profile').click();
}
}
}
};
}
}
export { PublicProfile };