Merge branch 'release/v0.6.0' into main

This commit is contained in:
Svilen Markov
2024-08-05 13:26:46 +01:00
committed by GitHub
79 changed files with 2659 additions and 336 deletions

View File

@@ -1,8 +1,14 @@
package assets
import (
"crypto/md5"
"embed"
"encoding/hex"
"io"
"io/fs"
"log/slog"
"strconv"
"time"
)
//go:embed static
@@ -13,3 +19,38 @@ var _templateFS embed.FS
var PublicFS, _ = fs.Sub(_publicFS, "static")
var TemplateFS, _ = fs.Sub(_templateFS, "templates")
func getFSHash(files fs.FS) string {
hash := md5.New()
err := fs.WalkDir(files, ".", func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
return nil
}
file, err := files.Open(path)
if err != nil {
return err
}
if _, err := io.Copy(hash, file); err != nil {
return err
}
return nil
})
if err == nil {
return hex.EncodeToString(hash.Sum(nil))[:10]
}
slog.Warn("Could not compute assets cache", "err", err)
return strconv.FormatInt(time.Now().Unix(), 10)
}
var PublicFSHash = getFSHash(PublicFS)

View File

@@ -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,10 @@
font-size: var(--font-size-h4);
}
.page {
height: 100%;
}
.page-content, .page.content-ready .page-loading-container {
display: none;
}
@@ -79,14 +84,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 {
@@ -114,6 +121,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);
@@ -180,6 +188,57 @@
transform: rotate(-90deg);
}
.widget-group-header {
overflow-x: auto;
scrollbar-width: thin;
}
.widget-group-title {
background: none;
font: inherit;
border: none;
color: inherit;
text-transform: uppercase;
border-bottom: 1px solid transparent;
cursor: pointer;
flex-shrink: 0;
padding-bottom: 0.1rem;
transition: color .3s, border-color .3s;
}
.widget-group-title:hover:not(.widget-group-title-current) {
border-bottom-color: var(--color-text-subdue);
color: var(--color-text-highlight);
}
.widget-group-title-current {
border-bottom-color: var(--color-primary);
color: var(--color-text-highlight);
}
.widget-group-content {
animation: widgetGroupContentEntrance .3s cubic-bezier(0.25, 1, 0.5, 1) backwards;
}
.widget-group-content[data-direction="right"] {
--direction: 5px;
}
.widget-group-content[data-direction="left"] {
--direction: -5px;
}
@keyframes widgetGroupContentEntrance {
from {
opacity: 0;
transform: translateX(var(--direction));
}
}
.widget-group-content:not(.widget-group-content-current) {
display: none;
}
.widget-content:has(.expand-toggle-button:last-child) {
padding-bottom: 0;
}
@@ -190,9 +249,17 @@
background-color: var(--color-background);
}
/* required to prevent collapsed lazy images from being loaded while the container is being setup */
.collapsible-container:not(.ready) img[loading=lazy] {
display: none;
.attachments {
display: flex;
flex-wrap: wrap;
gap: 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 {
@@ -248,9 +315,14 @@ html {
scroll-behavior: smooth;
}
html, body {
height: 100%;
}
a {
text-decoration: none;
color: inherit;
overflow-wrap: break-word;
}
ul {
@@ -280,9 +352,8 @@ body {
.page-columns {
display: flex;
gap: var(--widget-gap);
margin: var(--widget-gap) 0;
margin-top: var(--widget-gap);
animation: pageColumnsEntrance .3s cubic-bezier(0.25, 1, 0.5, 1) backwards;
animation-delay: 3ms;
}
@keyframes pageColumnsEntrance {
@@ -293,8 +364,11 @@ body {
}
.page-loading-container {
margin: 50px auto;
width: fit-content;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
transform: translateY(-5rem);
animation: loadingContainerEntrance 200ms backwards;
animation-delay: 150ms;
font-size: 2rem;
@@ -342,12 +416,38 @@ 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;
width: 100%;
margin-inline: auto;
padding: 0 var(--content-bounds-padding);
}
.page-width-wide .content-bounds {
max-width: 1920px;
}
.page-width-slim .content-bounds {
max-width: 1100px;
}
.dynamic-columns {
gap: calc(var(--widget-content-vertical-padding) / 2);
display: grid;
@@ -566,7 +666,7 @@ body {
}
.footer {
margin-block: calc(var(--widget-gap) * 1.5);
padding-block: calc(var(--widget-gap) * 1.5);
animation: loadingContainerEntrance 200ms backwards;
animation-delay: 150ms;
}
@@ -593,16 +693,16 @@ body {
color: var(--color-text-highlight);
}
.stock-chart {
.market-chart {
margin-left: auto;
width: 6.5rem;
}
.stock-chart svg {
.market-chart svg {
width: 100%;
}
.stock-values {
.market-values {
min-width: 8rem;
}
@@ -653,6 +753,86 @@ 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;
color: var(--color-text-highlight);
}
.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;
@@ -668,6 +848,10 @@ body {
margin-top: 0.1rem;
}
.forum-post-tags-container {
transform: translateY(-0.15rem);
}
.bookmarks-group {
--bookmarks-group-color: var(--color-primary);
}
@@ -721,7 +905,7 @@ body {
flex-direction: column;
width: calc(100% / 12);
padding-top: 3px;
max-width: 3rem;
max-width: 30px;
}
.weather-column-value, .weather-columns:hover .weather-column-value {
@@ -855,6 +1039,10 @@ body {
transform: translate(-50%, -50%);
}
.clock-time span {
color: var(--color-text-highlight);
}
.monitor-site-icon {
display: block;
opacity: 0.8;
@@ -885,7 +1073,18 @@ body {
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;
}
@@ -933,8 +1132,23 @@ 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;
aspect-ratio: 3 / 4;
border-radius: var(--border-radius);
}
@@ -1011,10 +1225,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;
}
@@ -1025,8 +1239,14 @@ body {
}
}
body {
padding-bottom: calc(var(--mobile-navigation-height) + var(--content-bounds-padding));
.mobile-navigation-offset {
height: var(--mobile-navigation-height);
margin-top: var(--widget-gap);
flex-shrink: 0;
}
.footer + .mobile-navigation-offset {
margin-top: 0;
}
.mobile-navigation {
@@ -1059,7 +1279,7 @@ body {
padding: 15px var(--content-bounds-padding);
display: flex;
align-items: center;
overflow-x: scroll;
overflow-x: auto;
gap: 2.5rem;
}
@@ -1130,6 +1350,10 @@ body {
/* 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) {
@@ -1173,11 +1397,11 @@ 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
}
@@ -1189,6 +1413,14 @@ body {
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); }
@@ -1216,7 +1448,10 @@ body {
.shrink { flex-shrink: 1; }
.shrink-0 { flex-shrink: 0; }
.min-width-0 { min-width: 0; }
.max-width-100 { max-width: 100%; }
.height-100 { height: 100%; }
.block { display: block; }
.inline-block { display: inline-block; }
.overflow-hidden { overflow: hidden; }
.relative { position: relative; }
.flex { display: flex; }
@@ -1224,6 +1459,7 @@ body {
.flex-nowrap { flex-wrap: nowrap; }
.justify-between { justify-content: space-between; }
.justify-stretch { justify-content: stretch; }
.justify-evenly { justify-content: space-evenly; }
.justify-center { justify-content: center; }
.justify-end { justify-content: end; }
.uppercase { text-transform: uppercase; }
@@ -1235,11 +1471,17 @@ body {
.gap-7 { gap: 0.7rem; }
.gap-10 { gap: 1rem; }
.gap-15 { gap: 1.5rem; }
.gap-20 { gap: 2rem; }
.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; }
.margin-top-10 { margin-top: 1rem; }
.margin-top-15 { margin-top: 1.5rem; }
.margin-top-auto { margin-top: auto; }
.margin-block-3 { margin-block: 0.3rem; }
.margin-block-5 { margin-block: 0.5rem; }
.margin-block-7 { margin-block: 0.7rem; }
@@ -1251,3 +1493,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); }

View File

@@ -59,9 +59,9 @@ function setupCarousels() {
const determineSideCutoffsRateLimited = throttledDebounce(determineSideCutoffs, 20, 100);
itemsContainer.addEventListener("scroll", determineSideCutoffsRateLimited);
document.addEventListener("resize", determineSideCutoffsRateLimited);
window.addEventListener("resize", determineSideCutoffsRateLimited);
setTimeout(determineSideCutoffs, 1);
afterContentReady(determineSideCutoffs);
}
}
@@ -103,7 +103,108 @@ 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 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 && currentBang == null) {
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.trim();
if (value in bangsMap) {
changeCurrentBang(bangsMap[value]);
return;
}
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());
});
}
}
@@ -149,6 +250,46 @@ function setupDynamicRelativeTime() {
});
}
function setupGroups() {
const groups = document.getElementsByClassName("widget-type-group");
if (groups.length == 0) {
return;
}
for (let g = 0; g < groups.length; g++) {
const group = groups[g];
const titles = group.getElementsByClassName("widget-header")[0].children;
const tabs = group.getElementsByClassName("widget-group-contents")[0].children;
let current = 0;
for (let t = 0; t < titles.length; t++) {
const title = titles[t];
title.addEventListener("click", () => {
if (t == current) {
return;
}
for (let i = 0; i < titles.length; i++) {
titles[i].classList.remove("widget-group-title-current");
tabs[i].classList.remove("widget-group-content-current");
}
if (current < t) {
tabs[t].dataset.direction = "right";
} else {
tabs[t].dataset.direction = "left";
}
current = t;
title.classList.add("widget-group-title-current");
tabs[t].classList.add("widget-group-content-current");
});
}
}
}
function setupLazyImages() {
const images = document.querySelectorAll("img[loading=lazy]");
@@ -160,22 +301,24 @@ function setupLazyImages() {
image.classList.add("finished-transition");
}
setTimeout(() => {
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);
});
}
}
}
}, 5);
}, 1);
});
}
function attachExpandToggleButton(collapsibleContainer) {
@@ -253,8 +396,6 @@ function setupCollapsibleLists() {
child.classList.add("collapsible-item");
child.style.animationDelay = ((c - collapseAfter) * 20).toString() + "ms";
}
list.classList.add("ready");
}
}
@@ -314,11 +455,10 @@ function setupCollapsibleGrids() {
}
};
setTimeout(() => {
afterContentReady(() => {
cardsPerRow = getCardsPerRow();
resolveCollapsibleItems();
gridElement.classList.add("ready");
}, 1);
});
window.addEventListener("resize", () => {
const newCardsPerRow = getCardsPerRow();
@@ -333,6 +473,118 @@ function setupCollapsibleGrids() {
}
}
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");
@@ -340,23 +592,26 @@ async function setupPage() {
pageContentElement.innerHTML = pageContent;
setTimeout(() => {
document.body.classList.add("animate-element-transition");
}, 200);
try {
setupLazyImages();
setupClocks()
setupCarousels();
setupSearchBoxes();
setupCollapsibleLists();
setupCollapsibleGrids();
setupGroups();
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);
}
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", setupPage);
} else {
setupPage();
}
setupPage();

View File

@@ -1,13 +1,14 @@
{
"name": "Glance",
"display": "standalone",
"background_color": "#151519",
"scope": "/",
"start_url": "/",
"icons": [
{
"src": "/static/app-icon.png",
"src": "app-icon.png",
"type": "image/png",
"sizes": "512x512"
}
]
}
}

View File

@@ -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,21 @@ 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")
MarketsTemplate = compileTemplate("markets.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")
ExtensionTemplate = compileTemplate("extension.html", "widget-base.html")
GroupTemplate = compileTemplate("group.html", "widget-base.html")
)
var globalTemplateFunctions = template.FuncMap{

View File

@@ -0,0 +1,17 @@
{{ template "widget-base.html" . }}
{{ define "widget-content" }}
<ul class="list list-gap-14 collapsible-container" data-collapse-after="{{ .CollapseAfter }}">
{{ range .ChangeDetections }}
<li>
<a class="size-h4 block text-truncate color-highlight" href="{{ .URL }}" target="_blank" rel="noreferrer">{{ .Title }}</a>
<ul class="list-horizontal-text">
<li {{ dynamicRelativeTimeAttrs .LastChanged }}></li>
<li class="shrink min-width-0"><a class="visited-indicator" href="{{ .DiffURL }}" target="_blank" rel="noreferrer">diff:{{ .PreviousHash }}</a></li>
</ul>
</li>
{{ else }}
<li>No watches configured</li>
{{ end}}
</ul>
{{ end }}

View File

@@ -0,0 +1,30 @@
{{ template "widget-base.html" . }}
{{ define "widget-content" }}
<div class="clock" data-hour-format="{{ .HourFormat }}">
<div class="flex justify-between items-center" data-local-time>
<div>
<div class="color-highlight size-h1" data-date></div>
<div data-year></div>
</div>
<div class="text-right">
<div class="clock-time size-h1" data-time></div>
<div data-weekday></div>
</div>
</div>
{{ if gt (len .Timezones) 0 }}
<hr class="margin-block-10">
<ul class="list list-gap-4">
{{ range .Timezones }}
<li class="flex items-center gap-15" data-time-in-zone="{{ .Timezone }}">
<div class="grow min-width-0">
<div class="text-truncate">{{ if ne .Label "" }}{{ .Label }}{{ else }}{{ .Timezone }}{{ end }}</div>
</div>
<div class="color-subdue" data-time-diff></div>
<div class="size-h4 clock-time shrink-0 text-right" data-time></div>
</li>
{{ end }}
</ul>
{{ end }}
</div>
{{ end }}

View File

@@ -11,12 +11,12 @@
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="Glance">
<meta name="theme-color" content="{{ if ne nil .App.Config.Theme.BackgroundColor }}{{ .App.Config.Theme.BackgroundColor }}{{ else }}hsl(240, 8%, 9%){{ end }}">
<link rel="apple-touch-icon" sizes="512x512" href="{{ .App.Config.Server.BaseUrl }}/static/app-icon.png">
<link rel="icon" type="image/png" sizes="50x50" href="{{ .App.Config.Server.BaseUrl }}/static/favicon.png">
<link rel="manifest" href="{{ .App.Config.Server.BaseUrl }}/static/manifest.json">
<link rel="icon" type="image/png" href="{{ .App.Config.Server.BaseUrl }}/static/favicon.png" />
<link rel="stylesheet" href="{{ .App.Config.Server.BaseUrl }}/static/main.css?v={{ .App.Config.Server.StartedAt.Unix }}">
<script async src="{{ .App.Config.Server.BaseUrl }}/static/main.js?v={{ .App.Config.Server.StartedAt.Unix }}"></script>
<link rel="apple-touch-icon" sizes="512x512" href="{{ .App.AssetPath "app-icon.png" }}">
<link rel="icon" type="image/png" sizes="50x50" href="{{ .App.AssetPath "favicon.png" }}">
<link rel="manifest" href="{{ .App.AssetPath "manifest.json" }}">
<link rel="icon" type="image/png" href="{{ .App.AssetPath "favicon.png" }}" />
<link rel="stylesheet" href="{{ .App.AssetPath "main.css" }}">
<script type="module" src="{{ .App.AssetPath "main.js" }}"></script>
{{ block "document-head-after" . }}{{ end }}
</head>
<body>

View File

@@ -0,0 +1,5 @@
{{ template "widget-base.html" . }}
{{ define "widget-content" }}
{{ .Extension.Content }}
{{ end }}

View File

@@ -4,7 +4,7 @@
<ul class="list list-gap-14 collapsible-container" data-collapse-after="{{ .CollapseAfter }}">
{{ range .Posts }}
<li>
<div class="forum-post-list-item thumbnail-container">
<div class="flex gap-10 row-reverse-on-mobile thumbnail-parent">
{{ if $.ShowThumbnails }}
{{ if ne .ThumbnailUrl "" }}
<img class="forum-post-list-thumbnail thumbnail" src="{{ .ThumbnailUrl }}" alt="" loading="lazy">
@@ -18,14 +18,23 @@
</svg>
{{ end }}
{{ end }}
<div class="grow">
<div class="grow min-width-0">
<a href="{{ .DiscussionUrl }}" class="size-h3 color-primary-if-not-visited" target="_blank" rel="noreferrer">{{ .Title }}</a>
{{ if gt (len .Tags) 0 }}
<div class="inline-block forum-post-tags-container">
<ul class="attachments">
{{ range .Tags }}
<li>{{ . }}</li>
{{ end }}
</ul>
</div>
{{ end }}
<ul class="list-horizontal-text">
<li {{ dynamicRelativeTimeAttrs .TimePosted }}></li>
<li>{{ .Score | formatNumber }} points</li>
<li>{{ .CommentCount | formatNumber }} comments</li>
{{ if .HasTargetUrl }}
<li class="shrink min-width-0"><a class="visited-indicator text-truncate block" href="{{ .TargetUrl }}" target="_blank" rel="noreferrer">{{ .TargetUrlDomain }}</a></li>
<li class="min-width-0"><a class="visited-indicator text-truncate block" href="{{ .TargetUrl }}" target="_blank" rel="noreferrer">{{ .TargetUrlDomain }}</a></li>
{{ end }}
</ul>
</div>

View File

@@ -0,0 +1,20 @@
{{ template "widget-base.html" . }}
{{ define "widget-content-classes" }}widget-content-frameless{{ end }}
{{ define "widget-content" }}
<div class="widget-group-header">
<div class="widget-header gap-20">
{{ range $i, $widget := .Widgets }}
<button class="widget-group-title{{ if eq $i 0 }} widget-group-title-current{{ end }}">{{ $widget.Title }}</button>
{{ end }}
</div>
</div>
<div class="widget-group-contents">
{{ range $i, $widget := .Widgets }}
<div class="widget-group-content{{ if eq $i 0 }} widget-group-content-current{{ end }}">{{ .Render }}</div>
{{ end }}
</div>
{{ end }}

View File

@@ -3,36 +3,36 @@
{{ define "widget-content" }}
{{ if ne .Style "dynamic-columns-experimental" }}
<ul class="list list-gap-20 list-with-separator">
{{ range .Stocks }}
{{ range .Markets }}
<li class="flex items-center gap-15">
{{ template "stock" . }}
{{ template "market" . }}
</li>
{{ end }}
</ul>
{{ else }}
<div class="dynamic-columns">
{{ range .Stocks }}
{{ range .Markets }}
<div class="flex items-center gap-15">
{{ template "stock" . }}
{{ template "market" . }}
</div>
{{ end }}
</div>
{{ end }}
{{ end }}
{{ define "stock" }}
<div class="shrink min-width-0">
{{ define "market" }}
<div class="min-width-0">
<a{{ if ne "" .SymbolLink }} href="{{ .SymbolLink }}" target="_blank" rel="noreferrer"{{ end }} class="color-highlight size-h3 block text-truncate">{{ .Symbol }}</a>
<div class="text-truncate">{{ .Name }}</div>
</div>
<a class="stock-chart" {{ if ne "" .ChartLink }} href="{{ .ChartLink }}" target="_blank" rel="noreferrer"{{ end }}>
<svg class="stock-chart shrink-0" viewBox="0 0 100 50">
<a class="market-chart" {{ if ne "" .ChartLink }} href="{{ .ChartLink }}" target="_blank" rel="noreferrer"{{ end }}>
<svg class="market-chart shrink-0" viewBox="0 0 100 50">
<polyline fill="none" stroke="var(--color-text-subdue)" stroke-width="1.5px" points="{{ .SvgChartPoints }}" vector-effect="non-scaling-stroke"></polyline>
</svg>
</a>
<div class="stock-values shrink-0">
<div class="market-values shrink-0">
<div class="size-h3 text-right {{ if eq .PercentChange 0.0 }}{{ else if gt .PercentChange 0.0 }}color-positive{{ else }}color-negative{{ end }}">{{ printf "%+.2f" .PercentChange }}%</div>
<div class="text-right">{{ .Currency }}{{ .Price | formatPrice }}</div>
</div>

View File

@@ -22,13 +22,13 @@
{{ define "site" }}
{{ if .IconUrl }}
<img class="monitor-site-icon" src="{{ .IconUrl }}" alt="" loading="lazy">
<img class="monitor-site-icon{{ if .IsSimpleIcon }} simple-icon{{ end }}" src="{{ .IconUrl }}" alt="" loading="lazy">
{{ end }}
<div>
<a class="size-h3 color-highlight" href="{{ .Url }}" {{ if not .SameTab }}target="_blank"{{ end }} rel="noreferrer">{{ .Title }}</a>
<a class="size-h3 color-highlight" href="{{ .URL }}" {{ if not .SameTab }}target="_blank"{{ end }} rel="noreferrer">{{ .Title }}</a>
<ul class="list-horizontal-text">
{{ if not .Status.Error }}
<li>{{ .StatusText }}</li>
<li title="{{ .Status.Code }}">{{ .StatusText }}</li>
<li>{{ .Status.ResponseTime.Milliseconds | formatNumber }}ms</li>
{{ else if .Status.TimedOut }}
<li class="color-negative">Timed Out</li>
@@ -37,7 +37,7 @@
{{ end }}
</ul>
</div>
{{ if eq .StatusStyle "good" }}
{{ if eq .StatusStyle "ok" }}
<div class="monitor-site-status-icon">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="var(--color-positive)">
<path fill-rule="evenodd" d="M2.25 12c0-5.385 4.365-9.75 9.75-9.75s9.75 4.365 9.75 9.75-4.365 9.75-9.75 9.75S2.25 17.385 2.25 12Zm13.36-1.814a.75.75 0 1 0-1.22-.872l-3.236 4.53L9.53 12.22a.75.75 0 0 0-1.06 1.06l2.25 2.25a.75.75 0 0 0 1.14-.094l3.75-5.25Z" clip-rule="evenodd" />

View File

@@ -11,7 +11,7 @@
</script>
{{ end }}
{{ define "document-root-attrs" }}{{ if .App.Config.Theme.Light }}class="light-scheme"{{ end }}{{ end }}
{{ define "document-root-attrs" }}class="{{ if .App.Config.Theme.Light }}light-scheme {{ end }}{{ if ne "" .Page.Width }}page-width-{{ .Page.Width }}{{ end }}"{{ end }}
{{ define "document-head-after" }}
{{ template "page-style-overrides.gotmpl" . }}
{{ if ne "" .App.Config.Theme.CustomCSSFile }}
@@ -26,43 +26,48 @@
{{ end }}
{{ define "document-body" }}
<div class="header-container content-bounds">
<div class="header flex padding-inline-widget widget-content-frame">
<!-- TODO: Replace G with actual logo, first need an actual logo -->
<div class="logo">G</div>
<div class="nav flex grow">
<div class="flex flex-column height-100">
{{ if not .Page.HideDesktopNavigation }}
<div class="header-container content-bounds">
<div class="header flex padding-inline-widget widget-content-frame">
<!-- TODO: Replace G with actual logo, first need an actual logo -->
<div class="logo">G</div>
<div class="nav flex grow">
{{ template "navigation-links" . }}
</div>
</div>
</div>
{{ end }}
<div class="mobile-navigation">
<div class="mobile-navigation-icons">
<a class="mobile-navigation-label" href="#top"></a>
{{ range $i, $column := .Page.Columns }}
<label class="mobile-navigation-label"><input type="radio" class="mobile-navigation-input" name="column" value="{{ $i }}" autocomplete="off"{{ if eq "full" $column.Size }} checked{{ end }}><div class="mobile-navigation-pill"></div></label>
{{ end }}
<label class="mobile-navigation-label"><input type="checkbox" class="mobile-navigation-page-links-input" autocomplete="on"><div class="hamburger-icon"></div></label>
</div>
<div class="mobile-navigation-page-links">
{{ template "navigation-links" . }}
</div>
</div>
</div>
<div class="mobile-navigation">
<div class="mobile-navigation-icons">
<a class="mobile-navigation-label" href="#top"></a>
{{ range $i, $column := .Page.Columns }}
<label class="mobile-navigation-label"><input type="radio" class="mobile-navigation-input" name="column" value="{{ $i }}" autocomplete="off"{{ if eq "full" $column.Size }} checked{{ end }}><div class="mobile-navigation-pill"></div></label>
{{ end }}
<label class="mobile-navigation-label"><input type="checkbox" class="mobile-navigation-page-links-input" autocomplete="on"><div class="hamburger-icon"></div></label>
</div>
<div class="mobile-navigation-page-links">
{{ template "navigation-links" . }}
</div>
</div>
<div class="content-bounds">
<div class="page" id="page">
<div class="page-content" id="page-content"></div>
<div class="page-loading-container">
<!-- TODO: add a bigger/better loading indicator -->
<div class="loading-icon"></div>
<div class="content-bounds grow">
<div class="page" id="page">
<div class="page-content" id="page-content"></div>
<div class="page-loading-container">
<!-- TODO: add a bigger/better loading indicator -->
<div class="loading-icon"></div>
</div>
</div>
</div>
</div>
<div class="footer flex items-center flex-column">
<div>
<a class="size-h3" href="https://github.com/glanceapp/glance" target="_blank" rel="noreferrer">Glance</a> {{ if ne "dev" .App.Version }}<a class="visited-indicator" title="Release notes" href="https://github.com/glanceapp/glance/releases/tag/{{ .App.Version }}" target="_blank" rel="noreferrer">{{ .App.Version }}</a>{{ else }}({{ .App.Version }}){{ end }}
<div class="footer flex items-center flex-column">
<div>
<a class="size-h3" href="https://github.com/glanceapp/glance" target="_blank" rel="noreferrer">Glance</a> {{ if ne "dev" .App.Version }}<a class="visited-indicator" title="Release notes" href="https://github.com/glanceapp/glance/releases/tag/{{ .App.Version }}" target="_blank" rel="noreferrer">{{ .App.Version }}</a>{{ else }}({{ .App.Version }}){{ end }}
</div>
</div>
<a class="color-primary block margin-top-5 size-h5" href="https://github.com/glanceapp/glance/issues" target="_blank" rel="noreferrer">Report issue</a>
<div class="mobile-navigation-offset"></div>
</div>
{{ end }}

View File

@@ -0,0 +1,38 @@
{{ template "widget-base.html" . }}
{{ define "widget-content" }}
<ul class="list list-gap-24 collapsible-container" data-collapse-after="{{ .CollapseAfter }}">
{{ range .Items }}
<li class="flex gap-15 items-start row-reverse-on-mobile thumbnail-parent">
<div class="thumbnail-container rss-detailed-thumbnail">
{{ if ne "" .ImageURL }}
<img class="thumbnail" loading="lazy" src="{{ .ImageURL }}" alt="">
{{ else }}
<svg class="scale-half hide-on-mobile" stroke="var(--color-text-subdue)" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="m2.25 15.75 5.159-5.159a2.25 2.25 0 0 1 3.182 0l5.159 5.159m-1.5-1.5 1.409-1.409a2.25 2.25 0 0 1 3.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 0 0 1.5-1.5V6a1.5 1.5 0 0 0-1.5-1.5H3.75A1.5 1.5 0 0 0 2.25 6v12a1.5 1.5 0 0 0 1.5 1.5Zm10.5-11.25h.008v.008h-.008V8.25Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Z" />
</svg>
{{ end }}
</div>
<div class="grow min-width-0">
<a class="size-h3 color-primary-if-not-visited" href="{{ .Link }}" target="_blank" rel="noreferrer">{{ .Title }}</a>
<ul class="list-horizontal-text flex-nowrap">
<li {{ dynamicRelativeTimeAttrs .PublishedAt }}></li>
<li class="min-width-0">
<a class="block text-truncate" href="{{ .ChannelURL }}" target="_blank" rel="noreferrer">{{ .ChannelName }}</a>
</li>
</ul>
{{ if ne "" .Description }}
<p class="rss-detailed-description text-truncate-2-lines margin-top-10">{{ .Description }}</p>
{{ end }}
{{ if gt (len .Categories) 0 }}
<ul class="attachments margin-top-10">
{{ range .Categories }}
<li>{{ . }}</li>
{{ end }}
</ul>
{{ end }}
</div>
</li>
{{ end }}
</ul>
{{ end }}

View File

@@ -6,7 +6,7 @@
<div class="carousel-container">
<div class="cards-horizontal carousel-items-container"{{ if ne 0.0 .CardHeight }} style="--rss-card-height: {{ .CardHeight }}rem;"{{ end }}>
{{ range .Items }}
<div class="card rss-card-2 widget-content-frame thumbnail-container">
<div class="card rss-card-2 widget-content-frame thumbnail-parent">
{{ if ne "" .ImageURL }}
<img class="rss-card-2-image thumbnail" loading="lazy" src="{{ .ImageURL }}" alt="">
{{ else }}
@@ -18,7 +18,7 @@
<a href="{{ .Link }}" title="{{ .Title }}" class="block text-truncate color-primary-if-not-visited" target="_blank" rel="noreferrer">{{ .Title }}</a>
<ul class="list-horizontal-text flex-nowrap margin-top-5">
<li class="shrink-0" {{ dynamicRelativeTimeAttrs .PublishedAt }}></li>
<li class="shrink min-width-0 text-truncate">{{ .ChannelName }}</li>
<li class="min-width-0 text-truncate">{{ .ChannelName }}</li>
</ul>
</div>
</div>

View File

@@ -6,7 +6,7 @@
<div class="carousel-container">
<div class="cards-horizontal carousel-items-container"{{ if ne 0.0 .ThumbnailHeight }} style="--rss-thumbnail-height: {{ .ThumbnailHeight }}rem;"{{ end }}>
{{ range .Items }}
<div class="card widget-content-frame thumbnail-container">
<div class="card widget-content-frame thumbnail-parent">
{{ if ne "" .ImageURL }}
<img class="rss-card-image thumbnail" loading="lazy" src="{{ .ImageURL }}" alt="">
{{ else }}
@@ -18,7 +18,7 @@
<a href="{{ .Link }}" title="{{ .Title }}" class="text-truncate-3-lines color-primary-if-not-visited margin-top-10 margin-bottom-auto" target="_blank" rel="noreferrer">{{ .Title }}</a>
<ul class="list-horizontal-text flex-nowrap margin-top-7">
<li class="shrink-0" {{ dynamicRelativeTimeAttrs .PublishedAt }}></li>
<li class="shrink min-width-0 text-truncate">{{ .ChannelName }}</li>
<li class="min-width-0 text-truncate">{{ .ChannelName }}</li>
</ul>
</div>
</div>

View File

@@ -5,11 +5,11 @@
{{ range .Items }}
<li>
<a class="size-title-dynamic color-primary-if-not-visited" href="{{ .Link }}" target="_blank" rel="noreferrer">{{ .Title }}</a>
<ul class="list-horizontal-text">
<ul class="list-horizontal-text flex-nowrap">
<li {{ dynamicRelativeTimeAttrs .PublishedAt }}></li>
{{ if gt (len $.FeedRequests) 1 }}
<li><a href="{{ .ChannelURL }}" target="_blank" rel="noreferrer">{{ .ChannelName }}</a></li>
{{ end }}
<li class="min-width-0">
<a class="block text-truncate" href="{{ .ChannelURL }}" target="_blank" rel="noreferrer">{{ .ChannelName }}</a>
</li>
</ul>
</li>
{{ end }}

View File

@@ -0,0 +1,24 @@
{{ template "widget-base.html" . }}
{{ define "widget-content-classes" }}widget-content-frameless{{ end }}
{{ define "widget-content" }}
<div class="search widget-content-frame padding-inline-widget flex gap-15 items-center" data-default-search-url="{{ .SearchEngine }}" data-new-tab="{{ .NewTab }}">
<div class="search-bangs">
{{ range .Bangs }}
<input type="hidden" data-shortcut="{{ .Shortcut }}" data-title="{{ .Title }}" data-url="{{ .URL }}">
{{ end }}
</div>
<div class="search-icon-container">
<svg class="search-icon" stroke="var(--color-text-subdue)" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" />
</svg>
</div>
<input class="search-input" type="text" placeholder="Type here to search…" autocomplete="off"{{ if .Autofocus }} autofocus{{ end }}>
<div class="search-bang"></div>
<kbd class="hide-on-mobile" title="Press [S] to focus the search input">S</kbd>
</div>
{{ end }}

View File

@@ -4,21 +4,25 @@
<ul class="list list-gap-14 collapsible-container" data-collapse-after="{{ .CollapseAfter }}">
{{ range .Channels }}
<li>
<div class="{{ if .IsLive }}twitch-channel-live {{ end }}flex gap-10 items-start thumbnail-container">
<div class="{{ if .IsLive }}twitch-channel-live {{ end }}flex gap-10 items-start thumbnail-parent">
<div class="twitch-channel-avatar-container">
{{ if .Exists }}
<img class="twitch-channel-avatar thumbnail" src="{{ .AvatarUrl }}" alt="" loading="lazy">
<a href="https://twitch.tv/{{ .Login }}" class="twitch-channel-avatar-link" target="_blank" rel="noreferrer">
<img class="twitch-channel-avatar thumbnail" src="{{ .AvatarUrl }}" alt="" loading="lazy">
</a>
{{ else }}
<svg class="twitch-channel-avatar thumbnail" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 6a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0ZM4.501 20.118a7.5 7.5 0 0 1 14.998 0A17.933 17.933 0 0 1 12 21.75c-2.676 0-5.216-.584-7.499-1.632Z" />
</svg>
{{ end }}
</div>
<div class="shrink min-width-0">
<div class="min-width-0">
<a href="https://twitch.tv/{{ .Login }}" class="size-h3{{ if .IsLive }} color-highlight{{ end }} block text-truncate" target="_blank" rel="noreferrer">{{ .Name }}</a>
{{ if .Exists }}
{{ if .IsLive }}
<a class="text-truncate block" href="https://www.twitch.tv/directory/category/{{ .CategorySlug }}" target="_blank" rel="noreferrer">{{ .Category }}</a>
{{ if .Category }}
<a class="text-truncate block" href="https://www.twitch.tv/directory/category/{{ .CategorySlug }}" target="_blank" rel="noreferrer">{{ .Category }}</a>
{{ end }}
<ul class="list-horizontal-text">
<li {{ dynamicRelativeTimeAttrs .LiveSince }}></li>
<li>{{ .ViewersCount | formatViewerCount }} viewers</li>

View File

@@ -3,10 +3,10 @@
{{ define "widget-content" }}
<ul class="list list-gap-14 collapsible-container" data-collapse-after="{{ .CollapseAfter }}">
{{ range .Categories }}
<li class="twitch-category thumbnail-container">
<div class="flex gap-10 items-center">
<li class="twitch-category thumbnail-parent">
<div class="flex gap-10 items-start">
<img class="twitch-category-thumbnail thumbnail" loading="lazy" src="{{ .AvatarUrl }}" alt="">
<div class="shrink min-width-0">
<div class="min-width-0">
<a class="size-h3 color-highlight text-truncate block" href="https://www.twitch.tv/directory/category/{{ .Slug }}" target="_blank" rel="noreferrer">{{ .Name }}</a>
<ul class="list-horizontal-text">
<li>{{ .ViewersCount | formatViewerCount }} viewers</li>
@@ -19,7 +19,7 @@
{{ if eq $i 0 }}
<li class="shrink-0">{{ $tag.Name }}</li>
{{ else }}
<li class="text-truncate shrink min-width-0">{{ $tag.Name }}</li>
<li class="text-truncate min-width-0">{{ $tag.Name }}</li>
{{ end }}
{{ end }}
</ul>

View File

@@ -4,7 +4,7 @@
<a class="video-title color-primary-if-not-visited" href="{{ .Url }}" target="_blank" rel="noreferrer" title="{{ .Title }}">{{ .Title }}</a>
<ul class="list-horizontal-text flex-nowrap margin-top-7">
<li class="shrink-0" {{ dynamicRelativeTimeAttrs .TimePosted }}></li>
<li class="shrink min-width-0">
<li class="min-width-0">
<a class="block text-truncate" href="{{ .AuthorUrl }}" target="_blank" rel="noreferrer">{{ .Author }}</a>
</li>
</ul>

View File

@@ -5,7 +5,7 @@
{{ define "widget-content" }}
<div class="cards-grid collapsible-container" data-collapse-after-rows="{{ .CollapseAfterRows }}">
{{ range .Videos }}
<div class="card widget-content-frame thumbnail-container">
<div class="card widget-content-frame thumbnail-parent">
{{ template "video-card-contents" . }}
</div>
{{ end }}

View File

@@ -6,7 +6,7 @@
<div class="carousel-container">
<div class="cards-horizontal carousel-items-container">
{{ range .Videos }}
<div class="card widget-content-frame thumbnail-container">
<div class="card widget-content-frame thumbnail-parent">
{{ template "video-card-contents" . }}
</div>
{{ end }}

View File

@@ -1,13 +1,15 @@
<div class="widget widget-type-{{ .GetType }}">
<div class="widget widget-type-{{ .GetType }}{{ if ne "" .CSSClass }} {{ .CSSClass }}{{ end }}">
{{ if not .HideHeader}}
<div class="widget-header">
<div class="uppercase">{{ .Title }}</div>
{{ if ne "" .TitleURL}}<a href="{{ .TitleURL }}" target="_blank" rel="noreferrer" class="uppercase">{{ .Title }}</a>{{ else }}<div class="uppercase">{{ .Title }}</div>{{ end }}
{{ if and .Error .ContentAvailable }}
<div class="notice-icon notice-icon-major" title="{{ .Error }}"></div>
{{ else if .Notice }}
<div class="notice-icon notice-icon-minor" title="{{ .Notice }}"></div>
{{ end }}
</div>
<div class="widget-content {{ if .ContentAvailable }}{{ block "widget-content-classes" . }}{{ end }}{{ end }}">
{{ end }}
<div class="widget-content{{ if .ContentAvailable }} {{ block "widget-content-classes" . }}{{ end }}{{ end }}">
{{ if .ContentAvailable }}
{{ block "widget-content" . }}{{ end }}
{{ else }}

View File

@@ -0,0 +1,139 @@
package feed
import (
"fmt"
"log/slog"
"net/http"
"sort"
"strings"
"time"
)
type ChangeDetectionWatch struct {
Title string
URL string
LastChanged time.Time
DiffURL string
PreviousHash string
}
type ChangeDetectionWatches []ChangeDetectionWatch
func (r ChangeDetectionWatches) SortByNewest() ChangeDetectionWatches {
sort.Slice(r, func(i, j int) bool {
return r[i].LastChanged.After(r[j].LastChanged)
})
return r
}
type changeDetectionResponseJson struct {
Title string `json:"title"`
URL string `json:"url"`
LastChanged int64 `json:"last_changed"`
DateCreated int64 `json:"date_created"`
PreviousHash string `json:"previous_md5"`
}
func FetchWatchUUIDsFromChangeDetection(instanceURL string, token string) ([]string, error) {
request, _ := http.NewRequest("GET", fmt.Sprintf("%s/api/v1/watch", instanceURL), nil)
if token != "" {
request.Header.Add("x-api-key", token)
}
uuidsMap, err := decodeJsonFromRequest[map[string]struct{}](defaultClient, request)
if err != nil {
return nil, fmt.Errorf("could not fetch list of watch UUIDs: %v", err)
}
uuids := make([]string, 0, len(uuidsMap))
for uuid := range uuidsMap {
uuids = append(uuids, uuid)
}
return uuids, nil
}
func FetchWatchesFromChangeDetection(instanceURL string, requestedWatchIDs []string, token string) (ChangeDetectionWatches, error) {
watches := make(ChangeDetectionWatches, 0, len(requestedWatchIDs))
if len(requestedWatchIDs) == 0 {
return watches, nil
}
requests := make([]*http.Request, len(requestedWatchIDs))
for i, repository := range requestedWatchIDs {
request, _ := http.NewRequest("GET", fmt.Sprintf("%s/api/v1/watch/%s", instanceURL, repository), nil)
if token != "" {
request.Header.Add("x-api-key", token)
}
requests[i] = request
}
task := decodeJsonFromRequestTask[changeDetectionResponseJson](defaultClient)
job := newJob(task, requests).withWorkers(15)
responses, errs, err := workerPoolDo(job)
if err != nil {
return nil, err
}
var failed int
for i := range responses {
if errs[i] != nil {
failed++
slog.Error("Failed to fetch or parse change detection watch", "error", errs[i], "url", requests[i].URL)
continue
}
watchJson := responses[i]
watch := ChangeDetectionWatch{
URL: watchJson.URL,
DiffURL: fmt.Sprintf("%s/diff/%s?from_version=%d", instanceURL, requestedWatchIDs[i], watchJson.LastChanged-1),
}
if watchJson.LastChanged == 0 {
watch.LastChanged = time.Unix(watchJson.DateCreated, 0)
} else {
watch.LastChanged = time.Unix(watchJson.LastChanged, 0)
}
if watchJson.Title != "" {
watch.Title = watchJson.Title
} else {
watch.Title = strings.TrimPrefix(strings.Trim(stripURLScheme(watchJson.URL), "/"), "www.")
}
if watchJson.PreviousHash != "" {
var hashLength = 8
if len(watchJson.PreviousHash) < hashLength {
hashLength = len(watchJson.PreviousHash)
}
watch.PreviousHash = watchJson.PreviousHash[0:hashLength]
}
watches = append(watches, watch)
}
if len(watches) == 0 {
return nil, ErrNoContent
}
watches.SortByNewest()
if failed > 0 {
return watches, fmt.Errorf("%w: could not get %d watches", ErrPartialContent, failed)
}
return watches, nil
}

View File

@@ -0,0 +1,97 @@
package feed
import (
"fmt"
"html"
"html/template"
"io"
"log/slog"
"net/http"
"net/url"
)
type ExtensionType int
const (
ExtensionContentHTML ExtensionType = iota
ExtensionContentUnknown = iota
)
var ExtensionStringToType = map[string]ExtensionType{
"html": ExtensionContentHTML,
}
const (
ExtensionHeaderTitle = "Widget-Title"
ExtensionHeaderContentType = "Widget-Content-Type"
)
type ExtensionRequestOptions struct {
URL string `yaml:"url"`
Parameters map[string]string `yaml:"parameters"`
AllowHtml bool `yaml:"allow-potentially-dangerous-html"`
}
type Extension struct {
Title string
Content template.HTML
}
func convertExtensionContent(options ExtensionRequestOptions, content []byte, contentType ExtensionType) template.HTML {
switch contentType {
case ExtensionContentHTML:
if options.AllowHtml {
return template.HTML(content)
}
fallthrough
default:
return template.HTML(html.EscapeString(string(content)))
}
}
func FetchExtension(options ExtensionRequestOptions) (Extension, error) {
request, _ := http.NewRequest("GET", options.URL, nil)
query := url.Values{}
for key, value := range options.Parameters {
query.Set(key, value)
}
request.URL.RawQuery = query.Encode()
response, err := http.DefaultClient.Do(request)
if err != nil {
slog.Error("failed fetching extension", "error", err, "url", options.URL)
return Extension{}, fmt.Errorf("%w: request failed: %w", ErrNoContent, err)
}
defer response.Body.Close()
body, err := io.ReadAll(response.Body)
if err != nil {
slog.Error("failed reading response body of extension", "error", err, "url", options.URL)
return Extension{}, fmt.Errorf("%w: could not read body: %w", ErrNoContent, err)
}
extension := Extension{}
if response.Header.Get(ExtensionHeaderTitle) == "" {
extension.Title = "Extension"
} else {
extension.Title = response.Header.Get(ExtensionHeaderTitle)
}
contentType, ok := ExtensionStringToType[response.Header.Get(ExtensionHeaderContentType)]
if !ok {
contentType = ExtensionContentUnknown
}
extension.Content = convertExtensionContent(options, body, contentType)
return extension, nil
}

View File

@@ -8,12 +8,10 @@ import (
"time"
)
type githubReleaseResponseJson struct {
type githubReleaseLatestResponseJson struct {
TagName string `json:"tag_name"`
PublishedAt string `json:"published_at"`
HtmlUrl string `json:"html_url"`
Draft bool `json:"draft"`
PreRelease bool `json:"prerelease"`
Reactions struct {
Downvotes int `json:"-1"`
} `json:"reactions"`
@@ -39,7 +37,7 @@ func FetchLatestReleasesFromGithub(repositories []string, token string) (AppRele
requests := make([]*http.Request, len(repositories))
for i, repository := range repositories {
request, _ := http.NewRequest("GET", fmt.Sprintf("https://api.github.com/repos/%s/releases?per_page=10", repository), nil)
request, _ := http.NewRequest("GET", fmt.Sprintf("https://api.github.com/repos/%s/releases/latest", repository), nil)
if token != "" {
request.Header.Add("Authorization", fmt.Sprintf("Bearer %s", token))
@@ -48,7 +46,7 @@ func FetchLatestReleasesFromGithub(repositories []string, token string) (AppRele
requests[i] = request
}
task := decodeJsonFromRequestTask[[]githubReleaseResponseJson](defaultClient)
task := decodeJsonFromRequestTask[githubReleaseLatestResponseJson](defaultClient)
job := newJob(task, requests).withWorkers(15)
responses, errs, err := workerPoolDo(job)
@@ -65,24 +63,7 @@ func FetchLatestReleasesFromGithub(repositories []string, token string) (AppRele
continue
}
releases := responses[i]
if len(releases) < 1 {
failed++
slog.Error("No releases found", "repository", repositories[i], "url", requests[i].URL)
continue
}
var liveRelease *githubReleaseResponseJson
for i := range releases {
release := &releases[i]
if !release.Draft && !release.PreRelease {
liveRelease = release
break
}
}
liveRelease := &responses[i]
if liveRelease == nil {
slog.Error("No live release found", "repository", repositories[i], "url", requests[i].URL)

91
internal/feed/lobsters.go Normal file
View File

@@ -0,0 +1,91 @@
package feed
import (
"net/http"
"strings"
"time"
)
type lobstersPostResponseJson struct {
CreatedAt string `json:"created_at"`
Title string `json:"title"`
URL string `json:"url"`
Score int `json:"score"`
CommentCount int `json:"comment_count"`
CommentsURL string `json:"comments_url"`
Tags []string `json:"tags"`
}
type lobstersFeedResponseJson []lobstersPostResponseJson
func getLobstersPostsFromFeed(feedUrl string) (ForumPosts, error) {
request, err := http.NewRequest("GET", feedUrl, nil)
if err != nil {
return nil, err
}
feed, err := decodeJsonFromRequest[lobstersFeedResponseJson](defaultClient, request)
if err != nil {
return nil, err
}
posts := make(ForumPosts, 0, len(feed))
for i := range feed {
createdAt, _ := time.Parse(time.RFC3339, feed[i].CreatedAt)
posts = append(posts, ForumPost{
Title: feed[i].Title,
DiscussionUrl: feed[i].CommentsURL,
TargetUrl: feed[i].URL,
TargetUrlDomain: extractDomainFromUrl(feed[i].URL),
CommentCount: feed[i].CommentCount,
Score: feed[i].Score,
TimePosted: createdAt,
Tags: feed[i].Tags,
})
}
if len(posts) == 0 {
return nil, ErrNoContent
}
return posts, nil
}
func FetchLobstersPosts(customURL string, instanceURL string, sortBy string, tags []string) (ForumPosts, error) {
var feedUrl string
if customURL != "" {
feedUrl = customURL
} else {
if instanceURL != "" {
instanceURL = strings.TrimRight(instanceURL, "/") + "/"
} else {
instanceURL = "https://lobste.rs/"
}
if sortBy == "hot" {
sortBy = "hottest"
} else if sortBy == "new" {
sortBy = "newest"
}
if len(tags) == 0 {
feedUrl = instanceURL + sortBy + ".json"
} else {
tags := strings.Join(tags, ",")
feedUrl = instanceURL + "t/" + tags + ".json"
}
}
posts, err := getLobstersPostsFromFeed(feedUrl)
if err != nil {
return nil, err
}
return posts, nil
}

View File

@@ -7,6 +7,12 @@ import (
"time"
)
type SiteStatusRequest struct {
URL string `yaml:"url"`
CheckURL string `yaml:"check-url"`
AllowInsecure bool `yaml:"allow-insecure"`
}
type SiteStatus struct {
Code int
TimedOut bool
@@ -14,14 +20,34 @@ type SiteStatus struct {
Error error
}
func getSiteStatusTask(request *http.Request) (SiteStatus, error) {
func getSiteStatusTask(statusRequest *SiteStatusRequest) (SiteStatus, error) {
var url string
if statusRequest.CheckURL != "" {
url = statusRequest.CheckURL
} else {
url = statusRequest.URL
}
request, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return SiteStatus{
Error: err,
}, nil
}
ctx, cancel := context.WithTimeout(context.Background(), time.Second*3)
defer cancel()
request = request.WithContext(ctx)
start := time.Now()
response, err := http.DefaultClient.Do(request)
took := time.Since(start)
status := SiteStatus{ResponseTime: took}
requestSentAt := time.Now()
var response *http.Response
if !statusRequest.AllowInsecure {
response, err = defaultClient.Do(request)
} else {
response, err = defaultInsecureClient.Do(request)
}
status := SiteStatus{ResponseTime: time.Since(requestSentAt)}
if err != nil {
if errors.Is(err, context.DeadlineExceeded) {
@@ -29,7 +55,7 @@ func getSiteStatusTask(request *http.Request) (SiteStatus, error) {
}
status.Error = err
return status, err
return status, nil
}
defer response.Body.Close()
@@ -39,7 +65,7 @@ func getSiteStatusTask(request *http.Request) (SiteStatus, error) {
return status, nil
}
func FetchStatusesForRequests(requests []*http.Request) ([]SiteStatus, error) {
func FetchStatusForSites(requests []*SiteStatusRequest) ([]SiteStatus, error) {
job := newJob(getSiteStatusTask, requests).withWorkers(20)
results, _, err := workerPoolDo(job)

View File

@@ -16,6 +16,7 @@ type ForumPost struct {
Score int
Engagement float64
TimePosted time.Time
Tags []string
}
type ForumPosts []ForumPost
@@ -84,20 +85,24 @@ var currencyToSymbol = map[string]string{
"PHP": "₱",
}
type Stock struct {
Name string `yaml:"name"`
Symbol string `yaml:"symbol"`
ChartLink string `yaml:"chart-link"`
SymbolLink string `yaml:"symbol-link"`
type MarketRequest struct {
Name string `yaml:"name"`
Symbol string `yaml:"symbol"`
ChartLink string `yaml:"chart-link"`
SymbolLink string `yaml:"symbol-link"`
}
type Market struct {
MarketRequest
Currency string `yaml:"-"`
Price float64 `yaml:"-"`
PercentChange float64 `yaml:"-"`
SvgChartPoints string `yaml:"-"`
}
type Stocks []Stock
type Markets []Market
func (t Stocks) SortByAbsChange() {
func (t Markets) SortByAbsChange() {
sort.Slice(t, func(i, j int) bool {
return math.Abs(t[i].PercentChange) > math.Abs(t[j].PercentChange)
})

View File

@@ -2,6 +2,7 @@ package feed
import (
"context"
"crypto/tls"
"encoding/json"
"encoding/xml"
"fmt"
@@ -11,8 +12,19 @@ import (
"time"
)
const defaultClientTimeout = 5 * time.Second
var defaultClient = &http.Client{
Timeout: 5 * time.Second,
Timeout: defaultClientTimeout,
}
var insecureClientTransport = &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
}
var defaultInsecureClient = &http.Client{
Timeout: defaultClientTimeout,
Transport: insecureClientTransport,
}
type RequestDoer interface {

View File

@@ -3,11 +3,16 @@ package feed
import (
"context"
"fmt"
"html"
"log/slog"
"net/url"
"regexp"
"sort"
"strings"
"time"
"github.com/mmcdole/gofeed"
gofeedext "github.com/mmcdole/gofeed/extensions"
)
type RSSFeedItem struct {
@@ -16,12 +21,48 @@ type RSSFeedItem struct {
Title string
Link string
ImageURL string
Categories []string
Description string
PublishedAt time.Time
}
// doesn't cover all cases but works the vast majority of the time
var htmlTagsWithAttributesPattern = regexp.MustCompile(`<\/?[a-zA-Z0-9-]+ *(?:[a-zA-Z-]+=(?:"|').*?(?:"|') ?)* *\/?>`)
var sequentialWhitespacePattern = regexp.MustCompile(`\s+`)
func sanitizeFeedDescription(description string) string {
if description == "" {
return ""
}
description = strings.ReplaceAll(description, "\n", " ")
description = htmlTagsWithAttributesPattern.ReplaceAllString(description, "")
description = sequentialWhitespacePattern.ReplaceAllString(description, " ")
description = strings.TrimSpace(description)
description = html.UnescapeString(description)
return description
}
func shortenFeedDescriptionLen(description string, maxLen int) string {
description, _ = limitStringLength(description, 1000)
description = sanitizeFeedDescription(description)
description, limited := limitStringLength(description, maxLen)
if limited {
description += "…"
}
return description
}
type RSSFeedRequest struct {
Url string `yaml:"url"`
Title string `yaml:"title"`
Url string `yaml:"url"`
Title string `yaml:"title"`
HideCategories bool `yaml:"hide-categories"`
HideDescription bool `yaml:"hide-description"`
ItemLinkPrefix string `yaml:"item-link-prefix"`
IsDetailed bool `yaml:"-"`
}
type RSSFeedItems []RSSFeedItem
@@ -53,8 +94,60 @@ func getItemsFromRSSFeedTask(request RSSFeedRequest) ([]RSSFeedItem, error) {
rssItem := RSSFeedItem{
ChannelURL: feed.Link,
Title: item.Title,
Link: item.Link,
}
if request.ItemLinkPrefix != "" {
rssItem.Link = request.ItemLinkPrefix + item.Link
} else if strings.HasPrefix(item.Link, "http://") || strings.HasPrefix(item.Link, "https://") {
rssItem.Link = item.Link
} else {
parsedUrl, err := url.Parse(feed.Link)
if err != nil {
parsedUrl, err = url.Parse(request.Url)
}
if err == nil {
var link string
if item.Link[0] == '/' {
link = item.Link
} else {
link = "/" + item.Link
}
rssItem.Link = parsedUrl.Scheme + "://" + parsedUrl.Host + link
}
}
if item.Title != "" {
rssItem.Title = item.Title
} else {
rssItem.Title = shortenFeedDescriptionLen(item.Description, 100)
}
if request.IsDetailed {
if !request.HideDescription && item.Description != "" && item.Title != "" {
rssItem.Description = shortenFeedDescriptionLen(item.Description, 200)
}
if !request.HideCategories {
var categories = make([]string, 0, 6)
for _, category := range item.Categories {
if len(categories) == 6 {
break
}
if len(category) == 0 || len(category) > 30 {
continue
}
categories = append(categories, category)
}
rssItem.Categories = categories
}
}
if request.Title != "" {
@@ -65,6 +158,8 @@ func getItemsFromRSSFeedTask(request RSSFeedRequest) ([]RSSFeedItem, error) {
if item.Image != nil {
rssItem.ImageURL = item.Image.URL
} else if url := findThumbnailInItemExtensions(item); url != "" {
rssItem.ImageURL = url
} else if feed.Image != nil {
rssItem.ImageURL = feed.Image.URL
}
@@ -81,6 +176,36 @@ func getItemsFromRSSFeedTask(request RSSFeedRequest) ([]RSSFeedItem, error) {
return items, nil
}
func recursiveFindThumbnailInExtensions(extensions map[string][]gofeedext.Extension) string {
for _, exts := range extensions {
for _, ext := range exts {
if ext.Name == "thumbnail" || ext.Name == "image" {
if url, ok := ext.Attrs["url"]; ok {
return url
}
}
if ext.Children != nil {
if url := recursiveFindThumbnailInExtensions(ext.Children); url != "" {
return url
}
}
}
}
return ""
}
func findThumbnailInItemExtensions(item *gofeed.Item) string {
media, ok := item.Extensions["media"]
if !ok {
return ""
}
return recursiveFindThumbnailInExtensions(media)
}
func GetItemsFromRSSFeeds(requests []RSSFeedRequest) (RSSFeedItems, error) {
job := newJob(getItemsFromRSSFeedTask, requests).withWorkers(10)
feeds, errs, err := workerPoolDo(job)

View File

@@ -204,9 +204,11 @@ func fetchChannelFromTwitchTask(channel string) (TwitchChannel, error) {
result.IsLive = true
result.ViewersCount = channelShell.UserOrError.Stream.ViewersCount
if streamMetadata.UserOrNull != nil && streamMetadata.UserOrNull.Stream != nil && streamMetadata.UserOrNull.Stream.Game != nil {
result.Category = streamMetadata.UserOrNull.Stream.Game.Name
result.CategorySlug = streamMetadata.UserOrNull.Stream.Game.Slug
if streamMetadata.UserOrNull != nil && streamMetadata.UserOrNull.Stream != nil {
if streamMetadata.UserOrNull.Stream.Game != nil {
result.Category = streamMetadata.UserOrNull.Stream.Game.Name
result.CategorySlug = streamMetadata.UserOrNull.Stream.Game.Slug
}
startedAt, err := time.Parse("2006-01-02T15:04:05Z", streamMetadata.UserOrNull.Stream.StartedAt)
if err == nil {

View File

@@ -4,6 +4,7 @@ import (
"errors"
"fmt"
"net/url"
"regexp"
"slices"
"strings"
)
@@ -77,3 +78,20 @@ func maybeCopySliceWithoutZeroValues[T int | float64](values []T) []T {
return values
}
var urlSchemePattern = regexp.MustCompile(`^[a-z]+:\/\/`)
func stripURLScheme(url string) string {
return urlSchemePattern.ReplaceAllString(url, "")
}
func limitStringLength(s string, max int) (string, bool) {
asRunes := []rune(s)
if len(asRunes) > max {
return string(asRunes[:max]), true
}
return s, false
}

View File

@@ -6,7 +6,7 @@ import (
"net/http"
)
type stockResponseJson struct {
type marketResponseJson struct {
Chart struct {
Result []struct {
Meta struct {
@@ -25,30 +25,30 @@ type stockResponseJson struct {
}
// TODO: allow changing chart time frame
const stockChartDays = 21
const marketChartDays = 21
func FetchStocksDataFromYahoo(stockRequests Stocks) (Stocks, error) {
requests := make([]*http.Request, 0, len(stockRequests))
func FetchMarketsDataFromYahoo(marketRequests []MarketRequest) (Markets, error) {
requests := make([]*http.Request, 0, len(marketRequests))
for i := range stockRequests {
request, _ := http.NewRequest("GET", fmt.Sprintf("https://query1.finance.yahoo.com/v8/finance/chart/%s?range=1mo&interval=1d", stockRequests[i].Symbol), nil)
for i := range marketRequests {
request, _ := http.NewRequest("GET", fmt.Sprintf("https://query1.finance.yahoo.com/v8/finance/chart/%s?range=1mo&interval=1d", marketRequests[i].Symbol), nil)
requests = append(requests, request)
}
job := newJob(decodeJsonFromRequestTask[stockResponseJson](defaultClient), requests)
job := newJob(decodeJsonFromRequestTask[marketResponseJson](defaultClient), requests)
responses, errs, err := workerPoolDo(job)
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrNoContent, err)
}
stocks := make(Stocks, 0, len(responses))
markets := make(Markets, 0, len(responses))
var failed int
for i := range responses {
if errs[i] != nil {
failed++
slog.Error("Failed to fetch stock data", "symbol", stockRequests[i].Symbol, "error", errs[i])
slog.Error("Failed to fetch market data", "symbol", marketRequests[i].Symbol, "error", errs[i])
continue
}
@@ -56,14 +56,14 @@ func FetchStocksDataFromYahoo(stockRequests Stocks) (Stocks, error) {
if len(response.Chart.Result) == 0 {
failed++
slog.Error("Stock response contains no data", "symbol", stockRequests[i].Symbol)
slog.Error("Market response contains no data", "symbol", marketRequests[i].Symbol)
continue
}
prices := response.Chart.Result[0].Indicators.Quote[0].Close
if len(prices) > stockChartDays {
prices = prices[len(prices)-stockChartDays:]
if len(prices) > marketChartDays {
prices = prices[len(prices)-marketChartDays:]
}
previous := response.Chart.Result[0].Meta.RegularMarketPrice
@@ -80,13 +80,10 @@ func FetchStocksDataFromYahoo(stockRequests Stocks) (Stocks, error) {
currency = response.Chart.Result[0].Meta.Currency
}
stocks = append(stocks, Stock{
Name: stockRequests[i].Name,
Symbol: response.Chart.Result[0].Meta.Symbol,
SymbolLink: stockRequests[i].SymbolLink,
ChartLink: stockRequests[i].ChartLink,
Price: response.Chart.Result[0].Meta.RegularMarketPrice,
Currency: currency,
markets = append(markets, Market{
MarketRequest: marketRequests[i],
Price: response.Chart.Result[0].Meta.RegularMarketPrice,
Currency: currency,
PercentChange: percentChange(
response.Chart.Result[0].Meta.RegularMarketPrice,
previous,
@@ -95,13 +92,13 @@ func FetchStocksDataFromYahoo(stockRequests Stocks) (Stocks, error) {
})
}
if len(stocks) == 0 {
if len(markets) == 0 {
return nil, ErrNoContent
}
if failed > 0 {
return stocks, fmt.Errorf("%w: could not fetch data for %d stock(s)", ErrPartialContent, failed)
return markets, fmt.Errorf("%w: could not fetch data for %d market(s)", ErrPartialContent, failed)
}
return stocks, nil
return markets, nil
}

View File

@@ -10,7 +10,7 @@ import (
)
type youtubeFeedResponseXml struct {
Channel string `xml:"title"`
Channel string `xml:"author>name"`
ChannelLink struct {
Href string `xml:"href,attr"`
} `xml:"link"`
@@ -39,11 +39,19 @@ func parseYoutubeFeedTime(t string) time.Time {
return parsedTime
}
func FetchYoutubeChannelUploads(channelIds []string, videoUrlTemplate string) (Videos, error) {
func FetchYoutubeChannelUploads(channelIds []string, videoUrlTemplate string, includeShorts bool) (Videos, error) {
requests := make([]*http.Request, 0, len(channelIds))
for i := range channelIds {
request, _ := http.NewRequest("GET", "https://www.youtube.com/feeds/videos.xml?channel_id="+channelIds[i], nil)
var feedUrl string
if !includeShorts && strings.HasPrefix(channelIds[i], "UC") {
playlistId := strings.Replace(channelIds[i], "UC", "UULF", 1)
feedUrl = "https://www.youtube.com/feeds/videos.xml?playlist_id=" + playlistId
} else {
feedUrl = "https://www.youtube.com/feeds/videos.xml?channel_id=" + channelIds[i]
}
request, _ := http.NewRequest("GET", feedUrl, nil)
requests = append(requests, request)
}
@@ -70,12 +78,6 @@ func FetchYoutubeChannelUploads(channelIds []string, videoUrlTemplate string) (V
for j := range response.Videos {
video := &response.Videos[j]
// TODO: figure out a better way of skipping shorts
if strings.Contains(video.Title, "#shorts") {
continue
}
var videoUrl string
if videoUrlTemplate == "" {

View File

@@ -32,6 +32,16 @@ func NewConfigFromYml(contents io.Reader) (*Config, error) {
return nil, err
}
for p := range config.Pages {
for c := range config.Pages[p].Columns {
for w := range config.Pages[p].Columns[c].Widgets {
if err := config.Pages[p].Columns[c].Widgets[w].Initialize(); err != nil {
return nil, err
}
}
}
}
return config, nil
}
@@ -50,12 +60,22 @@ func configIsValid(config *Config) error {
return fmt.Errorf("Page %d has no title", i+1)
}
if config.Pages[i].Width != "" && (config.Pages[i].Width != "wide" && config.Pages[i].Width != "slim") {
return fmt.Errorf("Page %d: width can only be either wide or slim", i+1)
}
if len(config.Pages[i].Columns) == 0 {
return fmt.Errorf("Page %d has no columns", i+1)
}
if len(config.Pages[i].Columns) > 3 {
return fmt.Errorf("Page %d has more than 3 columns: %d", i+1, len(config.Pages[i].Columns))
if config.Pages[i].Width == "slim" {
if len(config.Pages[i].Columns) > 2 {
return fmt.Errorf("Page %d is slim and cannot have more than 2 columns", i+1)
}
} else {
if len(config.Pages[i].Columns) > 3 {
return fmt.Errorf("Page %d has more than 3 columns: %d", i+1, len(config.Pages[i].Columns))
}
}
columnSizesCount := make(map[string]int)

View File

@@ -8,6 +8,7 @@ import (
"net/http"
"path/filepath"
"regexp"
"strconv"
"strings"
"sync"
"time"
@@ -24,6 +25,7 @@ type Application struct {
Version string
Config Config
slugToPage map[string]*Page
widgetByID map[uint64]widget.Widget
}
type Theme struct {
@@ -42,7 +44,8 @@ type Server struct {
Port uint16 `yaml:"port"`
AssetsPath string `yaml:"assets-path"`
BaseUrl string `yaml:"base-url"`
StartedAt time.Time `yaml:"-"`
AssetsHash string `yaml:"-"`
StartedAt time.Time `yaml:"-"` // used in custom css file
}
type Column struct {
@@ -56,11 +59,13 @@ type templateData struct {
}
type Page struct {
Title string `yaml:"name"`
Slug string `yaml:"slug"`
ShowMobileHeader bool `yaml:"show-mobile-header"`
Columns []Column `yaml:"columns"`
mu sync.Mutex
Title string `yaml:"name"`
Slug string `yaml:"slug"`
Width string `yaml:"width"`
ShowMobileHeader bool `yaml:"show-mobile-header"`
HideDesktopNavigation bool `yaml:"hide-desktop-navigation"`
Columns []Column `yaml:"columns"`
mu sync.Mutex
}
func (p *Page) UpdateOutdatedWidgets() {
@@ -106,16 +111,24 @@ func NewApplication(config *Config) (*Application, error) {
Version: buildVersion,
Config: *config,
slugToPage: make(map[string]*Page),
widgetByID: make(map[uint64]widget.Widget),
}
app.slugToPage[""] = &config.Pages[0]
for i := range config.Pages {
if config.Pages[i].Slug == "" {
config.Pages[i].Slug = titleToSlug(config.Pages[i].Title)
for p := range config.Pages {
if config.Pages[p].Slug == "" {
config.Pages[p].Slug = titleToSlug(config.Pages[p].Title)
}
app.slugToPage[config.Pages[i].Slug] = &config.Pages[i]
app.slugToPage[config.Pages[p].Slug] = &config.Pages[p]
for c := range config.Pages[p].Columns {
for w := range config.Pages[p].Columns[c].Widgets {
widget := config.Pages[p].Columns[c].Widgets[w]
app.widgetByID[widget.GetID()] = widget
}
}
}
return app, nil
@@ -190,15 +203,47 @@ func FileServerWithCache(fs http.FileSystem, cacheDuration time.Duration) http.H
})
}
func (a *Application) HandleWidgetRequest(w http.ResponseWriter, r *http.Request) {
widgetValue := r.PathValue("widget")
widgetID, err := strconv.ParseUint(widgetValue, 10, 64)
if err != nil {
a.HandleNotFound(w, r)
return
}
widget, exists := a.widgetByID[widgetID]
if !exists {
a.HandleNotFound(w, r)
return
}
widget.HandleRequest(w, r)
}
func (a *Application) AssetPath(asset string) string {
return "/static/" + a.Config.Server.AssetsHash + "/" + asset
}
func (a *Application) Serve() error {
a.Config.Server.AssetsHash = assets.PublicFSHash
// TODO: add gzip support, static files must have their gzipped contents cached
// TODO: add HTTPS support
mux := http.NewServeMux()
mux.HandleFunc("GET /{$}", a.HandlePageRequest)
mux.HandleFunc("GET /{page}", a.HandlePageRequest)
mux.HandleFunc("GET /api/pages/{page}/content/{$}", a.HandlePageContentRequest)
mux.Handle("GET /static/{path...}", http.StripPrefix("/static/", FileServerWithCache(http.FS(assets.PublicFS), 2*time.Hour)))
mux.HandleFunc("/api/widgets/{widget}/{path...}", a.HandleWidgetRequest)
mux.Handle(
fmt.Sprintf("GET /static/%s/{path...}", a.Config.Server.AssetsHash),
http.StripPrefix("/static/"+a.Config.Server.AssetsHash, FileServerWithCache(http.FS(assets.PublicFS), 8*time.Hour)),
)
if a.Config.Server.AssetsPath != "" {
absAssetsPath, err := filepath.Abs(a.Config.Server.AssetsPath)
@@ -218,7 +263,7 @@ func (a *Application) Serve() error {
}
a.Config.Server.StartedAt = time.Now()
slog.Info("Starting server", "host", a.Config.Server.Host, "port", a.Config.Server.Port, "base url", a.Config.Server.BaseUrl)
return server.ListenAndServe()
}

View File

@@ -2,7 +2,6 @@ package widget
import (
"html/template"
"strings"
"github.com/glanceapp/glance/internal/assets"
)
@@ -34,11 +33,8 @@ func (widget *Bookmarks) Initialize() error {
continue
}
if strings.HasPrefix(widget.Groups[g].Links[l].Icon, "si:") {
icon := strings.TrimPrefix(widget.Groups[g].Links[l].Icon, "si:")
widget.Groups[g].Links[l].IsSimpleIcon = true
widget.Groups[g].Links[l].Icon = "https://cdnjs.cloudflare.com/ajax/libs/simple-icons/11.14.0/" + icon + ".svg"
}
link := &widget.Groups[g].Links[l]
link.Icon, link.IsSimpleIcon = toSimpleIconIfPrefixed(link.Icon)
}
}

View File

@@ -0,0 +1,66 @@
package widget
import (
"context"
"html/template"
"time"
"github.com/glanceapp/glance/internal/assets"
"github.com/glanceapp/glance/internal/feed"
)
type ChangeDetection struct {
widgetBase `yaml:",inline"`
ChangeDetections feed.ChangeDetectionWatches `yaml:"-"`
WatchUUIDs []string `yaml:"watches"`
InstanceURL string `yaml:"instance-url"`
Token OptionalEnvString `yaml:"token"`
Limit int `yaml:"limit"`
CollapseAfter int `yaml:"collapse-after"`
}
func (widget *ChangeDetection) Initialize() error {
widget.withTitle("Change Detection").withCacheDuration(1 * time.Hour)
if widget.Limit <= 0 {
widget.Limit = 10
}
if widget.CollapseAfter == 0 || widget.CollapseAfter < -1 {
widget.CollapseAfter = 5
}
if widget.InstanceURL == "" {
widget.InstanceURL = "https://www.changedetection.io"
}
return nil
}
func (widget *ChangeDetection) Update(ctx context.Context) {
if len(widget.WatchUUIDs) == 0 {
uuids, err := feed.FetchWatchUUIDsFromChangeDetection(widget.InstanceURL, string(widget.Token))
if !widget.canContinueUpdateAfterHandlingErr(err) {
return
}
widget.WatchUUIDs = uuids
}
watches, err := feed.FetchWatchesFromChangeDetection(widget.InstanceURL, widget.WatchUUIDs, string(widget.Token))
if !widget.canContinueUpdateAfterHandlingErr(err) {
return
}
if len(watches) > widget.Limit {
watches = watches[:widget.Limit]
}
widget.ChangeDetections = watches
}
func (widget *ChangeDetection) Render() template.HTML {
return widget.render(widget, assets.ChangeDetectionTemplate)
}

50
internal/widget/clock.go Normal file
View File

@@ -0,0 +1,50 @@
package widget
import (
"errors"
"fmt"
"html/template"
"time"
"github.com/glanceapp/glance/internal/assets"
)
type Clock struct {
widgetBase `yaml:",inline"`
cachedHTML template.HTML `yaml:"-"`
HourFormat string `yaml:"hour-format"`
Timezones []struct {
Timezone string `yaml:"timezone"`
Label string `yaml:"label"`
} `yaml:"timezones"`
}
func (widget *Clock) Initialize() error {
widget.withTitle("Clock").withError(nil)
if widget.HourFormat == "" {
widget.HourFormat = "24h"
} else if widget.HourFormat != "12h" && widget.HourFormat != "24h" {
return errors.New("invalid hour format for clock widget, must be either 12h or 24h")
}
for t := range widget.Timezones {
if widget.Timezones[t].Timezone == "" {
return errors.New("missing timezone value for clock widget")
}
_, err := time.LoadLocation(widget.Timezones[t].Timezone)
if err != nil {
return fmt.Errorf("invalid timezone '%s' for clock widget: %v", widget.Timezones[t].Timezone, err)
}
}
widget.cachedHTML = widget.render(widget, assets.ClockTemplate)
return nil
}
func (widget *Clock) Render() template.HTML {
return widget.cachedHTML
}

View File

@@ -0,0 +1,59 @@
package widget
import (
"context"
"errors"
"html/template"
"net/url"
"time"
"github.com/glanceapp/glance/internal/assets"
"github.com/glanceapp/glance/internal/feed"
)
type Extension struct {
widgetBase `yaml:",inline"`
URL string `yaml:"url"`
Parameters map[string]string `yaml:"parameters"`
AllowHtml bool `yaml:"allow-potentially-dangerous-html"`
Extension feed.Extension `yaml:"-"`
cachedHTML template.HTML `yaml:"-"`
}
func (widget *Extension) Initialize() error {
widget.withTitle("Extension").withCacheDuration(time.Minute * 30)
if widget.URL == "" {
return errors.New("no extension URL specified")
}
_, err := url.Parse(widget.URL)
if err != nil {
return err
}
return nil
}
func (widget *Extension) Update(ctx context.Context) {
extension, err := feed.FetchExtension(feed.ExtensionRequestOptions{
URL: widget.URL,
Parameters: widget.Parameters,
AllowHtml: widget.AllowHtml,
})
widget.canContinueUpdateAfterHandlingErr(err)
widget.Extension = extension
if extension.Title != "" {
widget.Title = extension.Title
}
widget.cachedHTML = widget.render(widget, assets.ExtensionTemplate)
}
func (widget *Extension) Render() template.HTML {
return widget.cachedHTML
}

View File

@@ -6,6 +6,7 @@ import (
"os"
"regexp"
"strconv"
"strings"
"time"
"gopkg.in/yaml.v3"
@@ -150,3 +151,14 @@ func (f *OptionalEnvString) UnmarshalYAML(node *yaml.Node) error {
return nil
}
func toSimpleIconIfPrefixed(icon string) (string, bool) {
if !strings.HasPrefix(icon, "si:") {
return icon, false
}
icon = strings.TrimPrefix(icon, "si:")
icon = "https://cdnjs.cloudflare.com/ajax/libs/simple-icons/11.14.0/" + icon + ".svg"
return icon, true
}

70
internal/widget/group.go Normal file
View File

@@ -0,0 +1,70 @@
package widget
import (
"context"
"errors"
"html/template"
"sync"
"time"
"github.com/glanceapp/glance/internal/assets"
)
type Group struct {
widgetBase `yaml:",inline"`
Widgets Widgets `yaml:"widgets"`
}
func (widget *Group) Initialize() error {
widget.withError(nil)
widget.HideHeader = true
for i := range widget.Widgets {
widget.Widgets[i].SetHideHeader(true)
if widget.Widgets[i].GetType() == "group" {
return errors.New("nested groups are not allowed")
}
if err := widget.Widgets[i].Initialize(); err != nil {
return err
}
}
return nil
}
func (widget *Group) Update(ctx context.Context) {
var wg sync.WaitGroup
now := time.Now()
for w := range widget.Widgets {
widget := widget.Widgets[w]
if !widget.RequiresUpdate(&now) {
continue
}
wg.Add(1)
go func() {
defer wg.Done()
widget.Update(ctx)
}()
}
wg.Wait()
}
func (widget *Group) RequiresUpdate(now *time.Time) bool {
for i := range widget.Widgets {
if widget.Widgets[i].RequiresUpdate(now) {
return true
}
}
return false
}
func (widget *Group) Render() template.HTML {
return widget.render(widget, assets.GroupTemplate)
}

View File

@@ -21,7 +21,10 @@ type HackerNews struct {
}
func (widget *HackerNews) Initialize() error {
widget.withTitle("Hacker News").withCacheDuration(30 * time.Minute)
widget.
withTitle("Hacker News").
withTitleURL("https://news.ycombinator.com/").
withCacheDuration(30 * time.Minute)
if widget.Limit <= 0 {
widget.Limit = 15

20
internal/widget/html.go Normal file
View File

@@ -0,0 +1,20 @@
package widget
import (
"html/template"
)
type HTML struct {
widgetBase `yaml:",inline"`
Source template.HTML `yaml:"source"`
}
func (widget *HTML) Initialize() error {
widget.withTitle("").withError(nil)
return nil
}
func (widget *HTML) Render() template.HTML {
return widget.Source
}

View File

@@ -0,0 +1,64 @@
package widget
import (
"context"
"html/template"
"time"
"github.com/glanceapp/glance/internal/assets"
"github.com/glanceapp/glance/internal/feed"
)
type Lobsters struct {
widgetBase `yaml:",inline"`
Posts feed.ForumPosts `yaml:"-"`
InstanceURL string `yaml:"instance-url"`
CustomURL string `yaml:"custom-url"`
Limit int `yaml:"limit"`
CollapseAfter int `yaml:"collapse-after"`
SortBy string `yaml:"sort-by"`
Tags []string `yaml:"tags"`
ShowThumbnails bool `yaml:"-"`
}
func (widget *Lobsters) Initialize() error {
widget.withTitle("Lobsters").withCacheDuration(time.Hour)
if widget.InstanceURL == "" {
widget.withTitleURL("https://lobste.rs")
} else {
widget.withTitleURL(widget.InstanceURL)
}
if widget.SortBy == "" || (widget.SortBy != "hot" && widget.SortBy != "new") {
widget.SortBy = "hot"
}
if widget.Limit <= 0 {
widget.Limit = 15
}
if widget.CollapseAfter == 0 || widget.CollapseAfter < -1 {
widget.CollapseAfter = 5
}
return nil
}
func (widget *Lobsters) Update(ctx context.Context) {
posts, err := feed.FetchLobstersPosts(widget.CustomURL, widget.InstanceURL, widget.SortBy, widget.Tags)
if !widget.canContinueUpdateAfterHandlingErr(err) {
return
}
if widget.Limit < len(posts) {
posts = posts[:widget.Limit]
}
widget.Posts = posts
}
func (widget *Lobsters) Render() template.HTML {
return widget.render(widget, assets.ForumPostsTemplate)
}

View File

@@ -2,9 +2,7 @@ package widget
import (
"context"
"fmt"
"html/template"
"net/http"
"strconv"
"time"
@@ -37,22 +35,23 @@ func statusCodeToText(status int) string {
func statusCodeToStyle(status int) string {
if status == 200 {
return "good"
return "ok"
}
return "bad"
return "error"
}
type Monitor struct {
widgetBase `yaml:",inline"`
Sites []struct {
Title string `yaml:"title"`
Url OptionalEnvString `yaml:"url"`
IconUrl string `yaml:"icon"`
SameTab bool `yaml:"same-tab"`
Status *feed.SiteStatus `yaml:"-"`
StatusText string `yaml:"-"`
StatusStyle string `yaml:"-"`
*feed.SiteStatusRequest `yaml:",inline"`
Status *feed.SiteStatus `yaml:"-"`
Title string `yaml:"title"`
IconUrl string `yaml:"icon"`
IsSimpleIcon bool `yaml:"-"`
SameTab bool `yaml:"same-tab"`
StatusText string `yaml:"-"`
StatusStyle string `yaml:"-"`
} `yaml:"sites"`
Style string `yaml:"style"`
}
@@ -60,25 +59,21 @@ type Monitor struct {
func (widget *Monitor) Initialize() error {
widget.withTitle("Monitor").withCacheDuration(5 * time.Minute)
for i := range widget.Sites {
widget.Sites[i].IconUrl, widget.Sites[i].IsSimpleIcon = toSimpleIconIfPrefixed(widget.Sites[i].IconUrl)
}
return nil
}
func (widget *Monitor) Update(ctx context.Context) {
requests := make([]*http.Request, len(widget.Sites))
requests := make([]*feed.SiteStatusRequest, len(widget.Sites))
for i := range widget.Sites {
request, err := http.NewRequest("GET", string(widget.Sites[i].Url), nil)
if err != nil {
message := fmt.Errorf("failed to create http request for %s: %s", widget.Sites[i].Url, err)
widget.withNotice(message)
continue
}
requests[i] = request
requests[i] = widget.Sites[i].SiteStatusRequest
}
statuses, err := feed.FetchStatusesForRequests(requests)
statuses, err := feed.FetchStatusForSites(requests)
if !widget.canContinueUpdateAfterHandlingErr(err) {
return

View File

@@ -54,7 +54,10 @@ func (widget *Reddit) Initialize() error {
}
}
widget.withTitle("/r/" + widget.Subreddit).withCacheDuration(30 * time.Minute)
widget.
withTitle("/r/" + widget.Subreddit).
withTitleURL("https://www.reddit.com/r/" + widget.Subreddit + "/").
withCacheDuration(30 * time.Minute)
return nil
}

View File

@@ -39,6 +39,12 @@ func (widget *RSS) Initialize() error {
widget.CardHeight = 0
}
if widget.Style == "detailed-list" {
for i := range widget.FeedRequests {
widget.FeedRequests[i].IsDetailed = true
}
}
return nil
}
@@ -65,5 +71,9 @@ func (widget *RSS) Render() template.HTML {
return widget.render(widget, assets.RSSHorizontalCards2Template)
}
if widget.Style == "detailed-list" {
return widget.render(widget, assets.RSSDetailedListTemplate)
}
return widget.render(widget, assets.RSSListTemplate)
}

68
internal/widget/search.go Normal file
View File

@@ -0,0 +1,68 @@
package widget
import (
"fmt"
"html/template"
"strings"
"github.com/glanceapp/glance/internal/assets"
)
type SearchBang struct {
Title string
Shortcut string
URL string
}
type Search struct {
widgetBase `yaml:",inline"`
cachedHTML template.HTML `yaml:"-"`
SearchEngine string `yaml:"search-engine"`
Bangs []SearchBang `yaml:"bangs"`
NewTab bool `yaml:"new-tab"`
Autofocus bool `yaml:"autofocus"`
}
func convertSearchUrl(url string) string {
// Go's template is being stubborn and continues to escape the curlies in the
// URL regardless of what the type of the variable is so this is my way around it
return strings.ReplaceAll(url, "{QUERY}", "!QUERY!")
}
var searchEngines = map[string]string{
"duckduckgo": "https://duckduckgo.com/?q={QUERY}",
"google": "https://www.google.com/search?q={QUERY}",
}
func (widget *Search) Initialize() error {
widget.withTitle("Search").withError(nil)
if widget.SearchEngine == "" {
widget.SearchEngine = "duckduckgo"
}
if url, ok := searchEngines[widget.SearchEngine]; ok {
widget.SearchEngine = url
}
widget.SearchEngine = convertSearchUrl(widget.SearchEngine)
for i := range widget.Bangs {
if widget.Bangs[i].Shortcut == "" {
return fmt.Errorf("Search bang %d has no shortcut", i+1)
}
if widget.Bangs[i].URL == "" {
return fmt.Errorf("Search bang %d has no URL", i+1)
}
widget.Bangs[i].URL = convertSearchUrl(widget.Bangs[i].URL)
}
widget.cachedHTML = widget.render(widget, assets.SearchTemplate)
return nil
}
func (widget *Search) Render() template.HTML {
return widget.cachedHTML
}

View File

@@ -9,34 +9,39 @@ import (
"github.com/glanceapp/glance/internal/feed"
)
// TODO: rename to Markets at some point
type Stocks struct {
widgetBase `yaml:",inline"`
Stocks feed.Stocks `yaml:"stocks"`
Sort string `yaml:"sort-by"`
Style string `yaml:"style"`
type Markets struct {
widgetBase `yaml:",inline"`
StocksRequests []feed.MarketRequest `yaml:"stocks"`
MarketRequests []feed.MarketRequest `yaml:"markets"`
Sort string `yaml:"sort-by"`
Style string `yaml:"style"`
Markets feed.Markets `yaml:"-"`
}
func (widget *Stocks) Initialize() error {
widget.withTitle("Stocks").withCacheDuration(time.Hour)
func (widget *Markets) Initialize() error {
widget.withTitle("Markets").withCacheDuration(time.Hour)
if len(widget.MarketRequests) == 0 {
widget.MarketRequests = widget.StocksRequests
}
return nil
}
func (widget *Stocks) Update(ctx context.Context) {
stocks, err := feed.FetchStocksDataFromYahoo(widget.Stocks)
func (widget *Markets) Update(ctx context.Context) {
markets, err := feed.FetchMarketsDataFromYahoo(widget.MarketRequests)
if !widget.canContinueUpdateAfterHandlingErr(err) {
return
}
if widget.Sort == "absolute-change" {
stocks.SortByAbsChange()
markets.SortByAbsChange()
}
widget.Stocks = stocks
widget.Markets = markets
}
func (widget *Stocks) Render() template.HTML {
return widget.render(widget, assets.StocksTemplate)
func (widget *Markets) Render() template.HTML {
return widget.render(widget, assets.MarketsTemplate)
}

View File

@@ -18,7 +18,10 @@ type TwitchChannels struct {
}
func (widget *TwitchChannels) Initialize() error {
widget.withTitle("Twitch Channels").withCacheDuration(time.Minute * 10)
widget.
withTitle("Twitch Channels").
withTitleURL("https://www.twitch.tv/directory/following").
withCacheDuration(time.Minute * 10)
if widget.CollapseAfter == 0 || widget.CollapseAfter < -1 {
widget.CollapseAfter = 5

View File

@@ -18,7 +18,10 @@ type TwitchGames struct {
}
func (widget *TwitchGames) Initialize() error {
widget.withTitle("Top games on Twitch").withCacheDuration(time.Minute * 10)
widget.
withTitle("Top games on Twitch").
withTitleURL("https://www.twitch.tv/directory?sort=VIEWER_COUNT").
withCacheDuration(time.Minute * 10)
if widget.Limit <= 0 {
widget.Limit = 10

View File

@@ -17,6 +17,7 @@ type Videos struct {
CollapseAfterRows int `yaml:"collapse-after-rows"`
Channels []string `yaml:"channels"`
Limit int `yaml:"limit"`
IncludeShorts bool `yaml:"include-shorts"`
}
func (widget *Videos) Initialize() error {
@@ -34,7 +35,7 @@ func (widget *Videos) Initialize() error {
}
func (widget *Videos) Update(ctx context.Context) {
videos, err := feed.FetchYoutubeChannelUploads(widget.Channels, widget.VideoUrlTemplate)
videos, err := feed.FetchYoutubeChannelUploads(widget.Channels, widget.VideoUrlTemplate, widget.IncludeShorts)
if !widget.canContinueUpdateAfterHandlingErr(err) {
return

View File

@@ -27,6 +27,10 @@ var timeLabels24h = [12]string{"02:00", "04:00", "06:00", "08:00", "10:00", "12:
func (widget *Weather) Initialize() error {
widget.withTitle("Weather").withCacheOnTheHour()
if widget.Location == "" {
return fmt.Errorf("location must be specified for weather widget")
}
if widget.HourFormat == "" || widget.HourFormat == "12h" {
widget.TimeLabels = timeLabels12h
} else if widget.HourFormat == "24h" {
@@ -41,18 +45,21 @@ func (widget *Weather) Initialize() error {
return fmt.Errorf("invalid units '%s' for weather, must be either metric or imperial", widget.Units)
}
place, err := feed.FetchPlaceFromName(widget.Location)
if err != nil {
return fmt.Errorf("failed fetching data for %s: %v", widget.Location, err)
}
widget.Place = place
return nil
}
func (widget *Weather) Update(ctx context.Context) {
if widget.Place == nil {
place, err := feed.FetchPlaceFromName(widget.Location)
if err != nil {
widget.withError(err).scheduleEarlyUpdate()
return
}
widget.Place = place
}
weather, err := feed.FetchWeatherForPlace(widget.Place, widget.Units)
if !widget.canContinueUpdateAfterHandlingErr(err) {

View File

@@ -8,6 +8,8 @@ import (
"html/template"
"log/slog"
"math"
"net/http"
"sync/atomic"
"time"
"github.com/glanceapp/glance/internal/feed"
@@ -15,39 +17,61 @@ import (
"gopkg.in/yaml.v3"
)
var uniqueID atomic.Uint64
func New(widgetType string) (Widget, error) {
var widget Widget
switch widgetType {
case "calendar":
return &Calendar{}, nil
widget = &Calendar{}
case "clock":
widget = &Clock{}
case "weather":
return &Weather{}, nil
widget = &Weather{}
case "bookmarks":
return &Bookmarks{}, nil
widget = &Bookmarks{}
case "iframe":
return &IFrame{}, nil
widget = &IFrame{}
case "html":
widget = &HTML{}
case "hacker-news":
return &HackerNews{}, nil
widget = &HackerNews{}
case "releases":
return &Releases{}, nil
widget = &Releases{}
case "videos":
return &Videos{}, nil
case "stocks":
return &Stocks{}, nil
widget = &Videos{}
case "markets", "stocks":
widget = &Markets{}
case "reddit":
return &Reddit{}, nil
widget = &Reddit{}
case "rss":
return &RSS{}, nil
widget = &RSS{}
case "monitor":
return &Monitor{}, nil
widget = &Monitor{}
case "twitch-top-games":
return &TwitchGames{}, nil
widget = &TwitchGames{}
case "twitch-channels":
return &TwitchChannels{}, nil
widget = &TwitchChannels{}
case "lobsters":
widget = &Lobsters{}
case "change-detection":
widget = &ChangeDetection{}
case "repository":
return &Repository{}, nil
widget = &Repository{}
case "search":
widget = &Search{}
case "extension":
widget = &Extension{}
case "group":
widget = &Group{}
default:
return nil, fmt.Errorf("unknown widget type: %s", widgetType)
}
widget.SetID(uniqueID.Add(1))
return widget, nil
}
type Widgets []Widget
@@ -78,10 +102,6 @@ func (w *Widgets) UnmarshalYAML(node *yaml.Node) error {
return err
}
if err = widget.Initialize(); err != nil {
return err
}
*w = append(*w, widget)
}
@@ -94,6 +114,10 @@ type Widget interface {
Update(context.Context)
Render() template.HTML
GetType() string
GetID() uint64
SetID(uint64)
HandleRequest(w http.ResponseWriter, r *http.Request)
SetHideHeader(bool)
}
type cacheType int
@@ -105,8 +129,11 @@ const (
)
type widgetBase struct {
ID uint64 `yaml:"-"`
Type string `yaml:"type"`
Title string `yaml:"title"`
TitleURL string `yaml:"title-url"`
CSSClass string `yaml:"css-class"`
CustomCacheDuration DurationField `yaml:"cache"`
ContentAvailable bool `yaml:"-"`
Error error `yaml:"-"`
@@ -116,6 +143,7 @@ type widgetBase struct {
cacheType cacheType `yaml:"-"`
nextUpdate time.Time `yaml:"-"`
updateRetriedTimes int `yaml:"-"`
HideHeader bool `yaml:"-"`
}
func (w *widgetBase) RequiresUpdate(now *time.Time) bool {
@@ -134,6 +162,22 @@ func (w *widgetBase) Update(ctx context.Context) {
}
func (w *widgetBase) GetID() uint64 {
return w.ID
}
func (w *widgetBase) SetID(id uint64) {
w.ID = id
}
func (w *widgetBase) SetHideHeader(value bool) {
w.HideHeader = value
}
func (widget *widgetBase) HandleRequest(w http.ResponseWriter, r *http.Request) {
http.Error(w, "not implemented", http.StatusNotImplemented)
}
func (w *widgetBase) GetType() string {
return w.Type
}
@@ -173,6 +217,14 @@ func (w *widgetBase) withTitle(title string) *widgetBase {
return w
}
func (w *widgetBase) withTitleURL(titleURL string) *widgetBase {
if w.TitleURL == "" {
w.TitleURL = titleURL
}
return w
}
func (w *widgetBase) withCacheDuration(duration time.Duration) *widgetBase {
w.cacheType = cacheTypeDuration