mirror of
https://github.com/glanceapp/glance.git
synced 2024-11-26 10:25:09 +01:00
574 lines
17 KiB
JavaScript
574 lines
17 KiB
JavaScript
function throttledDebounce(callback, maxDebounceTimes, debounceDelay) {
|
|
let debounceTimeout;
|
|
let timesDebounced = 0;
|
|
|
|
return function () {
|
|
if (timesDebounced == maxDebounceTimes) {
|
|
clearTimeout(debounceTimeout);
|
|
timesDebounced = 0;
|
|
callback();
|
|
return;
|
|
}
|
|
|
|
clearTimeout(debounceTimeout);
|
|
timesDebounced++;
|
|
|
|
debounceTimeout = setTimeout(() => {
|
|
timesDebounced = 0;
|
|
callback();
|
|
}, debounceDelay);
|
|
};
|
|
};
|
|
|
|
|
|
async function fetchPageContent(pageSlug) {
|
|
// TODO: handle non 200 status codes/time outs
|
|
// TODO: add retries
|
|
const response = await fetch(`/api/pages/${pageSlug}/content/`);
|
|
const content = await response.text();
|
|
|
|
return content;
|
|
}
|
|
|
|
function setupCarousels() {
|
|
const carouselElements = document.getElementsByClassName("carousel-container");
|
|
|
|
if (carouselElements.length == 0) {
|
|
return;
|
|
}
|
|
|
|
for (let i = 0; i < carouselElements.length; i++) {
|
|
const carousel = carouselElements[i];
|
|
carousel.classList.add("show-right-cutoff");
|
|
const itemsContainer = carousel.getElementsByClassName("carousel-items-container")[0];
|
|
|
|
const determineSideCutoffs = () => {
|
|
if (itemsContainer.scrollLeft != 0) {
|
|
carousel.classList.add("show-left-cutoff");
|
|
} else {
|
|
carousel.classList.remove("show-left-cutoff");
|
|
}
|
|
|
|
if (Math.ceil(itemsContainer.scrollLeft) + itemsContainer.clientWidth < itemsContainer.scrollWidth) {
|
|
carousel.classList.add("show-right-cutoff");
|
|
} else {
|
|
carousel.classList.remove("show-right-cutoff");
|
|
}
|
|
}
|
|
|
|
const determineSideCutoffsRateLimited = throttledDebounce(determineSideCutoffs, 20, 100);
|
|
|
|
itemsContainer.addEventListener("scroll", determineSideCutoffsRateLimited);
|
|
window.addEventListener("resize", determineSideCutoffsRateLimited);
|
|
|
|
afterContentReady(determineSideCutoffs);
|
|
}
|
|
}
|
|
|
|
const minuteInSeconds = 60;
|
|
const hourInSeconds = minuteInSeconds * 60;
|
|
const dayInSeconds = hourInSeconds * 24;
|
|
const monthInSeconds = dayInSeconds * 30;
|
|
const yearInSeconds = monthInSeconds * 12;
|
|
|
|
function relativeTimeSince(timestamp) {
|
|
const delta = Math.round((Date.now() / 1000) - timestamp);
|
|
|
|
if (delta < minuteInSeconds) {
|
|
return "1m";
|
|
}
|
|
if (delta < hourInSeconds) {
|
|
return Math.floor(delta / minuteInSeconds) + "m";
|
|
}
|
|
if (delta < dayInSeconds) {
|
|
return Math.floor(delta / hourInSeconds) + "h";
|
|
}
|
|
if (delta < monthInSeconds) {
|
|
return Math.floor(delta / dayInSeconds) + "d";
|
|
}
|
|
if (delta < yearInSeconds) {
|
|
return Math.floor(delta / monthInSeconds) + "mo";
|
|
}
|
|
|
|
return Math.floor(delta / yearInSeconds) + "y";
|
|
}
|
|
|
|
function updateRelativeTimeForElements(elements)
|
|
{
|
|
for (let i = 0; i < elements.length; i++)
|
|
{
|
|
const element = elements[i];
|
|
const timestamp = element.dataset.dynamicRelativeTime;
|
|
|
|
if (timestamp === undefined)
|
|
continue
|
|
|
|
element.textContent = relativeTimeSince(timestamp);
|
|
}
|
|
}
|
|
|
|
function setupSearchBoxes() {
|
|
const searchWidgets = document.getElementsByClassName("search");
|
|
|
|
if (searchWidgets.length == 0) {
|
|
return;
|
|
}
|
|
|
|
for (let i = 0; i < searchWidgets.length; i++) {
|
|
const widget = searchWidgets[i];
|
|
const defaultSearchUrl = widget.dataset.defaultSearchUrl;
|
|
const newTab = widget.dataset.newTab === "true";
|
|
const inputElement = widget.getElementsByClassName("search-input")[0];
|
|
const bangElement = widget.getElementsByClassName("search-bang")[0];
|
|
const bangs = widget.querySelectorAll(".search-bangs > input");
|
|
const bangsMap = {};
|
|
const kbdElement = widget.getElementsByTagName("kbd")[0];
|
|
let currentBang = null;
|
|
|
|
for (let j = 0; j < bangs.length; j++) {
|
|
const bang = bangs[j];
|
|
bangsMap[bang.dataset.shortcut] = bang;
|
|
}
|
|
|
|
const handleKeyDown = (event) => {
|
|
if (event.key == "Escape") {
|
|
inputElement.blur();
|
|
return;
|
|
}
|
|
|
|
if (event.key == "Enter") {
|
|
const input = inputElement.value.trim();
|
|
let query;
|
|
let searchUrlTemplate;
|
|
|
|
if (currentBang != null) {
|
|
query = input.slice(currentBang.dataset.shortcut.length + 1);
|
|
searchUrlTemplate = currentBang.dataset.url;
|
|
} else {
|
|
query = input;
|
|
searchUrlTemplate = defaultSearchUrl;
|
|
}
|
|
|
|
if (query.length == 0) {
|
|
return;
|
|
}
|
|
|
|
const url = searchUrlTemplate.replace("!QUERY!", encodeURIComponent(query));
|
|
|
|
if (newTab && !event.ctrlKey || !newTab && event.ctrlKey) {
|
|
window.open(url, '_blank').focus();
|
|
} else {
|
|
window.location.href = url;
|
|
}
|
|
|
|
return;
|
|
}
|
|
};
|
|
|
|
const changeCurrentBang = (bang) => {
|
|
currentBang = bang;
|
|
bangElement.textContent = bang != null ? bang.dataset.title : "";
|
|
}
|
|
|
|
const handleInput = (event) => {
|
|
const value = event.target.value.trimStart();
|
|
const words = value.split(" ");
|
|
|
|
if (words.length >= 2 && words[0] in bangsMap) {
|
|
changeCurrentBang(bangsMap[words[0]]);
|
|
return;
|
|
}
|
|
|
|
changeCurrentBang(null);
|
|
};
|
|
|
|
inputElement.addEventListener("focus", () => {
|
|
document.addEventListener("keydown", handleKeyDown);
|
|
document.addEventListener("input", handleInput);
|
|
});
|
|
inputElement.addEventListener("blur", () => {
|
|
document.removeEventListener("keydown", handleKeyDown);
|
|
document.removeEventListener("input", handleInput);
|
|
});
|
|
|
|
document.addEventListener("keydown", (event) => {
|
|
if (['INPUT', 'TEXTAREA'].includes(document.activeElement.tagName)) return;
|
|
if (event.key != "s") return;
|
|
|
|
inputElement.focus();
|
|
event.preventDefault();
|
|
});
|
|
|
|
kbdElement.addEventListener("mousedown", () => {
|
|
requestAnimationFrame(() => inputElement.focus());
|
|
});
|
|
}
|
|
}
|
|
|
|
function setupDynamicRelativeTime() {
|
|
const elements = document.querySelectorAll("[data-dynamic-relative-time]");
|
|
const updateInterval = 60 * 1000;
|
|
let lastUpdateTime = Date.now();
|
|
|
|
updateRelativeTimeForElements(elements);
|
|
|
|
const updateElementsAndTimestamp = () => {
|
|
updateRelativeTimeForElements(elements);
|
|
lastUpdateTime = Date.now();
|
|
};
|
|
|
|
const scheduleRepeatingUpdate = () => setInterval(updateElementsAndTimestamp, updateInterval);
|
|
|
|
if (document.hidden === undefined) {
|
|
scheduleRepeatingUpdate();
|
|
return;
|
|
}
|
|
|
|
let timeout = scheduleRepeatingUpdate();
|
|
|
|
document.addEventListener("visibilitychange", () => {
|
|
if (document.hidden) {
|
|
clearTimeout(timeout);
|
|
return;
|
|
}
|
|
|
|
const delta = Date.now() - lastUpdateTime;
|
|
|
|
if (delta >= updateInterval) {
|
|
updateElementsAndTimestamp();
|
|
timeout = scheduleRepeatingUpdate();
|
|
return;
|
|
}
|
|
|
|
timeout = setTimeout(() => {
|
|
updateElementsAndTimestamp();
|
|
timeout = scheduleRepeatingUpdate();
|
|
}, updateInterval - delta);
|
|
});
|
|
}
|
|
|
|
function setupLazyImages() {
|
|
const images = document.querySelectorAll("img[loading=lazy]");
|
|
|
|
if (images.length == 0) {
|
|
return;
|
|
}
|
|
|
|
function imageFinishedTransition(image) {
|
|
image.classList.add("finished-transition");
|
|
}
|
|
|
|
afterContentReady(() => {
|
|
setTimeout(() => {
|
|
for (let i = 0; i < images.length; i++) {
|
|
const image = images[i];
|
|
|
|
if (image.complete) {
|
|
image.classList.add("cached");
|
|
setTimeout(() => imageFinishedTransition(image), 1);
|
|
} else {
|
|
// TODO: also handle error event
|
|
image.addEventListener("load", () => {
|
|
image.classList.add("loaded");
|
|
setTimeout(() => imageFinishedTransition(image), 400);
|
|
});
|
|
}
|
|
}
|
|
}, 1);
|
|
});
|
|
}
|
|
|
|
function attachExpandToggleButton(collapsibleContainer) {
|
|
const showMoreText = "Show more";
|
|
const showLessText = "Show less";
|
|
|
|
let expanded = false;
|
|
const button = document.createElement("button");
|
|
const icon = document.createElement("span");
|
|
icon.classList.add("expand-toggle-button-icon");
|
|
const textNode = document.createTextNode(showMoreText);
|
|
button.classList.add("expand-toggle-button");
|
|
button.append(textNode, icon);
|
|
button.addEventListener("click", () => {
|
|
expanded = !expanded;
|
|
|
|
if (expanded) {
|
|
collapsibleContainer.classList.add("container-expanded");
|
|
button.classList.add("container-expanded");
|
|
textNode.nodeValue = showLessText;
|
|
return;
|
|
}
|
|
|
|
const topBefore = button.getClientRects()[0].top;
|
|
|
|
collapsibleContainer.classList.remove("container-expanded");
|
|
button.classList.remove("container-expanded");
|
|
textNode.nodeValue = showMoreText;
|
|
|
|
const topAfter = button.getClientRects()[0].top;
|
|
|
|
if (topAfter > 0)
|
|
return;
|
|
|
|
window.scrollBy({
|
|
top: topAfter - topBefore,
|
|
behavior: "instant"
|
|
});
|
|
});
|
|
|
|
collapsibleContainer.after(button);
|
|
|
|
return button;
|
|
};
|
|
|
|
|
|
function setupCollapsibleLists() {
|
|
const collapsibleLists = document.querySelectorAll(".list.collapsible-container");
|
|
|
|
if (collapsibleLists.length == 0) {
|
|
return;
|
|
}
|
|
|
|
for (let i = 0; i < collapsibleLists.length; i++) {
|
|
const list = collapsibleLists[i];
|
|
|
|
if (list.dataset.collapseAfter === undefined) {
|
|
continue;
|
|
}
|
|
|
|
const collapseAfter = parseInt(list.dataset.collapseAfter);
|
|
|
|
if (collapseAfter == -1) {
|
|
continue;
|
|
}
|
|
|
|
if (list.children.length <= collapseAfter) {
|
|
continue;
|
|
}
|
|
|
|
attachExpandToggleButton(list);
|
|
|
|
for (let c = collapseAfter; c < list.children.length; c++) {
|
|
const child = list.children[c];
|
|
child.classList.add("collapsible-item");
|
|
child.style.animationDelay = ((c - collapseAfter) * 20).toString() + "ms";
|
|
}
|
|
}
|
|
}
|
|
|
|
function setupCollapsibleGrids() {
|
|
const collapsibleGridElements = document.querySelectorAll(".cards-grid.collapsible-container");
|
|
|
|
if (collapsibleGridElements.length == 0) {
|
|
return;
|
|
}
|
|
|
|
for (let i = 0; i < collapsibleGridElements.length; i++) {
|
|
const gridElement = collapsibleGridElements[i];
|
|
|
|
if (gridElement.dataset.collapseAfterRows === undefined) {
|
|
continue;
|
|
}
|
|
|
|
const collapseAfterRows = parseInt(gridElement.dataset.collapseAfterRows);
|
|
|
|
if (collapseAfterRows == -1) {
|
|
continue;
|
|
}
|
|
|
|
const getCardsPerRow = () => {
|
|
return parseInt(getComputedStyle(gridElement).getPropertyValue('--cards-per-row'));
|
|
};
|
|
|
|
const button = attachExpandToggleButton(gridElement);
|
|
|
|
let cardsPerRow = 2;
|
|
|
|
const resolveCollapsibleItems = () => {
|
|
const hideItemsAfterIndex = cardsPerRow * collapseAfterRows;
|
|
|
|
if (hideItemsAfterIndex >= gridElement.children.length) {
|
|
button.style.display = "none";
|
|
} else {
|
|
button.style.removeProperty("display");
|
|
}
|
|
|
|
let row = 0;
|
|
|
|
for (let i = 0; i < gridElement.children.length; i++) {
|
|
const child = gridElement.children[i];
|
|
|
|
if (i >= hideItemsAfterIndex) {
|
|
child.classList.add("collapsible-item");
|
|
child.style.animationDelay = (row * 40).toString() + "ms";
|
|
|
|
if (i % cardsPerRow + 1 == cardsPerRow) {
|
|
row++;
|
|
}
|
|
} else {
|
|
child.classList.remove("collapsible-item");
|
|
child.style.removeProperty("animation-delay");
|
|
}
|
|
}
|
|
};
|
|
|
|
afterContentReady(() => {
|
|
cardsPerRow = getCardsPerRow();
|
|
resolveCollapsibleItems();
|
|
});
|
|
|
|
window.addEventListener("resize", () => {
|
|
const newCardsPerRow = getCardsPerRow();
|
|
|
|
if (cardsPerRow == newCardsPerRow) {
|
|
return;
|
|
}
|
|
|
|
cardsPerRow = newCardsPerRow;
|
|
resolveCollapsibleItems();
|
|
});
|
|
}
|
|
}
|
|
|
|
const contentReadyCallbacks = [];
|
|
|
|
function afterContentReady(callback) {
|
|
contentReadyCallbacks.push(callback);
|
|
}
|
|
|
|
const weekDayNames = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
|
|
const monthNames = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
|
|
|
|
function makeSettableTimeElement(element, hourFormat) {
|
|
const fragment = document.createDocumentFragment();
|
|
const hour = document.createElement('span');
|
|
const minute = document.createElement('span');
|
|
const amPm = document.createElement('span');
|
|
fragment.append(hour, document.createTextNode(':'), minute);
|
|
|
|
if (hourFormat == '12h') {
|
|
fragment.append(document.createTextNode(' '), amPm);
|
|
}
|
|
|
|
element.append(fragment);
|
|
|
|
return (date) => {
|
|
const hours = date.getHours();
|
|
|
|
if (hourFormat == '12h') {
|
|
amPm.textContent = hours < 12 ? 'AM' : 'PM';
|
|
hour.textContent = hours % 12 || 12;
|
|
} else {
|
|
hour.textContent = hours < 10 ? '0' + hours : hours;
|
|
}
|
|
|
|
const minutes = date.getMinutes();
|
|
minute.textContent = minutes < 10 ? '0' + minutes : minutes;
|
|
};
|
|
};
|
|
|
|
function timeInZone(now, zone) {
|
|
let timeInZone;
|
|
|
|
try {
|
|
timeInZone = new Date(now.toLocaleString('en-US', { timeZone: zone }));
|
|
} catch (e) {
|
|
// TODO: indicate to the user that this is an invalid timezone
|
|
console.error(e);
|
|
timeInZone = now
|
|
}
|
|
|
|
const diffInHours = Math.round((timeInZone.getTime() - now.getTime()) / 1000 / 60 / 60);
|
|
|
|
return { time: timeInZone, diffInHours: diffInHours };
|
|
}
|
|
|
|
function setupClocks() {
|
|
const clocks = document.getElementsByClassName('clock');
|
|
|
|
if (clocks.length == 0) {
|
|
return;
|
|
}
|
|
|
|
const updateCallbacks = [];
|
|
|
|
for (var i = 0; i < clocks.length; i++) {
|
|
const clock = clocks[i];
|
|
const hourFormat = clock.dataset.hourFormat;
|
|
const localTimeContainer = clock.querySelector('[data-local-time]');
|
|
const localDateElement = localTimeContainer.querySelector('[data-date]');
|
|
const localWeekdayElement = localTimeContainer.querySelector('[data-weekday]');
|
|
const localYearElement = localTimeContainer.querySelector('[data-year]');
|
|
const timeZoneContainers = clock.querySelectorAll('[data-time-in-zone]');
|
|
|
|
const setLocalTime = makeSettableTimeElement(
|
|
localTimeContainer.querySelector('[data-time]'),
|
|
hourFormat
|
|
);
|
|
|
|
updateCallbacks.push((now) => {
|
|
setLocalTime(now);
|
|
localDateElement.textContent = now.getDate() + ' ' + monthNames[now.getMonth()];
|
|
localWeekdayElement.textContent = weekDayNames[now.getDay()];
|
|
localYearElement.textContent = now.getFullYear();
|
|
});
|
|
|
|
for (var z = 0; z < timeZoneContainers.length; z++) {
|
|
const timeZoneContainer = timeZoneContainers[z];
|
|
const diffElement = timeZoneContainer.querySelector('[data-time-diff]');
|
|
|
|
const setZoneTime = makeSettableTimeElement(
|
|
timeZoneContainer.querySelector('[data-time]'),
|
|
hourFormat
|
|
);
|
|
|
|
updateCallbacks.push((now) => {
|
|
const { time, diffInHours } = timeInZone(now, timeZoneContainer.dataset.timeInZone);
|
|
setZoneTime(time);
|
|
diffElement.textContent = (diffInHours <= 0 ? diffInHours : '+' + diffInHours) + 'h';
|
|
});
|
|
}
|
|
}
|
|
|
|
const updateClocks = () => {
|
|
const now = new Date();
|
|
|
|
for (var i = 0; i < updateCallbacks.length; i++)
|
|
updateCallbacks[i](now);
|
|
|
|
setTimeout(updateClocks, (60 - now.getSeconds()) * 1000);
|
|
};
|
|
|
|
updateClocks();
|
|
}
|
|
|
|
async function setupPage() {
|
|
const pageElement = document.getElementById("page");
|
|
const pageContentElement = document.getElementById("page-content");
|
|
const pageContent = await fetchPageContent(pageData.slug);
|
|
|
|
pageContentElement.innerHTML = pageContent;
|
|
|
|
try {
|
|
setupClocks()
|
|
setupCarousels();
|
|
setupSearchBoxes();
|
|
setupCollapsibleLists();
|
|
setupCollapsibleGrids();
|
|
setupDynamicRelativeTime();
|
|
setupLazyImages();
|
|
} finally {
|
|
pageElement.classList.add("content-ready");
|
|
|
|
for (let i = 0; i < contentReadyCallbacks.length; i++) {
|
|
contentReadyCallbacks[i]();
|
|
}
|
|
|
|
setTimeout(() => {
|
|
document.body.classList.add("page-columns-transitioned");
|
|
}, 300);
|
|
}
|
|
}
|
|
|
|
setupPage();
|