1
0
Fork 0
timelinize/frontend/resources/js/spa.js
Matthew Holt dab1adbf24
Force-update repo owner info when opening timeline
Bust the session storage cache in the browser

(Also load entity stored timestamp when loading entity)
2025-09-23 14:03:29 -06:00

330 lines
11 KiB
JavaScript

// When .map-container or .loader-container elems are added to the DOM, observe them for scrolling into view.
const mutationObs = new MutationObserver(function(mutations) {
for (const mut of mutations) {
for (const node of mut.addedNodes) {
if (!(node instanceof HTMLElement)) continue; // skip text/whitespace nodes
if (node.matches('.map-container')) {
tlz.mapIntersectionObs.observe(node);
}
$$('.map-container', node).forEach(el => {
tlz.mapIntersectionObs.observe(el);
});
}
// don't unobserve removedNodes, because the intersection observer
// removes map containers from a set when they go out of view,
// and if we unobserve it can't do that, causing a memory leak;
// but we can fire mapMoved events for special-use maps that may
// need to reset some state or initialization, such as the map on
// the Demo Mode Settings page, which adds draw controls (it needs
// to remove them when navigating away).
for (const node of mut.removedNodes) {
if (!(node instanceof HTMLElement)) continue; // skip text/whitespace nodes
if (node.matches('.map-container')) {
document.dispatchEvent(new CustomEvent("mapMoved", { detail: { previousElement: node } }));
}
$$('.map-container', node).forEach(el => {
document.dispatchEvent(new CustomEvent("mapMoved", { detail: { previousElement: el } }));
});
}
}
});
mutationObs.observe(document, {childList: true, subtree:true});
function addrBarPathToPagePath(userFriendlyPath) {
if (userFriendlyPath == "/") {
userFriendlyPath = "/dashboard"
}
return `/pages${userFriendlyPath}.html`;
}
function splitPathAndQueryString(uri) {
const qsStart = uri.indexOf('?');
if (qsStart > -1) {
uri.substring(0, qsStart);
return {
path: uri.substring(0, qsStart),
query: uri.substring(qsStart)
};
}
return {path: uri};
}
function currentURI() {
return window.location.pathname + window.location.search;
}
// navigateSPA changes the page. The argument should be the URI (not including scheme/host; i.e. just
// path and optional query string) to show in the address bar (the user-friendly URI). If omitted,
// or not a string (sometimes it is an event handler) it defaults to the path and query currently
// in the address bar.
async function navigateSPA(addrBarDestination, scrollToTop) {
$('#page-content').classList.add('opacity0');
// when used as an event handler, the first argument may be an event object,
// or we might call it without any argument; default to navigating to what the
// address bar shows when a URI string isn't explicitly passed in
if (typeof addrBarDestination !== 'string') {
addrBarDestination = currentURI();
}
// redirect to setup (or away from it) accordingly
if (!tlz.openRepos.length) {
console.log("no repositories are open; redirecting to setup");
addrBarDestination = "/setup";
} else if (!await updateRepoOwners()) {
// no owner yet, maybe the repo is still being set up
console.log(`repository ${tlz.openRepos[0].instance_id} has no owner; redirecting to setup`);
addrBarDestination = "/setup";
} else if (addrBarDestination.startsWith("/setup")) {
console.log("repository is already open and non-empty; redirecting to dashboard");
addrBarDestination = "/";
}
// don't change history if we aren't changing the address bar state
// (this happens on initial page load, or when the back button is used as the URL has already been changed)
const skipPushState = addrBarDestination == currentURI();
// split the user-facing URI into its path and query parts
const addrBarDestinationParts = splitPathAndQueryString(addrBarDestination);
// special cases: rewrite when path contains application data
const matches = addrBarDestinationParts.path.match(/\/(items|entities|jobs)\/[\w-]+\/\d+$/);
if (matches) {
const rewriteTo = {"items": "/item", "entities": "/entity", "jobs": "/job"}
addrBarDestinationParts.path = rewriteTo[matches[1]]
}
// craft the true destination (not shown to the user)
const destPath = addrBarPathToPagePath(addrBarDestinationParts.path);
const destination = destPath + (addrBarDestinationParts.query || "");
console.log("NAVIGATING:", destination, addrBarDestinationParts);
// show a loading indicator if things are going slow
let slowLoadingHandle;
if (!$('#app-loader')) {
slowLoadingHandle = setTimeout(function() {
const span = document.createElement('span');
span.classList.add('slow-loader', 'page-loader');
$('#page-content').insertAdjacentElement('beforebegin', span);
}, 1000);
}
// immediately perform the request, but don't start changing the page until it has faded out
// TODO: if URL not found or something, replaceState(null, null, "/") maybe?
const promise = fetch(destination).then((response) => response.text());
// wait for page to finish fading out before
setTimeout(async function() {
promise.then(async (data) => {
if (tlz.map) {
tlz.map.tl_containers = new Map();
tlz.map.tl_clear();
}
for (const dateInputEl of $$('.date-input')) {
// it seems like a good idea to clean up our AirDatepickers, but
// I haven't confirmed whether this is truly necessary
dateInputEl.datepicker.destroy();
}
// run any code needed to help the page unload
if (tlz.currentPageController?.unload) {
// TODO: await?
tlz.currentPageController.unload();
}
// update URL bar and history
if (!skipPushState) {
history.pushState(null, null, addrBarDestination);
}
// replace page content
$('#page-content').innerHTML = data;
// adjust page title
const newTitleEl = $('body title');
if (newTitleEl) {
$('head title').innerText = newTitleEl.innerText;
newTitleEl.remove();
}
// set up the page, and store a reference to the current page's
// controller, since it will be used later like when unloading it
tlz.currentPageController = tlz.pageControllers[destPath];
if (tlz.currentPageController?.load) {
await tlz.currentPageController.load();
}
// Render data source filter inputs (we don't use forEach here because it does not await async functions!)
for (const e of $$('.filter select.tl-data-source')) {
await newDataSourceSelect(e);
};
// Render item classification filter dropdowns
$$('.filter .tl-item-class-dropdown').forEach(e => {
renderFilterDropdown(e, "Types", 'item_classes');
});
await queryStringToFilter();
// set up the page, and store a reference to the current page's
// controller, since it will be used later like when unloading it
if (tlz.currentPageController?.render) {
await tlz.currentPageController.render();
}
activateTooltips();
// this isn't desirable every time, like when going Back, or refreshing the page,
// as we may want to keep the current position; but usually when navigating to a new
// page from a page where we've been scrolled down, it's weird to have it load to
// the bottom of the page... so generally we want to scroll up on link clicks
if (scrollToTop) {
window.scrollTo({
top: 0,
left: 0,
behavior: 'instant'
});
}
// hide any loading indicator (or prevent it from appearing in the first place)
if (slowLoadingHandle) {
clearTimeout(slowLoadingHandle);
$('.slow-loader.page-loader')?.remove();
}
// fade the content in, but wait a little bit for the page to have a chance to render
setTimeout(function() {
$('#page-content').classList.remove('opacity0');
}, 250);
// if the full page app loader is still showing (initial page load), fade it out gracefully
// (these timings are estimates; maybe advanced browser APIs could help us know when the page
// is done painting, but sounds like a lot of work)
if ($('#app-loader') && !$('#app-loader').classList.contains('fade-out')) {
// first start to fade out the loader itself (fading out only its container looks weird)
setTimeout(function() {
$('#app-loader .app-loader').classList.add('fade-out');
// then fade out its backdrop/container
setTimeout(function() {
$('#app-loader').classList.add('fade-out');
// once index has initially loaded, app loader no longer needed
setTimeout(function() {
$('#app-loader').remove();
}, 1000);
}, 100);
}, 100);
}
});
}, 250);
}
// when links to pages within the app are clicked, fake-navigate
on('click', '[href^="/"]:not([download])', async e => {
e.preventDefault();
const destination = e.target.closest(':not(use)[href]').getAttribute('href'); // can't use .href because that returns a fully-qualified URL, which actually breaks in Wails dev; and we only accept path+query
await navigateSPA(destination, true);
return false;
});
// this is for filter changes
on('click', `.filter [href^="?"], .pagination [href^="?"]`, async event => {
event.preventDefault();
// update URL bar so the filter will read the updated page number
const destination = event.target.closest(':not(use)[href]').getAttribute('href'); // can't use .href because that returns a fully-qualified URL, which actually breaks in Wails dev; and we only accept path+query
history.pushState(null, null, destination);
updateFilterResults();
return false;
});
// updateRepoOwners updates the repo owners as stored locally, only if
// the information is missing (or if forced === true).
async function updateRepoOwners(forced) {
let anyUpdated = false;
for (const repo of tlz.openRepos) {
if (!forced && repo.owner) {
continue; // avoid re-checking on _every single page load_
}
if (await app.RepositoryIsEmpty(repo.instance_id)) {
return false;
} else {
repo.owner = await app.GetEntity(repo.instance_id, 1);
anyUpdated = true;
}
}
if (anyUpdated) {
store('open_repos', tlz.openRepos);
const repoOwner = tlz.openRepos[0].owner;
store('owner', repoOwner);
let birthPlace = "";
if (repoOwner.attributes) {
for (const attr of repoOwner.attributes) {
if (attr.name == "birth_place") {
birthPlace = attr.value;
break;
}
}
}
repoOwner.forceUpdate = forced; // can notify UI to load new profile picture or other details, if applicable
$('#repo-owner-name').innerText = repoOwner.name;
$('#repo-owner-title').innerText = birthPlace;
$('#repo-owner-avatar').innerHTML = avatar(false, repoOwner, "avatar-sm");
$('header .profile-link').href = `/entities/${tlz.openRepos[0].instance_id}/${repoOwner.id}`;
}
return true;
}
async function updateItemClasses() {
const clArray = await app.ItemClassifications(tlz.openRepos[0].instance_id);
const classes = {};
for (const cl of clArray) {
classes[cl.name] = cl;
}
store('item_classes', classes);
}
async function initialize() {
// classifications are stored in the database, so get open repos first and attach owner info
tlz.openRepos = await app.OpenRepositories();
await updateRepoOwners();
// if there's an open repo, load classifications
if (tlz.openRepos.length && !load('item_classes')) {
await updateItemClasses();
}
// load app settings
tlz.settings = await app.GetSettings();
// now that settings are loaded, initialize the map
initMapSingleton();
// perform initial page load
if (document.readyState === 'loading') {
window.addEventListener('DOMContentLoaded', navigateSPA);
} else {
navigateSPA();
}
// when back button is pressed, also do SPA nav (TODO: test this works)
window.addEventListener('popstate', navigateSPA);
}
initialize();