mirror of
https://github.com/Xevion/glance.git
synced 2025-12-10 16:07:22 -06:00
Initial commit
This commit is contained in:
15
internal/assets/files.go
Normal file
15
internal/assets/files.go
Normal file
@@ -0,0 +1,15 @@
|
||||
package assets
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"io/fs"
|
||||
)
|
||||
|
||||
//go:embed static
|
||||
var _publicFS embed.FS
|
||||
|
||||
//go:embed templates
|
||||
var _templateFS embed.FS
|
||||
|
||||
var PublicFS, _ = fs.Sub(_publicFS, "static")
|
||||
var TemplateFS, _ = fs.Sub(_templateFS, "templates")
|
||||
BIN
internal/assets/static/favicon.png
Normal file
BIN
internal/assets/static/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 KiB |
BIN
internal/assets/static/fonts/JetBrainsMono-Regular.woff2
Normal file
BIN
internal/assets/static/fonts/JetBrainsMono-Regular.woff2
Normal file
Binary file not shown.
1032
internal/assets/static/main.css
Normal file
1032
internal/assets/static/main.css
Normal file
File diff suppressed because it is too large
Load Diff
163
internal/assets/static/main.js
Normal file
163
internal/assets/static/main.js
Normal file
@@ -0,0 +1,163 @@
|
||||
function throttledDebounce(callback, maxDebounceTimes, debounceDelay) {
|
||||
let debounceTimeout;
|
||||
let timesDebounced = 0;
|
||||
|
||||
return function () {
|
||||
if (timesDebounced == maxDebounceTimes) {
|
||||
clearTimeout(debounceTimeout);
|
||||
timesDebounced = 0;
|
||||
callback();
|
||||
return;
|
||||
}
|
||||
|
||||
clearTimeout(debounceTimeout);
|
||||
timesDebounced++;
|
||||
|
||||
debounceTimeout = setTimeout(() => {
|
||||
timesDebounced = 0;
|
||||
callback();
|
||||
}, debounceDelay);
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
async function fetchPageContents (pageSlug) {
|
||||
// TODO: handle non 200 status codes/time outs
|
||||
// TODO: add retries
|
||||
const response = await fetch(`/api/pages/${pageSlug}/content/`);
|
||||
const content = await response.text();
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
function setupCarousels() {
|
||||
const carouselElements = document.getElementsByClassName("carousel-container");
|
||||
|
||||
for (let i = 0; i < carouselElements.length; i++) {
|
||||
const carousel = carouselElements[i];
|
||||
const itemsContainer = carousel.getElementsByClassName("carousel-items-container")[0];
|
||||
|
||||
const determineSideCutoffs = () => {
|
||||
if (itemsContainer.scrollLeft != 0) {
|
||||
carousel.classList.add("show-left-cutoff");
|
||||
} else {
|
||||
carousel.classList.remove("show-left-cutoff");
|
||||
}
|
||||
|
||||
if (Math.ceil(itemsContainer.scrollLeft) + itemsContainer.clientWidth < itemsContainer.scrollWidth) {
|
||||
carousel.classList.add("show-right-cutoff");
|
||||
} else {
|
||||
carousel.classList.remove("show-right-cutoff");
|
||||
}
|
||||
}
|
||||
|
||||
const determineSideCutoffsRateLimited = throttledDebounce(determineSideCutoffs, 20, 100);
|
||||
|
||||
itemsContainer.addEventListener("scroll", determineSideCutoffsRateLimited);
|
||||
document.addEventListener("resize", determineSideCutoffsRateLimited);
|
||||
|
||||
determineSideCutoffs();
|
||||
}
|
||||
}
|
||||
|
||||
const minuteInSeconds = 60;
|
||||
const hourInSeconds = minuteInSeconds * 60;
|
||||
const dayInSeconds = hourInSeconds * 24;
|
||||
const monthInSeconds = dayInSeconds * 30;
|
||||
const yearInSeconds = monthInSeconds * 12;
|
||||
|
||||
function relativeTimeSince(timestamp) {
|
||||
const delta = Math.round((Date.now() / 1000) - timestamp);
|
||||
|
||||
if (delta < minuteInSeconds) {
|
||||
return "1m";
|
||||
}
|
||||
if (delta < hourInSeconds) {
|
||||
return Math.floor(delta / minuteInSeconds) + "m";
|
||||
}
|
||||
if (delta < dayInSeconds) {
|
||||
return Math.floor(delta / hourInSeconds) + "h";
|
||||
}
|
||||
if (delta < monthInSeconds) {
|
||||
return Math.floor(delta / dayInSeconds) + "d";
|
||||
}
|
||||
if (delta < yearInSeconds) {
|
||||
return Math.floor(delta / monthInSeconds) + "mo";
|
||||
}
|
||||
|
||||
return Math.floor(delta / yearInSeconds) + "y";
|
||||
}
|
||||
|
||||
function updateRelativeTimeForElements(elements)
|
||||
{
|
||||
for (let i = 0; i < elements.length; i++)
|
||||
{
|
||||
const element = elements[i];
|
||||
const timestamp = element.dataset.dynamicRelativeTime;
|
||||
|
||||
if (timestamp === undefined)
|
||||
continue
|
||||
|
||||
element.innerText = relativeTimeSince(timestamp);
|
||||
}
|
||||
}
|
||||
|
||||
function setupDynamicRelativeTime() {
|
||||
const elements = document.querySelectorAll("[data-dynamic-relative-time]");
|
||||
const updateInterval = 60 * 1000;
|
||||
let lastUpdateTime = Date.now();
|
||||
|
||||
const updateElementsAndTimestamp = () => {
|
||||
updateRelativeTimeForElements(elements);
|
||||
lastUpdateTime = Date.now();
|
||||
};
|
||||
|
||||
const scheduleRepeatingUpdate = () => setInterval(updateElementsAndTimestamp, updateInterval);
|
||||
|
||||
if (document.hidden === undefined) {
|
||||
scheduleRepeatingUpdate();
|
||||
return;
|
||||
}
|
||||
|
||||
let timeout = scheduleRepeatingUpdate();
|
||||
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
if (document.hidden) {
|
||||
clearTimeout(timeout);
|
||||
return;
|
||||
}
|
||||
|
||||
const delta = Date.now() - lastUpdateTime;
|
||||
|
||||
if (delta >= updateInterval) {
|
||||
updateElementsAndTimestamp();
|
||||
timeout = scheduleRepeatingUpdate();
|
||||
return;
|
||||
}
|
||||
|
||||
timeout = setTimeout(() => {
|
||||
updateElementsAndTimestamp();
|
||||
timeout = scheduleRepeatingUpdate();
|
||||
}, updateInterval - delta);
|
||||
});
|
||||
}
|
||||
|
||||
async function setupPage() {
|
||||
const pageElement = document.getElementById("page");
|
||||
const pageContents = await fetchPageContents(pageData.slug);
|
||||
|
||||
pageElement.innerHTML = pageContents;
|
||||
|
||||
setTimeout(() => {
|
||||
document.body.classList.add("animate-element-transition");
|
||||
}, 150);
|
||||
|
||||
setupCarousels();
|
||||
setupDynamicRelativeTime();
|
||||
}
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", setupPage);
|
||||
} else {
|
||||
setupPage();
|
||||
}
|
||||
112
internal/assets/templates.go
Normal file
112
internal/assets/templates.go
Normal file
@@ -0,0 +1,112 @@
|
||||
package assets
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html/template"
|
||||
"math"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"golang.org/x/text/language"
|
||||
"golang.org/x/text/message"
|
||||
)
|
||||
|
||||
var (
|
||||
PageTemplate = compileTemplate("page.html", "document.html", "page-style-overrides.gotmpl")
|
||||
PageContentTemplate = compileTemplate("content.html")
|
||||
CalendarTemplate = compileTemplate("calendar.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")
|
||||
ForumPostsTemplate = compileTemplate("forum-posts.html", "widget-base.html")
|
||||
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")
|
||||
VideosTemplate = compileTemplate("videos.html", "widget-base.html")
|
||||
StocksTemplate = compileTemplate("stocks.html", "widget-base.html")
|
||||
RSSListTemplate = compileTemplate("rss-list.html", "widget-base.html")
|
||||
RSSCardsTemplate = compileTemplate("rss-cards.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")
|
||||
)
|
||||
|
||||
var globalTemplateFunctions = template.FuncMap{
|
||||
"relativeTime": relativeTimeSince,
|
||||
"formatViewerCount": formatViewerCount,
|
||||
"formatNumber": intl.Sprint,
|
||||
"absInt": func(i int) int {
|
||||
return int(math.Abs(float64(i)))
|
||||
},
|
||||
"formatPrice": func(price float64) string {
|
||||
return intl.Sprintf("%.2f", price)
|
||||
},
|
||||
"formatTime": func(t time.Time) string {
|
||||
return t.Format("2006-01-02 15:04:05")
|
||||
},
|
||||
"shouldCollapse": func(i int, collapseAfter int) bool {
|
||||
if collapseAfter < -1 {
|
||||
return false
|
||||
}
|
||||
|
||||
return i >= collapseAfter
|
||||
},
|
||||
"itemAnimationDelay": func(i int, collapseAfter int) string {
|
||||
return fmt.Sprintf("%dms", (i-collapseAfter)*30)
|
||||
},
|
||||
"dynamicRelativeTimeAttrs": func(t time.Time) template.HTMLAttr {
|
||||
return template.HTMLAttr(fmt.Sprintf(`data-dynamic-relative-time="%d"`, t.Unix()))
|
||||
},
|
||||
}
|
||||
|
||||
func compileTemplate(primary string, dependencies ...string) *template.Template {
|
||||
t, err := template.New(primary).
|
||||
Funcs(globalTemplateFunctions).
|
||||
ParseFS(TemplateFS, append([]string{primary}, dependencies...)...)
|
||||
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
return t
|
||||
}
|
||||
|
||||
var intl = message.NewPrinter(language.English)
|
||||
|
||||
func formatViewerCount(count int) string {
|
||||
if count < 1_000 {
|
||||
return strconv.Itoa(count)
|
||||
}
|
||||
|
||||
if count < 10_000 {
|
||||
return fmt.Sprintf("%.1fk", float64(count)/1_000)
|
||||
}
|
||||
|
||||
if count < 1_000_000 {
|
||||
return fmt.Sprintf("%dk", count/1_000)
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%.1fm", float64(count)/1_000_000)
|
||||
}
|
||||
|
||||
func relativeTimeSince(t time.Time) string {
|
||||
delta := time.Since(t)
|
||||
|
||||
if delta < time.Minute {
|
||||
return "1m"
|
||||
}
|
||||
if delta < time.Hour {
|
||||
return fmt.Sprintf("%dm", delta/time.Minute)
|
||||
}
|
||||
if delta < 24*time.Hour {
|
||||
return fmt.Sprintf("%dh", delta/time.Hour)
|
||||
}
|
||||
if delta < 30*24*time.Hour {
|
||||
return fmt.Sprintf("%dd", delta/(24*time.Hour))
|
||||
}
|
||||
if delta < 12*30*24*time.Hour {
|
||||
return fmt.Sprintf("%dmo", delta/(30*24*time.Hour))
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%dy", delta/(365*24*time.Hour))
|
||||
}
|
||||
16
internal/assets/templates/bookmarks.html
Normal file
16
internal/assets/templates/bookmarks.html
Normal file
@@ -0,0 +1,16 @@
|
||||
{{ template "widget-base.html" . }}
|
||||
|
||||
{{ define "widget-content" }}
|
||||
<ul class="list list-gap-24 list-with-separator">
|
||||
{{ range .Groups }}
|
||||
<li class="bookmarks-group"{{ if .Color }} style="--bookmarks-group-color: {{ .Color.AsCSSValue }}"{{ end }}>
|
||||
{{ if ne .Title "" }}<div class="bookmarks-group-title size-h3 margin-bottom-3">{{ .Title }}</div>{{ end }}
|
||||
<ul class="list list-gap-2">
|
||||
{{ range .Links }}
|
||||
<li><a href="{{ .URL }}" class="bookmarks-link color-highlight size-h4" target="_blank" rel="noreferrer">{{ .Title }}</a></li>
|
||||
{{ end }}
|
||||
</ul>
|
||||
</li>
|
||||
{{ end }}
|
||||
</ul>
|
||||
{{ end }}
|
||||
27
internal/assets/templates/calendar.html
Normal file
27
internal/assets/templates/calendar.html
Normal file
@@ -0,0 +1,27 @@
|
||||
{{ template "widget-base.html" . }}
|
||||
|
||||
{{ define "widget-content" }}
|
||||
<div class="flex justify-between items-center">
|
||||
<div class="color-highlight size-h1">{{ .Calendar.CurrentMonthName }}</div>
|
||||
<ul class="list-horizontal-text color-highlight size-h4">
|
||||
<li>Week {{ .Calendar.CurrentWeekNumber }}</li>
|
||||
<li>{{ .Calendar.CurrentYear }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap size-h6 margin-top-10 color-subdue">
|
||||
<div class="calendar-day">Mo</div>
|
||||
<div class="calendar-day">Tu</div>
|
||||
<div class="calendar-day">We</div>
|
||||
<div class="calendar-day">Th</div>
|
||||
<div class="calendar-day">Fr</div>
|
||||
<div class="calendar-day">Sa</div>
|
||||
<div class="calendar-day">Su</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap">
|
||||
{{ range .Calendar.Days }}
|
||||
<div class="calendar-day{{ if eq . $.Calendar.CurrentDay }} calendar-day-today{{ end }}">{{ . }}</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
{{ end }}
|
||||
9
internal/assets/templates/content.html
Normal file
9
internal/assets/templates/content.html
Normal file
@@ -0,0 +1,9 @@
|
||||
<div class="page-columns">
|
||||
{{ range .Page.Columns }}
|
||||
<div class="page-column page-column-{{ .Size }}">
|
||||
{{ range .Widgets }}
|
||||
{{ .Render }}
|
||||
{{ end }}
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
17
internal/assets/templates/document.html
Normal file
17
internal/assets/templates/document.html
Normal file
@@ -0,0 +1,17 @@
|
||||
<!DOCTYPE html>
|
||||
<html {{ block "document-root-attrs" . }}{{ end }} lang="en" id="top">
|
||||
<head>
|
||||
{{ block "document-head-before" . }}{{ end }}
|
||||
<title>{{ block "document-title" . }}{{ end }}</title>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="color-scheme" content="dark">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link rel="icon" type="image/png" href="/static/favicon.png" />
|
||||
<link rel="stylesheet" href="/static/main.css?v={{ .App.Config.Server.StartedAt.Unix }}">
|
||||
<script async src="/static/main.js?v={{ .App.Config.Server.StartedAt.Unix }}"></script>
|
||||
{{ block "document-head-after" . }}{{ end }}
|
||||
</head>
|
||||
<body>
|
||||
{{ template "document-body" . }}
|
||||
</body>
|
||||
</html>
|
||||
22
internal/assets/templates/forum-posts.html
Normal file
22
internal/assets/templates/forum-posts.html
Normal file
@@ -0,0 +1,22 @@
|
||||
{{ template "widget-base.html" . }}
|
||||
|
||||
{{ define "widget-content" }}
|
||||
<ul class="list list-gap-14 list-collapsible">
|
||||
{{ range $i, $post := .Posts }}
|
||||
<li {{ if shouldCollapse $i $.CollapseAfter }}class="list-collapsible-item" style="--animation-delay: {{ itemAnimationDelay $i $.CollapseAfter }};"{{ end }}>
|
||||
<a href="{{ $post.DiscussionUrl }}" class="size-h3 color-primary-if-not-visited" target="_blank" rel="noreferrer">{{ .Title }}</a>
|
||||
<ul class="list-horizontal-text">
|
||||
<li title="{{ $post.TimePosted | formatTime }}" {{ dynamicRelativeTimeAttrs $post.TimePosted }}>{{ $post.TimePosted | relativeTime }}</li>
|
||||
<li>{{ $post.Score | formatNumber }} points</li>
|
||||
<li>{{ $post.CommentCount | formatNumber }} comments</li>
|
||||
{{ if $post.HasTargetUrl }}
|
||||
<li class="shrink min-width-0"><a class="visited-indicator text-truncate block" href="{{ .TargetUrl }}" target="_blank" rel="noreferrer">{{ $post.TargetUrlDomain }}</a></li>
|
||||
{{ end }}
|
||||
</ul>
|
||||
</li>
|
||||
{{ end }}
|
||||
</ul>
|
||||
{{ if gt (len .Posts) $.CollapseAfter }}
|
||||
<label class="list-collapsible-label"><input type="checkbox" autocomplete="off" class="list-collapsible-input"></label>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
7
internal/assets/templates/iframe.html
Normal file
7
internal/assets/templates/iframe.html
Normal file
@@ -0,0 +1,7 @@
|
||||
{{ template "widget-base.html" . }}
|
||||
|
||||
{{ define "widget-content-classes" }}widget-content-frameless{{ end }}
|
||||
|
||||
{{ define "widget-content" }}
|
||||
<iframe src="{{ .Source }}" width="100%" height="{{ .Height }}px" frameborder="0"></iframe>
|
||||
{{ end }}
|
||||
39
internal/assets/templates/monitor.html
Normal file
39
internal/assets/templates/monitor.html
Normal file
@@ -0,0 +1,39 @@
|
||||
{{ template "widget-base.html" . }}
|
||||
|
||||
{{ define "widget-content" }}
|
||||
<ul class="list list-gap-20 list-with-separator">
|
||||
{{ range .Sites }}
|
||||
<li class="monitor-site flex items-center gap-15">
|
||||
{{ if .IconUrl }}
|
||||
<img class="monitor-site-icon" src="{{ .IconUrl }}" alt="" loading="lazy">
|
||||
{{ end }}
|
||||
<div>
|
||||
<a class="size-h3 color-highlight" href="{{ .Url }}" target="_blank" rel="noreferrer">{{ .Title }}</a>
|
||||
<ul class="list-horizontal-text">
|
||||
{{ if not .Status.Error }}
|
||||
<li>{{ .StatusText }}</li>
|
||||
<li>{{ .Status.ResponseTime.Milliseconds | formatNumber }}ms</li>
|
||||
{{ else if .Status.TimedOut }}
|
||||
<li class="color-negative">Timed Out</li>
|
||||
{{ else }}
|
||||
<li class="color-negative" title="{{ .Status.Error }}">ERROR</li>
|
||||
{{ end }}
|
||||
</ul>
|
||||
</div>
|
||||
{{ if eq .StatusStyle "good" }}
|
||||
<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" />
|
||||
</svg>
|
||||
</div>
|
||||
{{ else }}
|
||||
<div class="monitor-site-status-icon">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="var(--color-negative)">
|
||||
<path fill-rule="evenodd" d="M9.401 3.003c1.155-2 4.043-2 5.197 0l7.355 12.748c1.154 2-.29 4.5-2.599 4.5H4.645c-2.309 0-3.752-2.5-2.598-4.5L9.4 3.003ZM12 8.25a.75.75 0 0 1 .75.75v3.75a.75.75 0 0 1-1.5 0V9a.75.75 0 0 1 .75-.75Zm0 8.25a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5Z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
{{ end }}
|
||||
</li>
|
||||
{{ end }}
|
||||
</ul>
|
||||
{{ end }}
|
||||
14
internal/assets/templates/page-style-overrides.gotmpl
Normal file
14
internal/assets/templates/page-style-overrides.gotmpl
Normal file
@@ -0,0 +1,14 @@
|
||||
<style>
|
||||
:root {
|
||||
{{ if .App.Config.Theme.BackgroundColor }}
|
||||
--bgh: {{ .App.Config.Theme.BackgroundColor.Hue }};
|
||||
--bgs: {{ .App.Config.Theme.BackgroundColor.Saturation }}%;
|
||||
--bgl: {{ .App.Config.Theme.BackgroundColor.Lightness }}%;
|
||||
{{ end }}
|
||||
{{ if ne 0.0 .App.Config.Theme.ContrastMultiplier }}--cm: {{ .App.Config.Theme.ContrastMultiplier }};{{ end }}
|
||||
{{ if ne 0.0 .App.Config.Theme.TextSaturationMultiplier }}--tsm: {{ .App.Config.Theme.TextSaturationMultiplier }};{{ end }}
|
||||
{{ if .App.Config.Theme.PrimaryColor }}--color-primary: {{ .App.Config.Theme.PrimaryColor.AsCSSValue }};{{ end }}
|
||||
{{ if .App.Config.Theme.PositiveColor }}--color-positive: {{ .App.Config.Theme.PositiveColor.AsCSSValue }};{{ end }}
|
||||
{{ if .App.Config.Theme.NegativeColor }}--color-negative: {{ .App.Config.Theme.NegativeColor.AsCSSValue }};{{ end }}
|
||||
}
|
||||
</style>
|
||||
64
internal/assets/templates/page.html
Normal file
64
internal/assets/templates/page.html
Normal file
@@ -0,0 +1,64 @@
|
||||
{{ template "document.html" . }}
|
||||
|
||||
{{ define "document-title" }}{{ .Page.Title }} - Glance{{ end }}
|
||||
|
||||
{{ define "document-head-before" }}
|
||||
<script>
|
||||
const pageData = {
|
||||
slug: "{{ .Page.Slug }}",
|
||||
};
|
||||
</script>
|
||||
{{ end }}
|
||||
|
||||
{{ define "document-root-attrs" }}{{ if .App.Config.Theme.Light }}class="light-scheme"{{ end }}{{ end }}
|
||||
{{ define "document-head-after" }}{{ template "page-style-overrides.gotmpl" . }}{{ end }}
|
||||
|
||||
{{ define "navigation-links" }}
|
||||
{{ range .App.Config.Pages }}
|
||||
<a href="/{{ .Slug }}" class="nav-item{{ if eq .Slug $.Page.Slug }} nav-item-current{{ end }}">{{ .Title }}</a>
|
||||
{{ end }}
|
||||
{{ 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 flex-grow">
|
||||
{{ 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-loading-container">
|
||||
<!-- TODO: add a bigger/better loading indicator -->
|
||||
<div class="loading-icon"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="footer flex items-center flex-column">
|
||||
<div>
|
||||
<span class="size-h3">Glance</span> ({{ .App.Version }})
|
||||
</div>
|
||||
<ul class="list-horizontal-text margin-top-5 size-h5 color-primary">
|
||||
<li><a href="https://github.com/glanceapp/glance/issues" target="_blank" rel="noreferrer">Report issue</a></li>
|
||||
<li><a href="https://github.com/glanceapp/glance/discussions" target="_blank" rel="noreferrer">Submit feedback</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
{{ end }}
|
||||
31
internal/assets/templates/reddit-horizontal-cards.html
Normal file
31
internal/assets/templates/reddit-horizontal-cards.html
Normal file
@@ -0,0 +1,31 @@
|
||||
{{ template "widget-base.html" . }}
|
||||
|
||||
{{ define "widget-content-classes" }}widget-content-frameless{{ end }}
|
||||
|
||||
{{ define "widget-content" }}
|
||||
<div class="carousel-container">
|
||||
<div class="cards-horizontal carousel-items-container">
|
||||
{{ range .Posts }}
|
||||
<div class="card widget-content-frame relative">
|
||||
{{ if ne "" .ThumbnailUrl }}
|
||||
<div class="reddit-card-thumbnail-container">
|
||||
<img class="reddit-card-thumbnail" loading="lazy" src="{{ .ThumbnailUrl }}" alt="">
|
||||
</div>
|
||||
{{ end }}
|
||||
<div class="padding-widget flex flex-column flex-grow relative">
|
||||
{{ if ne "" .TargetUrl }}
|
||||
<a class="color-highlight size-h5 text-truncate visited-indicator" href="{{ .TargetUrl }}" target="_blank" rel="noreferrer">{{ .TargetUrlDomain }}</a>
|
||||
{{ else }}
|
||||
<div class="color-highlight size-h5 text-truncate">/r/{{ $.Subreddit }}</div>
|
||||
{{ end }}
|
||||
<a href="{{ .DiscussionUrl }}" title="{{ .Title }}" class="text-truncate-3-lines color-primary-if-not-visited margin-top-7 margin-bottom-auto" target="_blank" rel="noreferrer">{{ .Title }}</a>
|
||||
<ul class="list-horizontal-text margin-top-7">
|
||||
<li title="{{ .TimePosted | formatTime }}" {{ dynamicRelativeTimeAttrs .TimePosted }}>{{ .TimePosted | relativeTime }}</li>
|
||||
<li>{{ .Score | formatNumber }} points</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
{{ end }}
|
||||
29
internal/assets/templates/reddit-vertical-cards.html
Normal file
29
internal/assets/templates/reddit-vertical-cards.html
Normal file
@@ -0,0 +1,29 @@
|
||||
{{ template "widget-base.html" . }}
|
||||
|
||||
{{ define "widget-content-classes" }}widget-content-frameless{{ end }}
|
||||
|
||||
{{ define "widget-content" }}
|
||||
<div class="cards-vertical">
|
||||
{{ range .Posts }}
|
||||
<div class="widget-content-frame relative">
|
||||
{{ if ne "" .ThumbnailUrl }}
|
||||
<div class="reddit-card-thumbnail-container">
|
||||
<img class="reddit-card-thumbnail" loading="lazy" src="{{ .ThumbnailUrl }}" alt="">
|
||||
</div>
|
||||
{{ end }}
|
||||
<div class="padding-widget relative">
|
||||
{{ if ne "" .TargetUrl }}
|
||||
<a class="color-highlight size-h5 text-truncate visited-indicator block" href="{{ .TargetUrl }}" target="_blank" rel="noreferrer">{{ .TargetUrlDomain }}</a>
|
||||
{{ else }}
|
||||
<div class="color-highlight size-h5 text-truncate">/r/{{ $.Subreddit }}</div>
|
||||
{{ end }}
|
||||
<a href="{{ .DiscussionUrl }}" title="{{ .Title }}" class="text-truncate-3-lines color-primary-if-not-visited margin-top-7" target="_blank" rel="noreferrer">{{ .Title }}</a>
|
||||
<ul class="list-horizontal-text margin-top-7">
|
||||
<li title="{{ .TimePosted | formatTime }}" {{ dynamicRelativeTimeAttrs .TimePosted }}>{{ .TimePosted | relativeTime }}</li>
|
||||
<li>{{ .Score | formatNumber }} points</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
{{ end }}
|
||||
21
internal/assets/templates/releases.html
Normal file
21
internal/assets/templates/releases.html
Normal file
@@ -0,0 +1,21 @@
|
||||
{{ template "widget-base.html" . }}
|
||||
|
||||
{{ define "widget-content" }}
|
||||
<ul class="list list-gap-14 list-collapsible">
|
||||
{{ range $i, $release := .Releases }}
|
||||
<li {{ if shouldCollapse $i $.CollapseAfter }}class="list-collapsible-item" style="--animation-delay: {{ itemAnimationDelay $i $.CollapseAfter }};"{{ end }}>
|
||||
<a class="size-h4 block text-truncate color-primary-if-not-visited" href="{{ $release.NotesUrl }}" target="_blank" rel="noreferrer">{{ .Name }}</a>
|
||||
<ul class="list-horizontal-text">
|
||||
<li title="{{ $release.TimeReleased | formatTime }}" {{ dynamicRelativeTimeAttrs $release.TimeReleased }}>{{ $release.TimeReleased | relativeTime }}</li>
|
||||
<li>{{ $release.Version }}</li>
|
||||
{{ if gt $release.Downvotes 3 }}
|
||||
<li>{{ $release.Downvotes | formatNumber }} ⚠</li>
|
||||
{{ end }}
|
||||
</ul>
|
||||
</li>
|
||||
{{ end }}
|
||||
</ul>
|
||||
{{ if gt (len .Releases) $.CollapseAfter }}
|
||||
<label class="list-collapsible-label"><input type="checkbox" autocomplete="off" class="list-collapsible-input"></label>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
28
internal/assets/templates/rss-cards.html
Normal file
28
internal/assets/templates/rss-cards.html
Normal file
@@ -0,0 +1,28 @@
|
||||
{{ template "widget-base.html" . }}
|
||||
|
||||
{{ define "widget-content-classes" }}widget-content-frameless{{ end }}
|
||||
|
||||
{{ define "widget-content" }}
|
||||
<div class="carousel-container">
|
||||
<div class="cards-horizontal carousel-items-container">
|
||||
{{ range .Items }}
|
||||
<div class="card widget-content-frame thumbnail-container">
|
||||
{{ if ne "" .ImageURL }}
|
||||
<img class="rss-card-image thumbnail" loading="lazy" src="{{ .ImageURL }}" alt="">
|
||||
{{ else }}
|
||||
<svg class="rss-card-image" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="var(--color-text-subdue)">
|
||||
<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 class="margin-bottom-widget padding-inline-widget flex flex-column flex-grow">
|
||||
<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" title="{{ .PublishedAt | formatTime }}" {{ dynamicRelativeTimeAttrs .PublishedAt }}>{{ .PublishedAt | relativeTime }}</li>
|
||||
<li class="shrink min-width-0 text-truncate">{{ .ChannelName }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
{{ end }}
|
||||
20
internal/assets/templates/rss-list.html
Normal file
20
internal/assets/templates/rss-list.html
Normal file
@@ -0,0 +1,20 @@
|
||||
{{ template "widget-base.html" . }}
|
||||
|
||||
{{ define "widget-content" }}
|
||||
<ul class="list list-gap-14 list-collapsible">
|
||||
{{ range $i, $item := .Items }}
|
||||
<li {{ if shouldCollapse $i $.CollapseAfter }}class="list-collapsible-item" style="--animation-delay: {{ itemAnimationDelay $i $.CollapseAfter }};"{{ end }}>
|
||||
<a class="size-title-dynamic color-primary-if-not-visited" href="{{ .Link }}" target="_blank" rel="noreferrer">{{ .Title }}</a>
|
||||
<ul class="list-horizontal-text">
|
||||
<li title="{{ $item.PublishedAt | formatTime }}" {{ dynamicRelativeTimeAttrs $item.PublishedAt }}>{{ .PublishedAt | relativeTime }}</li>
|
||||
{{ if gt (len $.FeedRequests) 1 }}
|
||||
<li><a href="{{ .ChannelURL }}" target="_blank" rel="noreferrer">{{ .ChannelName }}</a></li>
|
||||
{{ end }}
|
||||
</ul>
|
||||
</li>
|
||||
{{ end }}
|
||||
</ul>
|
||||
{{ if gt (len .Items) $.CollapseAfter }}
|
||||
<label class="list-collapsible-label"><input type="checkbox" autocomplete="off" class="list-collapsible-input"></label>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
23
internal/assets/templates/stocks.html
Normal file
23
internal/assets/templates/stocks.html
Normal file
@@ -0,0 +1,23 @@
|
||||
{{ template "widget-base.html" . }}
|
||||
|
||||
{{ define "widget-content" }}
|
||||
<ul class="list list-gap-20 list-with-separator">
|
||||
{{ range .Stocks }}
|
||||
<li class="flex items-center gap-15">
|
||||
<div class="shrink min-width-0">
|
||||
<div class="color-highlight size-h3 text-truncate">{{ .Symbol }}</div>
|
||||
<div class="text-truncate">{{ .Name }}</div>
|
||||
</div>
|
||||
|
||||
<svg class="stock-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>
|
||||
|
||||
<div class="stock-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">${{ .Price | formatPrice }}</div>
|
||||
</div>
|
||||
</li>
|
||||
{{ end }}
|
||||
</ul>
|
||||
{{ end }}
|
||||
40
internal/assets/templates/twitch-channels.html
Normal file
40
internal/assets/templates/twitch-channels.html
Normal file
@@ -0,0 +1,40 @@
|
||||
{{ template "widget-base.html" . }}
|
||||
|
||||
{{ define "widget-content" }}
|
||||
<ul class="list list-gap-14 list-collapsible">
|
||||
{{ range $i, $channel := .Channels }}
|
||||
<li {{ if shouldCollapse $i $.CollapseAfter }}class="list-collapsible-item" style="--animation-delay: {{ itemAnimationDelay $i $.CollapseAfter }};"{{ end }}>
|
||||
<div class="{{ if $channel.IsLive }}twitch-channel-live {{ end }}flex gap-10 items-start thumbnail-container">
|
||||
<div class="twitch-channel-avatar-container">
|
||||
{{ if $channel.Exists }}
|
||||
<img class="twitch-channel-avatar thumbnail" src="{{ $channel.AvatarUrl }}" alt="" loading="lazy">
|
||||
{{ 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">
|
||||
<a href="https://twitch.tv/{{ $channel.Login }}" class="size-h3{{ if $channel.IsLive }} color-highlight{{ end }} block text-truncate" target="_blank" rel="noreferrer">{{ $channel.Name }}</a>
|
||||
{{ if $channel.Exists }}
|
||||
{{ if $channel.IsLive }}
|
||||
<a class="text-truncate block" href="https://www.twitch.tv/directory/category/{{ $channel.CategorySlug }}" target="_blank" rel="noreferrer">{{ $channel.Category }}</a>
|
||||
<ul class="list-horizontal-text">
|
||||
<li title="{{ $channel.LiveSince | formatTime }}" {{ dynamicRelativeTimeAttrs $channel.LiveSince }}>{{ $channel.LiveSince | relativeTime }}</li>
|
||||
<li>{{ $channel.ViewersCount | formatViewerCount }} viewers</li>
|
||||
</ul>
|
||||
{{ else }}
|
||||
<div>Offline</div>
|
||||
{{ end }}
|
||||
{{ else }}
|
||||
<div class="color-negative">Not found</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
{{ end }}
|
||||
</ul>
|
||||
{{ if gt (len .Channels) $.CollapseAfter }}
|
||||
<label class="list-collapsible-label"><input type="checkbox" autocomplete="off" class="list-collapsible-input"></label>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
35
internal/assets/templates/twitch-games-list.html
Normal file
35
internal/assets/templates/twitch-games-list.html
Normal file
@@ -0,0 +1,35 @@
|
||||
{{ template "widget-base.html" . }}
|
||||
|
||||
{{ define "widget-content" }}
|
||||
<ul class="list list-gap-14 list-collapsible">
|
||||
{{ range $i, $category := .Categories }}
|
||||
{{ $shouldCollapseItem := shouldCollapse $i $.CollapseAfter }}
|
||||
<li class="twitch-category thumbnail-container{{ if $shouldCollapseItem }} list-collapsible-item{{ end }}" {{ if $shouldCollapseItem }}style="--animation-delay: {{ itemAnimationDelay $i $.CollapseAfter }};"{{ end }}>
|
||||
<div class="flex gap-10 items-center">
|
||||
<img class="twitch-category-thumbnail thumbnail" loading="lazy" src="{{ $category.AvatarUrl }}" alt="">
|
||||
<div class="shrink min-width-0">
|
||||
<a class="size-h3 color-highlight text-truncate block" href="https://www.twitch.tv/directory/category/{{ $category.Slug }}" target="_blank" rel="noreferrer">{{ $category.Name }}</a>
|
||||
<ul class="list-horizontal-text">
|
||||
<li>{{ $category.ViewersCount | formatViewerCount }} viewers</li>
|
||||
{{ if $category.IsNew }}
|
||||
<li class="color-primary">NEW</li>
|
||||
{{ end }}
|
||||
</ul>
|
||||
<ul class="list-horizontal-text flex-nowrap">
|
||||
{{ range $i, $tag := $category.Tags }}
|
||||
{{ if eq $i 0 }}
|
||||
<li class="shrink-0">{{ $tag.Name }}</li>
|
||||
{{ else }}
|
||||
<li class="text-truncate shrink min-width-0">{{ $tag.Name }}</li>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
{{ end }}
|
||||
</ul>
|
||||
{{ if gt (len .Categories) $.CollapseAfter }}
|
||||
<label class="list-collapsible-label"><input type="checkbox" autocomplete="off" class="list-collapsible-input"></label>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
24
internal/assets/templates/videos.html
Normal file
24
internal/assets/templates/videos.html
Normal file
@@ -0,0 +1,24 @@
|
||||
{{ template "widget-base.html" . }}
|
||||
|
||||
{{ define "widget-content-classes" }}widget-content-frameless{{ end }}
|
||||
|
||||
{{ define "widget-content" }}
|
||||
<div class="carousel-container">
|
||||
<div class="videos cards-horizontal carousel-items-container">
|
||||
{{ range .Videos }}
|
||||
<div class="card widget-content-frame thumbnail-container">
|
||||
<img class="video-thumbnail thumbnail" loading="lazy" src="{{ .ThumbnailUrl }}" alt="">
|
||||
<div class="margin-top-10 margin-bottom-widget flex flex-column flex-grow padding-inline-widget">
|
||||
<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" title="{{ .TimePosted | formatTime }}" {{ dynamicRelativeTimeAttrs .TimePosted }}>{{ .TimePosted | relativeTime }}</li>
|
||||
<li class="shrink min-width-0">
|
||||
<a class="block text-truncate" href="{{ .AuthorUrl }}" target="_blank" rel="noreferrer">{{ .Author }}</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
{{ end }}
|
||||
29
internal/assets/templates/weather.html
Normal file
29
internal/assets/templates/weather.html
Normal file
@@ -0,0 +1,29 @@
|
||||
{{ template "widget-base.html" . }}
|
||||
|
||||
{{ define "widget-content" }}
|
||||
<div class="size-h2 color-highlight text-center">{{ .Weather.WeatherCodeAsString }}</div>
|
||||
<div class="size-h4 text-center">Feels like {{ .Weather.ApparentTemperature }}°C</div>
|
||||
|
||||
<div class="weather-columns flex margin-top-15 justify-center">
|
||||
{{ range $i, $column := .Weather.Columns }}
|
||||
<div class="weather-column{{ if eq $i $.Weather.CurrentColumn }} weather-column-current{{ end }}">
|
||||
{{ if $column.HasPrecipitation }}
|
||||
<div class="weather-column-rain"></div>
|
||||
{{ end }}
|
||||
{{ if and (ge $i $.Weather.SunriseColumn) (le $i $.Weather.SunsetColumn ) }}
|
||||
<div class="weather-column-daylight{{ if eq $i $.Weather.SunriseColumn }} weather-column-daylight-sunrise{{ else if eq $i $.Weather.SunsetColumn }} weather-column-daylight-sunset{{ end }}"></div>
|
||||
{{ end }}
|
||||
<div class="weather-column-value{{ if lt $column.Temperature 0 }} weather-column-value-negative{{ end }}">{{ $column.Temperature | absInt }}</div>
|
||||
<div class="weather-bar" style='--weather-bar-height: {{ printf "%.2f" $column.Scale }}'></div>
|
||||
<div class="weather-column-time">{{ index $.TimeLabels $i }}</div>
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
|
||||
{{ if not .HideLocation }}
|
||||
<div class="flex items-center justify-center margin-top-15 gap-7 size-h5">
|
||||
<div class="location-icon"></div>
|
||||
<div class="text-truncate">{{ .Place.Name }}, {{ .Place.Country }}</div>
|
||||
</div>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
21
internal/assets/templates/widget-base.html
Normal file
21
internal/assets/templates/widget-base.html
Normal file
@@ -0,0 +1,21 @@
|
||||
<div class="widget widget-type-{{ .GetType }}">
|
||||
<div class="widget-header">
|
||||
<div class="uppercase">{{ .Title }}</div>
|
||||
{{ 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 }}">
|
||||
{{ if .ContentAvailable }}
|
||||
{{ block "widget-content" . }}{{ end }}
|
||||
{{ else }}
|
||||
<div class="widget-error-header">
|
||||
<div class="color-negative size-h3">ERROR</div>
|
||||
<div class="widget-error-icon"></div>
|
||||
</div>
|
||||
<p class="break-all">{{ if .Error }}{{ .Error }}{{ else }}No error information provided{{ end }}</p>
|
||||
{{ end}}
|
||||
</div>
|
||||
</div>
|
||||
Reference in New Issue
Block a user