mirror of
https://github.com/Xevion/glance.git
synced 2025-12-16 00:11:56 -06:00
Merge branch 'release/v0.7.0' into default-expand-mobile-navigation
This commit is contained in:
1
internal/assets/static/icons/codeberg.svg
Normal file
1
internal/assets/static/icons/codeberg.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M11.955.49A12 12 0 0 0 0 12.49a12 12 0 0 0 1.832 6.373L11.838 5.928a.187.14 0 0 1 .324 0l10.006 12.935A12 12 0 0 0 24 12.49a12 12 0 0 0-12-12 12 12 0 0 0-.045 0zm.375 6.467l4.416 16.553a12 12 0 0 0 5.137-4.213z"/></svg>
|
||||
|
After Width: | Height: | Size: 300 B |
@@ -1,27 +1,6 @@
|
||||
import { setupPopovers } from './popover.js';
|
||||
|
||||
function throttledDebounce(callback, maxDebounceTimes, debounceDelay) {
|
||||
let debounceTimeout;
|
||||
let timesDebounced = 0;
|
||||
|
||||
return function () {
|
||||
if (timesDebounced == maxDebounceTimes) {
|
||||
clearTimeout(debounceTimeout);
|
||||
timesDebounced = 0;
|
||||
callback();
|
||||
return;
|
||||
}
|
||||
|
||||
clearTimeout(debounceTimeout);
|
||||
timesDebounced++;
|
||||
|
||||
debounceTimeout = setTimeout(() => {
|
||||
timesDebounced = 0;
|
||||
callback();
|
||||
}, debounceDelay);
|
||||
};
|
||||
};
|
||||
|
||||
import { setupMasonries } from './masonry.js';
|
||||
import { throttledDebounce, isElementVisible } from './utils.js';
|
||||
|
||||
async function fetchPageContent(pageData) {
|
||||
// TODO: handle non 200 status codes/time outs
|
||||
@@ -427,7 +406,7 @@ function setupCollapsibleGrids() {
|
||||
|
||||
const button = attachExpandToggleButton(gridElement);
|
||||
|
||||
let cardsPerRow = 2;
|
||||
let cardsPerRow;
|
||||
|
||||
const resolveCollapsibleItems = () => {
|
||||
const hideItemsAfterIndex = cardsPerRow * collapseAfterRows;
|
||||
@@ -457,12 +436,11 @@ function setupCollapsibleGrids() {
|
||||
}
|
||||
};
|
||||
|
||||
afterContentReady(() => {
|
||||
cardsPerRow = getCardsPerRow();
|
||||
resolveCollapsibleItems();
|
||||
});
|
||||
const observer = new ResizeObserver(() => {
|
||||
if (!isElementVisible(gridElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.addEventListener("resize", () => {
|
||||
const newCardsPerRow = getCardsPerRow();
|
||||
|
||||
if (cardsPerRow == newCardsPerRow) {
|
||||
@@ -472,6 +450,8 @@ function setupCollapsibleGrids() {
|
||||
cardsPerRow = newCardsPerRow;
|
||||
resolveCollapsibleItems();
|
||||
});
|
||||
|
||||
afterContentReady(() => observer.observe(gridElement));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -523,9 +503,34 @@ function timeInZone(now, zone) {
|
||||
timeInZone = now
|
||||
}
|
||||
|
||||
const diffInHours = Math.round((timeInZone.getTime() - now.getTime()) / 1000 / 60 / 60);
|
||||
const diffInMinutes = Math.round((timeInZone.getTime() - now.getTime()) / 1000 / 60);
|
||||
|
||||
return { time: timeInZone, diffInHours: diffInHours };
|
||||
return { time: timeInZone, diffInMinutes: diffInMinutes };
|
||||
}
|
||||
|
||||
function zoneDiffText(diffInMinutes) {
|
||||
if (diffInMinutes == 0) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const sign = diffInMinutes < 0 ? "-" : "+";
|
||||
const signText = diffInMinutes < 0 ? "behind" : "ahead";
|
||||
|
||||
diffInMinutes = Math.abs(diffInMinutes);
|
||||
|
||||
const hours = Math.floor(diffInMinutes / 60);
|
||||
const minutes = diffInMinutes % 60;
|
||||
const hourSuffix = hours == 1 ? "" : "s";
|
||||
|
||||
if (minutes == 0) {
|
||||
return { text: `${sign}${hours}h`, title: `${hours} hour${hourSuffix} ${signText}` };
|
||||
}
|
||||
|
||||
if (hours == 0) {
|
||||
return { text: `${sign}${minutes}m`, title: `${minutes} minutes ${signText}` };
|
||||
}
|
||||
|
||||
return { text: `${sign}${hours}h~`, title: `${hours} hour${hourSuffix} and ${minutes} minutes ${signText}` };
|
||||
}
|
||||
|
||||
function setupClocks() {
|
||||
@@ -568,9 +573,11 @@ function setupClocks() {
|
||||
);
|
||||
|
||||
updateCallbacks.push((now) => {
|
||||
const { time, diffInHours } = timeInZone(now, timeZoneContainer.dataset.timeInZone);
|
||||
const { time, diffInMinutes } = timeInZone(now, timeZoneContainer.dataset.timeInZone);
|
||||
setZoneTime(time);
|
||||
diffElement.textContent = (diffInHours <= 0 ? diffInHours : '+' + diffInHours) + 'h';
|
||||
const { text, title } = zoneDiffText(diffInMinutes);
|
||||
diffElement.textContent = text;
|
||||
diffElement.title = title;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -602,6 +609,7 @@ async function setupPage() {
|
||||
setupCollapsibleLists();
|
||||
setupCollapsibleGrids();
|
||||
setupGroups();
|
||||
setupMasonries();
|
||||
setupDynamicRelativeTime();
|
||||
setupLazyImages();
|
||||
} finally {
|
||||
|
||||
53
internal/assets/static/js/masonry.js
Normal file
53
internal/assets/static/js/masonry.js
Normal file
@@ -0,0 +1,53 @@
|
||||
|
||||
import { clamp } from "./utils.js";
|
||||
|
||||
export function setupMasonries() {
|
||||
const masonryContainers = document.getElementsByClassName("masonry");
|
||||
|
||||
for (let i = 0; i < masonryContainers.length; i++) {
|
||||
const container = masonryContainers[i];
|
||||
|
||||
const options = {
|
||||
minColumnWidth: container.dataset.minColumnWidth || 330,
|
||||
maxColumns: container.dataset.maxColumns || 6,
|
||||
};
|
||||
|
||||
const items = Array.from(container.children);
|
||||
let previousColumnsCount = 0;
|
||||
|
||||
const render = function() {
|
||||
const columnsCount = clamp(
|
||||
Math.floor(container.offsetWidth / options.minColumnWidth),
|
||||
1,
|
||||
Math.min(options.maxColumns, items.length)
|
||||
);
|
||||
|
||||
if (columnsCount === previousColumnsCount) {
|
||||
return;
|
||||
} else {
|
||||
container.textContent = "";
|
||||
previousColumnsCount = columnsCount;
|
||||
}
|
||||
|
||||
const columnsFragment = document.createDocumentFragment();
|
||||
|
||||
for (let i = 0; i < columnsCount; i++) {
|
||||
const column = document.createElement("div");
|
||||
column.className = "masonry-column";
|
||||
columnsFragment.append(column);
|
||||
}
|
||||
|
||||
// poor man's masonry
|
||||
// TODO: add an option that allows placing items in the
|
||||
// shortest column instead of iterating the columns in order
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
columnsFragment.children[i % columnsCount].appendChild(items[i]);
|
||||
}
|
||||
|
||||
container.append(columnsFragment);
|
||||
};
|
||||
|
||||
const observer = new ResizeObserver(() => requestAnimationFrame(render));
|
||||
observer.observe(container);
|
||||
}
|
||||
}
|
||||
@@ -56,6 +56,8 @@ function clearTogglePopoverTimeout() {
|
||||
}
|
||||
|
||||
function showPopover() {
|
||||
if (pendingTarget === null) return;
|
||||
|
||||
activeTarget = pendingTarget;
|
||||
pendingTarget = null;
|
||||
|
||||
@@ -109,9 +111,10 @@ function repositionContainer() {
|
||||
|
||||
const containerBounds = containerElement.getBoundingClientRect();
|
||||
const containerInlinePadding = parseInt(containerComputedStyle.getPropertyValue("padding-inline"));
|
||||
const targetBoundsWidthOffset = targetBounds.width * (activeTarget.dataset.popoverOffset || 0.5);
|
||||
const targetBoundsWidthOffset = targetBounds.width * (activeTarget.dataset.popoverTargetOffset || 0.5);
|
||||
const position = activeTarget.dataset.popoverPosition || "below";
|
||||
const left = Math.round(targetBounds.left + targetBoundsWidthOffset - (containerBounds.width / 2));
|
||||
const popoverOffest = activeTarget.dataset.popoverOffset || 0.5;
|
||||
const left = Math.round(targetBounds.left + targetBoundsWidthOffset - (containerBounds.width * popoverOffest));
|
||||
|
||||
if (left < 0) {
|
||||
containerElement.style.left = 0;
|
||||
@@ -124,7 +127,7 @@ function repositionContainer() {
|
||||
} else {
|
||||
containerElement.style.removeProperty("right");
|
||||
containerElement.style.left = left + "px";
|
||||
containerElement.style.removeProperty("--triangle-offset");
|
||||
containerElement.style.setProperty("--triangle-offset", ((targetBounds.left + targetBoundsWidthOffset) - left - containerInlinePadding) + "px");
|
||||
}
|
||||
|
||||
const distanceFromTarget = activeTarget.dataset.popoverMargin || defaultDistanceFromTarget;
|
||||
|
||||
29
internal/assets/static/js/utils.js
Normal file
29
internal/assets/static/js/utils.js
Normal file
@@ -0,0 +1,29 @@
|
||||
export function throttledDebounce(callback, maxDebounceTimes, debounceDelay) {
|
||||
let debounceTimeout;
|
||||
let timesDebounced = 0;
|
||||
|
||||
return function () {
|
||||
if (timesDebounced == maxDebounceTimes) {
|
||||
clearTimeout(debounceTimeout);
|
||||
timesDebounced = 0;
|
||||
callback();
|
||||
return;
|
||||
}
|
||||
|
||||
clearTimeout(debounceTimeout);
|
||||
timesDebounced++;
|
||||
|
||||
debounceTimeout = setTimeout(() => {
|
||||
timesDebounced = 0;
|
||||
callback();
|
||||
}, debounceDelay);
|
||||
};
|
||||
};
|
||||
|
||||
export function isElementVisible(element) {
|
||||
return !!(element.offsetWidth || element.offsetHeight || element.getClientRects().length);
|
||||
}
|
||||
|
||||
export function clamp(value, min, max) {
|
||||
return Math.min(Math.max(value, min), max);
|
||||
}
|
||||
@@ -440,6 +440,17 @@ kbd:active {
|
||||
box-shadow: 0 0 0 0 var(--color-widget-background-highlight);
|
||||
}
|
||||
|
||||
.masonry {
|
||||
display: flex;
|
||||
gap: var(--widget-gap);
|
||||
}
|
||||
|
||||
.masonry-column {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.popover-container, [data-popover-html] {
|
||||
display: none;
|
||||
}
|
||||
@@ -851,6 +862,7 @@ details[open] .summary::after {
|
||||
border-bottom: 2px solid transparent;
|
||||
transition: color .3s, border-color .3s;
|
||||
font-size: var(--font-size-h3);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.nav-item:not(.nav-item-current):hover {
|
||||
@@ -1049,6 +1061,7 @@ details[open] .summary::after {
|
||||
border-radius: var(--border-radius);
|
||||
padding: 0.5rem;
|
||||
opacity: 0.7;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.bookmarks-icon {
|
||||
@@ -1057,7 +1070,7 @@ details[open] .summary::after {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
:root:not(.light-scheme) .simple-icon {
|
||||
:root:not(.light-scheme) .flat-icon {
|
||||
filter: invert(1);
|
||||
}
|
||||
|
||||
@@ -1337,6 +1350,10 @@ details[open] .summary::after {
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
.clock-time {
|
||||
min-width: 8ch;
|
||||
}
|
||||
|
||||
.clock-time span {
|
||||
color: var(--color-text-highlight);
|
||||
}
|
||||
@@ -1353,7 +1370,7 @@ details[open] .summary::after {
|
||||
transition: filter 0.3s, opacity 0.3s;
|
||||
}
|
||||
|
||||
.monitor-site-icon.simple-icon {
|
||||
.monitor-site-icon.flat-icon {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
@@ -1361,7 +1378,7 @@ details[open] .summary::after {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.monitor-site:hover .monitor-site-icon:not(.simple-icon) {
|
||||
.monitor-site:hover .monitor-site-icon:not(.flat-icon) {
|
||||
filter: grayscale(0);
|
||||
}
|
||||
|
||||
@@ -1491,6 +1508,14 @@ details[open] .summary::after {
|
||||
border: 2px solid var(--color-widget-background);
|
||||
}
|
||||
|
||||
.twitch-stream-preview {
|
||||
max-width: 100%;
|
||||
width: 400px;
|
||||
aspect-ratio: 16 / 9;
|
||||
border-radius: var(--border-radius);
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.reddit-card-thumbnail {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
@@ -39,9 +39,11 @@ var (
|
||||
ExtensionTemplate = compileTemplate("extension.html", "widget-base.html")
|
||||
GroupTemplate = compileTemplate("group.html", "widget-base.html")
|
||||
DNSStatsTemplate = compileTemplate("dns-stats.html", "widget-base.html")
|
||||
SplitColumnTemplate = compileTemplate("split-column.html", "widget-base.html")
|
||||
CustomAPITemplate = compileTemplate("custom-api.html", "widget-base.html")
|
||||
)
|
||||
|
||||
var globalTemplateFunctions = template.FuncMap{
|
||||
var GlobalTemplateFunctions = template.FuncMap{
|
||||
"relativeTime": relativeTimeSince,
|
||||
"formatViewerCount": formatViewerCount,
|
||||
"formatNumber": intl.Sprint,
|
||||
@@ -58,7 +60,7 @@ var globalTemplateFunctions = template.FuncMap{
|
||||
|
||||
func compileTemplate(primary string, dependencies ...string) *template.Template {
|
||||
t, err := template.New(primary).
|
||||
Funcs(globalTemplateFunctions).
|
||||
Funcs(GlobalTemplateFunctions).
|
||||
ParseFS(TemplateFS, append([]string{primary}, dependencies...)...)
|
||||
|
||||
if err != nil {
|
||||
|
||||
@@ -8,9 +8,9 @@
|
||||
<ul class="list list-gap-2">
|
||||
{{ range .Links }}
|
||||
<li class="flex items-center gap-10">
|
||||
{{ if ne "" .Icon }}
|
||||
{{ if ne "" .Icon.URL }}
|
||||
<div class="bookmarks-icon-container">
|
||||
<img class="bookmarks-icon{{ if .IsSimpleIcon }} simple-icon{{ end }}" src="{{ .Icon }}" alt="" loading="lazy">
|
||||
<img class="bookmarks-icon{{ if .Icon.IsFlatIcon }} flat-icon{{ end }}" src="{{ .Icon.URL }}" alt="" loading="lazy">
|
||||
</div>
|
||||
{{ end }}
|
||||
<a href="{{ .URL }}" class="bookmarks-link {{ if .HideArrow }}bookmarks-link-no-arrow {{ end }}color-highlight size-h4" {{ if not .SameTab }}target="_blank"{{ end }} rel="noreferrer">{{ .Title }}</a>
|
||||
|
||||
7
internal/assets/templates/custom-api.html
Normal file
7
internal/assets/templates/custom-api.html
Normal file
@@ -0,0 +1,7 @@
|
||||
{{ template "widget-base.html" . }}
|
||||
|
||||
{{ define "widget-content-classes" }}{{ if .Frameless }}widget-content-frameless{{ end }}{{ end }}
|
||||
|
||||
{{ define "widget-content" }}
|
||||
{{ .CompiledHTML }}
|
||||
{{ end }}
|
||||
@@ -6,7 +6,7 @@
|
||||
<div class="flex items-center gap-15">
|
||||
<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 title="{{ .Name }}" class="text-truncate">{{ .Name }}</div>
|
||||
</div>
|
||||
|
||||
<a class="market-chart" {{ if ne "" .ChartLink }} href="{{ .ChartLink }}" target="_blank" rel="noreferrer"{{ end }}>
|
||||
|
||||
@@ -21,8 +21,8 @@
|
||||
{{ end }}
|
||||
|
||||
{{ define "site" }}
|
||||
{{ if .IconUrl }}
|
||||
<img class="monitor-site-icon{{ if .IsSimpleIcon }} simple-icon{{ end }}" src="{{ .IconUrl }}" alt="" loading="lazy">
|
||||
{{ if .Icon.URL }}
|
||||
<img class="monitor-site-icon{{ if .Icon.IsFlatIcon }} flat-icon{{ end }}" src="{{ .Icon.URL }}" alt="" loading="lazy">
|
||||
{{ end }}
|
||||
<div class="min-width-0">
|
||||
<a class="size-h3 color-highlight text-truncate block" href="{{ .URL }}" {{ if not .SameTab }}target="_blank"{{ end }} rel="noreferrer">{{ .Title }}</a>
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
<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>
|
||||
<label class="mobile-navigation-label"><input type="radio" class="mobile-navigation-input" name="column" value="{{ $i }}" autocomplete="off"{{ if eq $i $.Page.PrimaryColumnIndex }} 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"{{ if .Page.ExpandMobilePageNavigation }} checked{{ end }}><div class="hamburger-icon"></div></label>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{{ template "widget-base.html" . }}
|
||||
|
||||
{{ define "widget-content" }}
|
||||
<ul class="list list-gap-10 collapsible-container single-line-titles" data-collapse-after="{{ .CollapseAfter }}">
|
||||
<ul class="list list-gap-10 collapsible-container" data-collapse-after="{{ .CollapseAfter }}">
|
||||
{{ range .Releases }}
|
||||
<li>
|
||||
<div class="flex items-center gap-10">
|
||||
|
||||
11
internal/assets/templates/split-column.html
Normal file
11
internal/assets/templates/split-column.html
Normal file
@@ -0,0 +1,11 @@
|
||||
{{ template "widget-base.html" . }}
|
||||
|
||||
{{ define "widget-content-classes" }}widget-content-frameless{{ end }}
|
||||
|
||||
{{ define "widget-content" }}
|
||||
<div class="masonry" data-max-columns="{{ .MaxColumns }}">
|
||||
{{ range .Widgets }}
|
||||
{{ .Render }}
|
||||
{{ end }}
|
||||
</div>
|
||||
{{ end }}
|
||||
@@ -5,9 +5,15 @@
|
||||
{{ range .Channels }}
|
||||
<li>
|
||||
<div class="{{ if .IsLive }}twitch-channel-live {{ end }}flex gap-10 items-start thumbnail-parent">
|
||||
<div class="twitch-channel-avatar-container">
|
||||
<div class="twitch-channel-avatar-container"{{ if .IsLive }} data-popover-type="html" data-popover-position="above" data-popover-margin="0.15rem" data-popover-offset="0.2"{{ end }}>
|
||||
{{ if .IsLive }}
|
||||
<div data-popover-html>
|
||||
<img class="twitch-stream-preview" src="https://static-cdn.jtvnw.net/previews-ttv/live_user_{{ .Login }}-440x248.jpg" loading="lazy" alt="">
|
||||
<p class="margin-top-10 color-highlight text-truncate-3-lines">{{ .StreamTitle }}</p>
|
||||
</div>
|
||||
{{ end }}
|
||||
{{ if .Exists }}
|
||||
<a href="https://twitch.tv/{{ .Login }}" class="twitch-channel-avatar-link" target="_blank" rel="noreferrer">
|
||||
<a href="https://twitch.tv/{{ .Login }}" target="_blank" rel="noreferrer">
|
||||
<img class="twitch-channel-avatar thumbnail" src="{{ .AvatarUrl }}" alt="" loading="lazy">
|
||||
</a>
|
||||
{{ else }}
|
||||
|
||||
Reference in New Issue
Block a user