diff --git a/README.md b/README.md index 11ffb42..a9de87b 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ * Weather * Bookmarks * Latest YouTube videos from specific channels +* Clock * Calendar * Stocks * iframe @@ -18,6 +19,7 @@ * GitHub releases * Repository overview * Site monitor +* Search box #### Themeable  diff --git a/docs/configuration.md b/docs/configuration.md index 6767e69..90d9d7d 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -12,12 +12,14 @@ - [Hacker News](#hacker-news) - [Lobsters](#lobsters) - [Reddit](#reddit) + - [Search](#search-widget) - [Weather](#weather) - [Monitor](#monitor) - [Releases](#releases) - [Repository](#repository) - [Bookmarks](#bookmarks) - [Calendar](#calendar) + - [Clock](#clock) - [Stocks](#stocks) - [Twitch Channels](#twitch-channels) - [Twitch Top Games](#twitch-top-games) @@ -35,6 +37,7 @@ pages: columns: - size: small widgets: + - type: clock - type: calendar - type: rss @@ -683,6 +686,80 @@ Can be used to specify an additional sort which will be applied on top of the al The `engagement` sort tries to place the posts with the most points and comments on top, also prioritizing recent over old posts. +### Search Widget +Display a search bar that can be used to search for specific terms on various search engines. + +Example: + +```yaml +- type: search + search-engine: duckduckgo + bangs: + - title: YouTube + shortcut: "!yt" + url: https://www.youtube.com/results?search_query={QUERY} +``` + +Preview: + + + +#### Keyboard shortcuts +| Keys | Action | Condition | +| ---- | ------ | --------- | +| S | Focus the search bar | Not already focused on another input field | +| Enter | Perform search in the same tab | Search input is focused and not empty | +| Ctrl + Enter | Perform search in a new tab | Search input is focused and not empty | +| Escape | Leave focus | Search input is focused | + +#### Properties +| Name | Type | Required | Default | +| ---- | ---- | -------- | ------- | +| search-engine | string | no | duckduckgo | +| bangs | array | no | | + +##### `search-engine` +Either a value from the table below or a URL to a custom search engine. Use `{QUERY}` to indicate where the query value gets placed. + +| Name | URL | +| ---- | --- | +| duckduckgo | `https://duckduckgo.com/?q={QUERY}` | +| google | `https://www.google.com/search?q={QUERY}` | + +##### `bangs` +What now? [Bangs](https://duckduckgo.com/bangs). They're shortcuts that allow you to use the same search box for many different sites. Assuming you have it configured, if for example you start your search input with `!yt` you'd be able to perform a search on YouTube: + + + +##### Properties for each bang +| Name | Type | Required | +| ---- | ---- | -------- | +| title | string | no | +| shortcut | string | yes | +| url | string | yes | + +###### `title` +Optional title that will appear on the right side of the search bar when the query starts with the associated shortcut. + +###### `shortcut` +Any value you wish to use as the shortcut for the search engine. It does not have to start with `!`. + +> [!IMPORTANT] +> +> In YAML some characters have special meaning when placed in the beginning of a value. If your shortcut starts with `!` (and potentially some other special characters) you'll have to wrap the value in quotes: +> ```yaml +> shortcut: "!yt" +>``` + +###### `url` +The URL of the search engine. Use `{QUERY}` to indicate where the query value gets placed. Examples: + +```yaml +url: https://www.reddit.com/search?q={QUERY} +url: https://store.steampowered.com/search/?term={QUERY} +url: https://www.amazon.com/s?k={QUERY} +``` + ### Weather Display weather information for a specific location. The data is provided by https://open-meteo.com/. @@ -691,6 +768,7 @@ Example: ```yaml - type: weather units: metric + hour-format: 12h location: London, United Kingdom ``` @@ -715,6 +793,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 | @@ -724,6 +803,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. @@ -1002,6 +1084,51 @@ Whether to open the link in the same tab or a new one. Whether to hide the colored arrow on each link. +### Clock +Display a clock showing the current time and date. Optionally, also display the the time in other timezones. + +Example: + +```yaml +- type: clock + hour-format: 24h + timezones: + - timezone: Europe/Paris + label: Paris + - timezone: America/New_York + label: New York + - timezone: Asia/Tokyo + label: Tokyo +``` + +Preview: + + + +#### Properties + +| Name | Type | Required | Default | +| ---- | ---- | -------- | ------- | +| hour-format | string | no | 24h | +| timezones | array | no | | + +##### `hour-format` +Whether to show the time in 12 or 24 hour format. Possible values are `12h` and `24h`. + +#### Properties for each timezone + +| Name | Type | Required | Default | +| ---- | ---- | -------- | ------- | +| timezone | string | yes | | +| label | string | no | | + +##### `timezone` +A timezone identifier such as `Europe/London`, `America/New_York`, etc. The full list of available identifiers can be found [here](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones). + +##### `label` +Optionally, override the display value for the timezone to something more meaningful such as "Home", "Work" or anything else. + + ### Calendar Display a calendar. @@ -1107,6 +1234,7 @@ Preview: | ---- | ---- | -------- | ------- | | channels | array | yes | | | collapse-after | integer | no | 5 | +| sort-by | string | no | viewers | ##### `channels` A list of channels to display. @@ -1114,6 +1242,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/docs/images/clock-widget-preview.png b/docs/images/clock-widget-preview.png new file mode 100644 index 0000000..bf809c5 Binary files /dev/null and b/docs/images/clock-widget-preview.png differ diff --git a/docs/images/search-widget-bangs-preview.png b/docs/images/search-widget-bangs-preview.png new file mode 100644 index 0000000..9490690 Binary files /dev/null and b/docs/images/search-widget-bangs-preview.png differ diff --git a/docs/images/search-widget-preview.png b/docs/images/search-widget-preview.png new file mode 100644 index 0000000..9672a77 Binary files /dev/null and b/docs/images/search-widget-preview.png differ 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 1e64def..7f98e94 100644 --- a/internal/assets/static/main.css +++ b/internal/assets/static/main.css @@ -37,6 +37,7 @@ --ths: var(--bgh), calc(var(--bgs) * var(--tsm)); --color-text-base: hsl(var(--ths), calc(var(--scheme) var(--cm) * 58%)); + --color-text-base-muted: hsl(var(--ths), calc(var(--scheme) var(--cm) * 52%)); --color-text-highlight: hsl(var(--ths), calc(var(--scheme) var(--cm) * 85%)); --color-text-subdue: hsl(var(--ths), calc(var(--scheme) var(--cm) * 35%)); @@ -57,6 +58,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); } @@ -71,14 +80,16 @@ white-space: nowrap; } -.text-truncate-3-lines { +.text-truncate-2-lines, .text-truncate-3-lines { overflow: hidden; text-overflow: ellipsis; - -webkit-line-clamp: 3; display: -webkit-box; -webkit-box-orient: vertical; } +.text-truncate-3-lines { -webkit-line-clamp: 3; } +.text-truncate-2-lines { -webkit-line-clamp: 2; } + .visited-indicator:not(.text-truncate)::after, .visited-indicator.text-truncate::before, .bookmarks-link:not(.bookmarks-link-no-arrow)::after { @@ -106,6 +117,7 @@ .list-gap-14 { --list-half-gap: 0.7rem; } .list-gap-20 { --list-half-gap: 1rem; } .list-gap-24 { --list-half-gap: 1.2rem; } +.list-gap-34 { --list-half-gap: 1.7rem; } .list > *:not(:first-child) { margin-top: calc(var(--list-half-gap) * 2); @@ -117,70 +129,85 @@ 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); +} + +.attachments { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-left: -0.5rem; +} + +.attachments > * { + border-radius: var(--border-radius); + padding: 0.1rem 0.5rem; + font-size: var(--font-size-h6); + background-color: var(--color-separator); +} + ::selection { background-color: hsl(var(--bghs), calc(var(--scheme) (var(--scheme) var(--bgl) + 20%))); color: var(--color-text-highlight); @@ -327,6 +354,23 @@ body { border: 1px solid var(--color-negative); } +kbd { + font: inherit; + padding: 0.1rem 0.8rem; + border-radius: var(--border-radius); + border: 2px solid var(--color-widget-background-highlight); + box-shadow: 0 2px 0 var(--color-widget-background-highlight); + user-select: none; + transition: transform .1s, box-shadow .1s; + font-size: var(--font-size-h5); + cursor: pointer; +} + +kbd:active { + transform: translateY(2px); + box-shadow: 0 0 0 0 var(--color-widget-background-highlight); +} + .content-bounds { max-width: 1600px; margin-inline: auto; @@ -638,6 +682,85 @@ body { -webkit-box-orient: vertical; } +.search-icon { + width: 2.3rem; +} + +.search-icon-container { + position: relative; + flex-shrink: 0; +} + +/* gives a wider hit area for the 3 people that will notice the animation : ) */ +.search-icon-container::before { + content: ''; + position: absolute; + inset: -1rem; +} + +.search-icon-container:hover > .search-icon { + animation: searchIconHover 2.9s forwards; +} + +@keyframes searchIconHover { + 0%, 39% { translate: 0 0; } + 20% { scale: 1.3; } + 40% { scale: 1; } + 50% { translate: -30% 30%; } + 70% { translate: 30% -30%; } + 90% { translate: -30% -30%; } + 100% { translate: 0 0; } +} + +.search { + transition: border-color .2s; + position: relative; +} + +.search:hover { + border-color: var(--color-text-subdue); +} + +.search:focus-within { + border-color: var(--color-primary); +} + +.search-input { + border: 0; + background: none; + width: 100%; + height: 6rem; + font: inherit; + outline: none; +} + +.search-input::placeholder { + color: var(--color-text-base-muted); + opacity: 1; +} + +.search-bangs { display: none; } + +.search-bang { + border-radius: calc(var(--border-radius) * 2); + background: var(--color-widget-background-highlight); + padding: 0.3rem 1rem; + flex-shrink: 0; + font-size: var(--font-size-h5); + animation: searchBangsEntrance .3s cubic-bezier(0.25, 1, 0.5, 1) backwards; +} + +@keyframes searchBangsEntrance { + 0% { + opacity: 0; + transform: translateX(-10px); + } +} + +.search-bang:empty { + display: none; +} + .forum-post-list-item { display: flex; gap: 1.2rem; @@ -706,7 +829,7 @@ body { flex-direction: column; width: calc(100% / 12); padding-top: 3px; - max-width: 3.5rem; + max-width: 30px; } .weather-column-value, .weather-columns:hover .weather-column-value { @@ -840,6 +963,10 @@ body { transform: translate(-50%, -50%); } +.clock-time span { + color: var(--color-text-highlight); +} + .monitor-site-icon { display: block; opacity: 0.8; @@ -866,11 +993,22 @@ 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 { +.thumbnail-container { + flex-shrink: 0; + border: 1px solid var(--color-separator); + border-radius: var(--border-radius); +} + +.thumbnail-container > * { + border-radius: var(--border-radius); + object-fit: cover; +} + +.thumbnail-parent:hover .thumbnail { opacity: 1; filter: none; } @@ -918,6 +1056,20 @@ body { z-index: 3; } +.rss-detailed-description { + max-width: 55rem; + color: var(--color-text-base-muted); +} + +.rss-detailed-thumbnail { + margin-top: 0.3rem; +} + +.rss-detailed-thumbnail > * { + aspect-ratio: 3 / 2; + height: 8.7rem; +} + .twitch-category-thumbnail { width: 5rem; border-radius: var(--border-radius); @@ -996,10 +1148,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 +1259,48 @@ 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); + } + + .weather-column-rain::before { + background-size: 7px 7px; + } +} + +@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) { @@ -1123,22 +1314,30 @@ body { .dynamic-columns:has(> :nth-child(1)) { --columns-per-row: 1; } - .forum-post-list-item { - flex-flow: row-reverse; + .row-reverse-on-mobile { + flex-direction: row-reverse; } - .hide-on-mobile { + .hide-on-mobile, .thumbnail-container:has(> .hide-on-mobile) { display: none } .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; } + + .rss-detailed-thumbnail > * { + height: 6rem; + } + + .rss-detailed-description { + -webkit-line-clamp: 3; + } } .size-h1 { font-size: var(--font-size-h1); } @@ -1166,6 +1365,7 @@ body { .shrink { flex-shrink: 1; } .shrink-0 { flex-shrink: 0; } .min-width-0 { min-width: 0; } +.max-width-100 { max-width: 100%; } .block { display: block; } .overflow-hidden { overflow: hidden; } .relative { position: relative; } @@ -1185,6 +1385,10 @@ body { .gap-7 { gap: 0.7rem; } .gap-10 { gap: 1rem; } .gap-15 { gap: 1.5rem; } +.gap-25 { gap: 2.5rem; } +.gap-35 { gap: 3.5rem; } +.gap-45 { gap: 4.5rem; } +.gap-55 { gap: 5.5rem; } .margin-top-3 { margin-top: 0.3rem; } .margin-top-5 { margin-top: 0.5rem; } .margin-top-7 { margin-top: 0.7rem; } @@ -1201,3 +1405,4 @@ body { .margin-bottom-10 { margin-bottom: 1rem; } .margin-bottom-15 { margin-bottom: 1.5rem; } .margin-bottom-auto { margin-bottom: auto; } +.scale-half { transform: scale(0.5); } diff --git a/internal/assets/static/main.js b/internal/assets/static/main.js index 05dbc45..3e10b96 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 = () => { @@ -54,9 +59,9 @@ function setupCarousels() { const determineSideCutoffsRateLimited = throttledDebounce(determineSideCutoffs, 20, 100); itemsContainer.addEventListener("scroll", determineSideCutoffsRateLimited); - document.addEventListener("resize", determineSideCutoffsRateLimited); + window.addEventListener("resize", determineSideCutoffsRateLimited); - determineSideCutoffs(); + afterContentReady(determineSideCutoffs); } } @@ -98,7 +103,104 @@ function updateRelativeTimeForElements(elements) if (timestamp === undefined) continue - element.innerText = relativeTimeSince(timestamp); + 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 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 (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()); + }); } } @@ -107,6 +209,8 @@ function setupDynamicRelativeTime() { const updateInterval = 60 * 1000; let lastUpdateTime = Date.now(); + updateRelativeTimeForElements(elements); + const updateElementsAndTimestamp = () => { updateRelativeTimeForElements(elements); lastUpdateTime = Date.now(); @@ -153,35 +257,316 @@ 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"; } } } +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 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 { + setupClocks() + setupCarousels(); + setupSearchboxes(); + 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..668b289 --- /dev/null +++ b/internal/assets/static/manifest.json @@ -0,0 +1,14 @@ +{ + "name": "Glance", + "display": "standalone", + "background_color": "#151519", + "scope": "/", + "start_url": "/", + "icons": [ + { + "src": "/static/app-icon.png", + "type": "image/png", + "sizes": "512x512" + } + ] +} diff --git a/internal/assets/templates.go b/internal/assets/templates.go index b8aa6ae..6ff1c89 100644 --- a/internal/assets/templates.go +++ b/internal/assets/templates.go @@ -15,6 +15,7 @@ var ( PageTemplate = compileTemplate("page.html", "document.html", "page-style-overrides.gotmpl") PageContentTemplate = compileTemplate("content.html") CalendarTemplate = compileTemplate("calendar.html", "widget-base.html") + ClockTemplate = compileTemplate("clock.html", "widget-base.html") BookmarksTemplate = compileTemplate("bookmarks.html", "widget-base.html") IFrameTemplate = compileTemplate("iframe.html", "widget-base.html") WeatherTemplate = compileTemplate("weather.html", "widget-base.html") @@ -22,16 +23,19 @@ var ( RedditCardsHorizontalTemplate = compileTemplate("reddit-horizontal-cards.html", "widget-base.html") RedditCardsVerticalTemplate = compileTemplate("reddit-vertical-cards.html", "widget-base.html") ReleasesTemplate = compileTemplate("releases.html", "widget-base.html") + ChangeDetectionTemplate = compileTemplate("change-detection.html", "widget-base.html") VideosTemplate = compileTemplate("videos.html", "widget-base.html", "video-card-contents.html") VideosGridTemplate = compileTemplate("videos-grid.html", "widget-base.html", "video-card-contents.html") StocksTemplate = compileTemplate("stocks.html", "widget-base.html") RSSListTemplate = compileTemplate("rss-list.html", "widget-base.html") + RSSDetailedListTemplate = compileTemplate("rss-detailed-list.html", "widget-base.html") RSSHorizontalCardsTemplate = compileTemplate("rss-horizontal-cards.html", "widget-base.html") RSSHorizontalCards2Template = compileTemplate("rss-horizontal-cards-2.html", "widget-base.html") MonitorTemplate = compileTemplate("monitor.html", "widget-base.html") TwitchGamesListTemplate = compileTemplate("twitch-games-list.html", "widget-base.html") TwitchChannelsTemplate = compileTemplate("twitch-channels.html", "widget-base.html") RepositoryTemplate = compileTemplate("repository.html", "widget-base.html") + SearchTemplate = compileTemplate("search.html", "widget-base.html") ) var globalTemplateFunctions = template.FuncMap{ diff --git a/internal/assets/templates/change-detection.html b/internal/assets/templates/change-detection.html new file mode 100644 index 0000000..22b7a18 --- /dev/null +++ b/internal/assets/templates/change-detection.html @@ -0,0 +1,17 @@ +{{ template "widget-base.html" . }} + +{{ define "widget-content" }} +
{{ .Description }}
+ {{ end }} + {{ if gt (len .Categories) 0 }} + + {{ end }} +