diff --git a/components/user-profile/contributionActivity.js b/components/user-profile/contributionActivity.js index be3d9845..3461c012 100644 --- a/components/user-profile/contributionActivity.js +++ b/components/user-profile/contributionActivity.js @@ -1,21 +1,28 @@ import TPEN from '../../api/TPEN.js' -import User from '../../api/User.js' import Project from '../../api/Project.js' import { CleanupRegistry } from '../../utilities/CleanupRegistry.js' +import { onUserReady } from '../../utilities/userReady.js' +import { onUserProjectsReady } from '../../utilities/userProjectsReady.js' /** * ContributionActivity - Displays user's contribution activity across projects. * @element contribution-activity */ class ContributionActivity extends HTMLElement { - static get observedAttributes() { - return ['tpen-user-id'] - } - /** @type {CleanupRegistry} Registry for cleanup handlers */ cleanup = new CleanupRegistry() /** @type {CleanupRegistry} Registry for render-specific handlers */ renderCleanup = new CleanupRegistry() + /** @type {Function|null} Unsubscribe function for user ready listener */ + _unsubUser = null + /** @type {Function|null} Unsubscribe function for projects ready listener */ + _unsubProjects = null + /** @type {Object|null} Cached profile data */ + _profile = null + /** @type {Array|null} Cached projects data */ + _projects = null + /** @type {boolean} Flag to prevent double rendering */ + _isRendering = false constructor() { super() @@ -24,34 +31,43 @@ class ContributionActivity extends HTMLElement { connectedCallback() { TPEN.attachAuthentication(this) - this.cleanup.onEvent(TPEN.eventDispatcher, 'tpen-user-loaded', () => this.loadAndRender()) + this._unsubUser = onUserReady(this, (user) => { + this._profile = user + this.renderIfReady() + }) + this._unsubProjects = onUserProjectsReady(this, (projects) => { + this._projects = projects + this.renderIfReady() + }) } /** - * Loads user projects and renders the contribution activity. + * Renders only when both profile and projects are available. + * Guards against double rendering when both callbacks fire quickly. + */ + renderIfReady() { + if (this._profile && this._projects && !this._isRendering) { + this._isRendering = true + this.loadAndRender() + .finally(() => { this._isRendering = false }) + } + } + + /** + * Processes cached projects and renders the contribution activity. */ async loadAndRender() { - const projects = await TPEN.getUserProjects(TPEN.getAuthorization()) + const projects = this._projects await this.processAndRender(projects) } disconnectedCallback() { + try { this._unsubUser?.() } catch {} + try { this._unsubProjects?.() } catch {} this.renderCleanup.run() this.cleanup.run() } - attributeChangedCallback(name, oldValue, newValue) { - if (name === 'tpen-user-id') { - if (oldValue !== newValue) { - const currVal = this?.user?._id - if (newValue === currVal) return - const loadedUser = new User(newValue) - loadedUser.authentication = TPEN.getAuthorization() - loadedUser.getProfile() - } - } - } - /** * Processes project data and renders the contribution activity. * @param {Array} projects - Array of project data diff --git a/components/user-profile/index.js b/components/user-profile/index.js index f7f4767e..9cc00727 100644 --- a/components/user-profile/index.js +++ b/components/user-profile/index.js @@ -1,20 +1,19 @@ import TPEN from '../../api/TPEN.js' -import User from '../../api/User.js' import { CleanupRegistry } from '../../utilities/CleanupRegistry.js' +import { onUserReady } from '../../utilities/userReady.js' /** * UserProfile - Displays and allows editing of user profile information. * @element tpen-user-profile */ class UserProfile extends HTMLElement { - static get observedAttributes() { - return ['tpen-user-id'] - } user = TPEN.currentUser /** @type {CleanupRegistry} Registry for cleanup handlers */ cleanup = new CleanupRegistry() /** @type {CleanupRegistry} Registry for render-specific handlers */ renderCleanup = new CleanupRegistry() + /** @type {Function|null} Unsubscribe function for user ready listener */ + _unsubUser = null constructor() { super() @@ -23,28 +22,23 @@ class UserProfile extends HTMLElement { connectedCallback() { TPEN.attachAuthentication(this) - this.cleanup.onEvent(TPEN.eventDispatcher, 'tpen-user-loaded', ev => { - this.render(ev.detail) - this.updateProfile(ev.detail) - this.user = ev.detail - }) + this._unsubUser = onUserReady(this, this.handleUserReady) } disconnectedCallback() { + try { this._unsubUser?.() } catch {} this.renderCleanup.run() this.cleanup.run() } - attributeChangedCallback(name, oldValue, newValue) { - if (name === 'tpen-user-id') { - if (oldValue !== newValue) { - const currVal = this?.user?._id - if (newValue === currVal) return - const loadedUser = new User(newValue) - loadedUser.authentication = TPEN.getAuthorization() - loadedUser.getProfile() - } - } + /** + * Handler for when user data is ready. + * @param {Object} user - The loaded user object + */ + handleUserReady(user) { + this.user = user + this.render(user) + this.updateProfile(user) } updateProfile(profile) { diff --git a/components/user-profile/report.js b/components/user-profile/report.js index 21a468b2..3aaf7271 100644 --- a/components/user-profile/report.js +++ b/components/user-profile/report.js @@ -1,19 +1,26 @@ import TPEN from '../../api/TPEN.js' -import User from '../../api/User.js' import Project from '../../api/Project.js' import { CleanupRegistry } from '../../utilities/CleanupRegistry.js' +import { onUserReady } from '../../utilities/userReady.js' +import { onUserProjectsReady } from '../../utilities/userProjectsReady.js' /** * ReportStats - Displays summary statistics about user's projects. * @element report-stats */ class ReportStats extends HTMLElement { - static get observedAttributes() { - return ['tpen-user-id'] - } - /** @type {CleanupRegistry} Registry for cleanup handlers */ cleanup = new CleanupRegistry() + /** @type {Function|null} Unsubscribe function for user ready listener */ + _unsubUser = null + /** @type {Function|null} Unsubscribe function for projects ready listener */ + _unsubProjects = null + /** @type {Object|null} Cached profile data */ + _profile = null + /** @type {Array|null} Cached projects data */ + _projects = null + /** @type {boolean} Flag to prevent double rendering */ + _isRendering = false constructor() { super() @@ -22,14 +29,33 @@ class ReportStats extends HTMLElement { connectedCallback() { TPEN.attachAuthentication(this) - this.cleanup.onEvent(TPEN.eventDispatcher, 'tpen-user-loaded', () => this.loadAndRender()) + this._unsubUser = onUserReady(this, (user) => { + this._profile = user + this.renderIfReady() + }) + this._unsubProjects = onUserProjectsReady(this, (projects) => { + this._projects = projects + this.renderIfReady() + }) } /** - * Loads user projects, calculates statistics, and renders the report. + * Renders only when both profile and projects are available. + * Guards against double rendering when both callbacks fire quickly. + */ + renderIfReady() { + if (this._profile && this._projects && !this._isRendering) { + this._isRendering = true + this.loadAndRender() + .finally(() => { this._isRendering = false }) + } + } + + /** + * Calculates statistics from cached projects and renders the report. */ async loadAndRender() { - const projects = await TPEN.getUserProjects(TPEN.getAuthorization()) + const projects = this._projects const uniqueCollaborators = new Set() projects.forEach(project => { @@ -56,21 +82,11 @@ class ReportStats extends HTMLElement { } disconnectedCallback() { + try { this._unsubUser?.() } catch {} + try { this._unsubProjects?.() } catch {} this.cleanup.run() } - attributeChangedCallback(name, oldValue, newValue) { - if (name === 'tpen-user-id') { - if (oldValue !== newValue) { - const currVal = this?.user?._id - if (newValue === currVal) return - const loadedUser = new User(newValue) - loadedUser.authentication = TPEN.getAuthorization() - loadedUser.getProfile() - } - } - } - /** * Renders the report with pre-calculated statistics. * @param {number} projectCount - Number of projects diff --git a/components/user-profile/userStats.js b/components/user-profile/userStats.js index c605c0d5..ac4ec72e 100644 --- a/components/user-profile/userStats.js +++ b/components/user-profile/userStats.js @@ -1,19 +1,26 @@ import TPEN from '../../api/TPEN.js' -import User from '../../api/User.js' import Project from '../../api/Project.js' import { CleanupRegistry } from '../../utilities/CleanupRegistry.js' +import { onUserReady } from '../../utilities/userReady.js' +import { onUserProjectsReady } from '../../utilities/userProjectsReady.js' /** * UserStats - Displays user profile card with stats and collaborators. * @element user-stats */ class UserStats extends HTMLElement { - static get observedAttributes() { - return ['tpen-user-id'] - } - /** @type {CleanupRegistry} Registry for cleanup handlers */ cleanup = new CleanupRegistry() + /** @type {Function|null} Unsubscribe function for user ready listener */ + _unsubUser = null + /** @type {Function|null} Unsubscribe function for projects ready listener */ + _unsubProjects = null + /** @type {Object|null} Cached profile data */ + _profile = null + /** @type {Array|null} Cached projects data */ + _projects = null + /** @type {boolean} Flag to prevent double rendering */ + _isRendering = false constructor() { super() @@ -22,35 +29,44 @@ class UserStats extends HTMLElement { connectedCallback() { TPEN.attachAuthentication(this) - this.cleanup.onEvent(TPEN.eventDispatcher, 'tpen-user-loaded', ev => this.loadAndRender(ev.detail)) + this._unsubUser = onUserReady(this, (user) => { + this._profile = user + this.renderIfReady() + }) + this._unsubProjects = onUserProjectsReady(this, (projects) => { + this._projects = projects + this.renderIfReady() + }) } /** - * Loads user projects and renders the stats. + * Renders only when both profile and projects are available. + * Guards against double rendering when both callbacks fire quickly. + */ + renderIfReady() { + if (this._profile && this._projects && !this._isRendering) { + this._isRendering = true + this.loadAndRender(this._profile, this._projects) + .finally(() => { this._isRendering = false }) + } + } + + /** + * Processes profile and project data, then renders the stats. * @param {Object} profile - User profile data + * @param {Array} projects - Array of project data */ - async loadAndRender(profile) { - const projects = await TPEN.getUserProjects(TPEN.getAuthorization()) + async loadAndRender(profile, projects) { await this.processAndRender(profile, projects) this.updateProfile(profile) } disconnectedCallback() { + try { this._unsubUser?.() } catch {} + try { this._unsubProjects?.() } catch {} this.cleanup.run() } - attributeChangedCallback(name, oldValue, newValue) { - if (name === 'tpen-user-id') { - if (oldValue !== newValue) { - const currVal = this?.user?._id - if (newValue === currVal) return - const loadedUser = new User(newValue) - loadedUser.authentication = TPEN.getAuthorization() - loadedUser.getProfile() - } - } - } - updateProfile(profile) { const publicProfile = this.getPublicProfile(profile) if(publicProfile.imageURL) { diff --git a/utilities/userProjectsReady.js b/utilities/userProjectsReady.js new file mode 100644 index 00000000..cb6179d4 --- /dev/null +++ b/utilities/userProjectsReady.js @@ -0,0 +1,49 @@ +import TPEN from "../api/TPEN.js" + +// Module-level flag to prevent multiple simultaneous fetches +let isFetching = false + +/** + * Utility to handle user projects readiness with caching. + * Checks if projects are already loaded before fetching. + * Triggers fetch if needed, subscribes to event for results. + * @param {Object} ctx - The context to bind the handler to + * @param {Function} handler - The handler function to invoke with projects + * @returns {Function} Unsubscribe function + */ +export const onUserProjectsReady = (ctx, handler) => { + if (!ctx || typeof handler !== 'function') return () => {} + const bound = handler.bind(ctx) + + // Check if projects are already cached + try { + if (Array.isArray(TPEN.userProjects)) { + bound(TPEN.userProjects) + } + } catch (_) {} + + // Subscribe to future updates + const eventHandler = () => { + try { + bound(TPEN.userProjects) + } catch (_) {} + } + TPEN.eventDispatcher.on('tpen-user-projects-loaded', eventHandler) + + // Trigger fetch if not already cached and not currently fetching + if (!TPEN.userProjects && !isFetching) { + isFetching = true + const token = TPEN.getAuthorization() + if (token) { + TPEN.getUserProjects(token) + .catch((error) => { + console.error("Failed to load user projects:", error) + }) + .finally(() => { isFetching = false }) + } else { + isFetching = false + } + } + + return () => TPEN.eventDispatcher.off('tpen-user-projects-loaded', eventHandler) +} diff --git a/utilities/userReady.js b/utilities/userReady.js new file mode 100644 index 00000000..7760bbab --- /dev/null +++ b/utilities/userReady.js @@ -0,0 +1,52 @@ +import TPEN from "../api/TPEN.js" +import User from "../api/User.js" +import { getUserFromToken } from "../components/iiif-tools/index.js" + +// Module-level flag to prevent multiple simultaneous user fetches +let isFetchingUser = false + +/** + * Utility to handle user data readiness, similar to onProjectReady. + * Immediately invokes handler if user is already loaded, also subscribes to updates. + * Triggers user fetch if not already loaded and not currently fetching. + * @param {Object} ctx - The context to bind the handler to + * @param {Function} handler - The handler function to invoke when user is ready + * @param {string} eventName - The event name to listen for (default: 'tpen-user-loaded') + * @returns {Function} Unsubscribe function + */ +export const onUserReady = (ctx, handler, eventName = 'tpen-user-loaded') => { + if (!ctx || typeof handler !== 'function') return () => {} + const bound = handler.bind(ctx) + + // Check if user is already loaded (has _id and displayName) + const userLoaded = TPEN.currentUser?._id && TPEN.currentUser?.displayName + try { + if (userLoaded) { + bound(TPEN.currentUser) + } + } catch (_) {} + + // Subscribe to future updates (extract user from event detail, unlike onProjectReady which passes no args) + const eventHandler = (ev) => bound(ev.detail) + TPEN.eventDispatcher.on(eventName, eventHandler) + + // Trigger user fetch if not already loaded and not currently fetching + if (!userLoaded && !isFetchingUser) { + const token = TPEN.getAuthorization() + if (token) { + const userId = getUserFromToken(token) + if (userId) { + isFetchingUser = true + const user = new User(userId) + user.authentication = token + user.getProfile() + .catch((error) => { + console.error("Failed to load user profile:", error) + }) + .finally(() => { isFetchingUser = false }) + } + } + } + + return () => TPEN.eventDispatcher.off(eventName, eventHandler) +}