diff --git a/docs/configuration.md b/docs/configuration.md index 7c04d04..9143e0f 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -647,6 +647,7 @@ Example: ```yaml - type: weather units: metric + hour-format: 12h location: London, United Kingdom ``` @@ -671,6 +672,7 @@ Each bar represents a 2 hour interval. The yellow background represents sunrise | ---- | ---- | -------- | ------- | | location | string | yes | | | units | string | no | metric | +| hour-format | string | no | 12h | | hide-location | boolean | no | false | | show-area-name | boolean | no | false | @@ -680,6 +682,9 @@ The name of the city and country to fetch weather information for. Attempting to ##### `units` Whether to show the temperature in celsius or fahrenheit, possible values are `metric` or `imperial`. +#### `hour-format` +Whether to show the hours of the day in 12-hour format or 24-hour format. Possible values are `12h` and `24h`. + ##### `hide-location` Optionally don't display the location name on the widget. @@ -1063,6 +1068,7 @@ Preview: | ---- | ---- | -------- | ------- | | channels | array | yes | | | collapse-after | integer | no | 5 | +| sort-by | string | no | viewers | ##### `channels` A list of channels to display. @@ -1070,6 +1076,9 @@ A list of channels to display. ##### `collapse-after` How many channels are visible before the "SHOW MORE" button appears. Set to `-1` to never collapse. +##### `sort-by` +Can be used to specify the order in which the channels are displayed. Possible values are `viewers` and `live`. + ### Twitch top games Display a list of games with the most viewers on Twitch. diff --git a/internal/assets/static/app-icon.png b/internal/assets/static/app-icon.png new file mode 100644 index 0000000..54fc413 Binary files /dev/null and b/internal/assets/static/app-icon.png differ diff --git a/internal/assets/static/main.css b/internal/assets/static/main.css index 1df960e..c59ce9e 100644 --- a/internal/assets/static/main.css +++ b/internal/assets/static/main.css @@ -57,6 +57,14 @@ font-size: var(--font-size-h4); } +.page-content, .page.content-ready .page-loading-container { + display: none; +} + +.page.content-ready > .page-content { + display: block; +} + .page-column-full .size-title-dynamic { font-size: var(--font-size-h3); } @@ -117,70 +125,71 @@ padding-top: var(--list-half-gap); } -@keyframes listItemReveal { +.collapsible-container:not(.container-expanded) > .collapsible-item { + display: none; +} + +.collapsible-item { + animation: collapsibleItemReveal .25s backwards; +} + +@keyframes collapsibleItemReveal { from { opacity: 0; transform: translateY(10px); } } -.list-collapsible-item { - display: none; - animation: listItemReveal 0.3s backwards; - animation-delay: var(--animation-delay); -} - -.list-collapsible-label { - display: flex; - align-items: center; - gap: 1rem; +.expand-toggle-button { + font: inherit; + border: 0; + cursor: pointer; + display: block; + width: 100%; + text-align: left; + color: var(--color-text-base); + text-transform: uppercase; + font-size: var(--font-size-h4); padding: var(--widget-content-vertical-padding) 0; background: var(--color-widget-background); } -.list-collapsible-label:has(.list-collapsible-input:checked) { +.expand-toggle-button.container-expanded { position: sticky; - bottom: 0; + /* -1px to hide 1px gap on chrome */ + bottom: -1px; } -.list-collapsible:has(+ .list-collapsible-label > .list-collapsible-input:checked) .list-collapsible-item { - display: block; +.expand-toggle-button-icon { + display: inline-block; + margin-left: 1rem; + position: relative; + top: -.2rem; } -.list-collapsible-input { - display: none; -} - -.list-collapsible-label::before, .list-collapsible-label::after { - cursor: pointer; - display: block; -} - -.list-collapsible-label::before { - content: 'SHOW MORE'; - font-size: var(--font-size-h4); -} - -.list-collapsible-label:has(.list-collapsible-input:checked)::before { - content: 'SHOW LESS'; -} - -.list-collapsible-label::after { +.expand-toggle-button-icon::before { content: ''; font-size: 0.8rem; transform: rotate(90deg); line-height: 1; + display: inline-block; transition: transform 0.3s; } -.list-collapsible-label:has(.list-collapsible-input:checked)::after { +.expand-toggle-button.container-expanded .expand-toggle-button-icon::before { transform: rotate(-90deg); } -.widget-content:has(.list-collapsible-label:last-child) { +.widget-content:has(.expand-toggle-button:last-child) { padding-bottom: 0; } +.cards-grid.collapsible-container + .expand-toggle-button { + text-align: center; + margin-top: 0.5rem; + background-color: var(--color-background); +} + ::selection { background-color: hsl(var(--bghs), calc(var(--scheme) (var(--scheme) var(--bgl) + 20%))); color: var(--color-text-highlight); @@ -706,7 +715,7 @@ body { flex-direction: column; width: calc(100% / 12); padding-top: 3px; - max-width: 3.5rem; + max-width: 3rem; } .weather-column-value, .weather-columns:hover .weather-column-value { @@ -866,8 +875,8 @@ body { .thumbnail { filter: grayscale(0.2) contrast(0.9); - transition: all 0.2s; opacity: 0.8; + transition: filter 0.2s, opacity .2s; } .thumbnail-container:hover .thumbnail { @@ -996,10 +1005,10 @@ body { .page-column { display: none; - animation: columnEntrance 0s cubic-bezier(0.25, 1, 0.5, 1) backwards; + animation: columnEntrance .0s cubic-bezier(0.25, 1, 0.5, 1) backwards; } - .animate-element-transition .page-column { + .page-columns-transitioned .page-column { animation-duration: .3s; } @@ -1107,9 +1116,44 @@ body { box-shadow: 0 calc(var(--spacing) * -1) 0 0 currentColor, 0 var(--spacing) 0 0 currentColor; } - .list-collapsible-label:has(.list-collapsible-input:checked) { + .expand-toggle-button.container-expanded { bottom: var(--mobile-navigation-height); } + + .cards-grid + .expand-toggle-button.container-expanded { + /* hides content that peeks through the rounded borders of the mobile navigation */ + box-shadow: 0 var(--border-radius) 0 0 var(--color-background); + } +} + +@media (max-width: 1190px) and (display-mode: standalone) { + :root { + --safe-area-inset-bottom: env(safe-area-inset-bottom, 0); + } + + .list-collapsible-label:has(.list-collapsible-input:checked) { + bottom: calc(var(--mobile-navigation-height) + var(--safe-area-inset-bottom)); + } + + .mobile-navigation { + transform: translateY(calc(100% - var(--mobile-navigation-height) - var(--safe-area-inset-bottom))); + padding-bottom: var(--safe-area-inset-bottom); + } + + .mobile-navigation-icons { + padding-bottom: var(--safe-area-inset-bottom); + transition: padding-bottom .3s; + } + + .mobile-navigation-icons:has(.mobile-navigation-page-links-input:checked) { + padding-bottom: 0; + } +} + +@media (display-mode: standalone) { + body { + padding-top: env(safe-area-inset-top, 0); + } } @media (max-width: 550px) { @@ -1134,7 +1178,7 @@ body { .mobile-reachability-header { display: block; font-size: 3rem; - padding: 10dvh 1rem; + padding: 10vh 1rem; text-align: center; color: var(--color-text-highlight); animation: pageColumnsEntrance .3s cubic-bezier(0.25, 1, 0.5, 1) backwards; diff --git a/internal/assets/static/main.js b/internal/assets/static/main.js index 05dbc45..fcc2043 100644 --- a/internal/assets/static/main.js +++ b/internal/assets/static/main.js @@ -21,7 +21,7 @@ function throttledDebounce(callback, maxDebounceTimes, debounceDelay) { }; -async function fetchPageContents (pageSlug) { +async function fetchPageContent(pageSlug) { // TODO: handle non 200 status codes/time outs // TODO: add retries const response = await fetch(`/api/pages/${pageSlug}/content/`); @@ -33,8 +33,13 @@ async function fetchPageContents (pageSlug) { 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 = () => { @@ -56,7 +61,7 @@ function setupCarousels() { itemsContainer.addEventListener("scroll", determineSideCutoffsRateLimited); document.addEventListener("resize", determineSideCutoffsRateLimited); - determineSideCutoffs(); + afterContentReady(determineSideCutoffs); } } @@ -107,6 +112,8 @@ function setupDynamicRelativeTime() { const updateInterval = 60 * 1000; let lastUpdateTime = Date.now(); + updateRelativeTimeForElements(elements); + const updateElementsAndTimestamp = () => { updateRelativeTimeForElements(elements); lastUpdateTime = Date.now(); @@ -153,35 +160,211 @@ function setupLazyImages() { image.classList.add("finished-transition"); } - for (let i = 0; i < images.length; i++) { - const image = images[i]; + afterContentReady(() => { + setTimeout(() => { + for (let i = 0; i < images.length; i++) { + const image = images[i]; - if (image.complete) { - image.classList.add("cached"); - setTimeout(() => imageFinishedTransition(image), 5); - } else { - // TODO: also handle error event - image.addEventListener("load", () => { - image.classList.add("loaded"); - setTimeout(() => imageFinishedTransition(image), 500); - }); + 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"; + } + + list.classList.add("ready"); + } +} + +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(); + gridElement.classList.add("ready"); + }); + + window.addEventListener("resize", () => { + const newCardsPerRow = getCardsPerRow(); + + if (cardsPerRow == newCardsPerRow) { + return; + } + + cardsPerRow = newCardsPerRow; + resolveCollapsibleItems(); + }); + } +} + +const contentReadyCallbacks = []; + +function afterContentReady(callback) { + contentReadyCallbacks.push(callback); } async function setupPage() { const pageElement = document.getElementById("page"); - const pageContents = await fetchPageContents(pageData.slug); + const pageContentElement = document.getElementById("page-content"); + const pageContent = await fetchPageContent(pageData.slug); - pageElement.innerHTML = pageContents; + pageContentElement.innerHTML = pageContent; - setTimeout(() => { - document.body.classList.add("animate-element-transition"); - }, 150); + try { + setupCarousels(); + setupCollapsibleLists(); + setupCollapsibleGrids(); + setupDynamicRelativeTime(); + setupLazyImages(); + } finally { + pageElement.classList.add("content-ready"); - setTimeout(setupLazyImages, 5); - setupCarousels(); - setupDynamicRelativeTime(); + for (let i = 0; i < contentReadyCallbacks.length; i++) { + contentReadyCallbacks[i](); + } + + setTimeout(() => { + document.body.classList.add("page-columns-transitioned"); + }, 300); + } } if (document.readyState === "loading") { diff --git a/internal/assets/static/manifest.json b/internal/assets/static/manifest.json new file mode 100644 index 0000000..8ce7aa8 --- /dev/null +++ b/internal/assets/static/manifest.json @@ -0,0 +1,13 @@ +{ + "name": "Glance", + "display": "standalone", + "scope": "/", + "start_url": "/", + "icons": [ + { + "src": "/static/app-icon.png", + "type": "image/png", + "sizes": "512x512" + } + ] +} \ No newline at end of file diff --git a/internal/assets/templates/document.html b/internal/assets/templates/document.html index 04984f8..d126d8b 100644 --- a/internal/assets/templates/document.html +++ b/internal/assets/templates/document.html @@ -5,7 +5,15 @@ {{ block "document-title" . }}{{ end }} - + + + + + + + + + diff --git a/internal/assets/templates/forum-posts.html b/internal/assets/templates/forum-posts.html index 5efec4d..d8d995a 100644 --- a/internal/assets/templates/forum-posts.html +++ b/internal/assets/templates/forum-posts.html @@ -1,14 +1,14 @@ {{ template "widget-base.html" . }} {{ define "widget-content" }} -