Initial commit

This commit is contained in:
Svilen Markov
2024-04-27 20:10:24 +01:00
commit ec8ba40cf0
100 changed files with 6883 additions and 0 deletions

15
internal/assets/files.go Normal file
View 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")

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

Binary file not shown.

View File

File diff suppressed because it is too large Load Diff

View 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();
}

View 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))
}

View 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 }}

View 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 }}

View 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>

View 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>

View 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 }}

View 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 }}

View 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 }}

View 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>

View 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 }}

View 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 }}

View 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 }}

View 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 }}

View 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 }}

View 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 }}

View 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 }}

View 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 }}

View 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 }}

View 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 }}

View 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 }}

View 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>