From e796b2dff4289c972c9b1422b821b25576768aaf Mon Sep 17 00:00:00 2001 From: eleith Date: Tue, 19 Aug 2025 04:39:03 +0000 Subject: [PATCH 1/2] refactor(templates): decouple css and javascript from html --- internal/templates/entries.gohtml | 429 +--------------------------- internal/templates/funcs.go | 34 +++ internal/templates/static/script.js | 170 +++++++++++ internal/templates/static/style.css | 256 +++++++++++++++++ internal/templates/templates.go | 2 +- 5 files changed, 463 insertions(+), 428 deletions(-) create mode 100644 internal/templates/funcs.go create mode 100644 internal/templates/static/script.js create mode 100644 internal/templates/static/style.css diff --git a/internal/templates/entries.gohtml b/internal/templates/entries.gohtml index b021ab5..62e5395 100644 --- a/internal/templates/entries.gohtml +++ b/internal/templates/entries.gohtml @@ -6,262 +6,7 @@ {{.Category.Title}} @@ -334,176 +78,7 @@ diff --git a/internal/templates/funcs.go b/internal/templates/funcs.go new file mode 100644 index 0000000..1596ed5 --- /dev/null +++ b/internal/templates/funcs.go @@ -0,0 +1,34 @@ +package templates + +import ( + "embed" + "html/template" + "io/fs" + "path/filepath" + "strings" +) + +//go:embed static +var staticFS embed.FS + +func FuncMap() template.FuncMap { + return template.FuncMap{ + "include": include, + } +} + +func include(filename string) (interface{}, error) { + b, err := fs.ReadFile(staticFS, filename) + if err != nil { + return "", err + } + + switch strings.ToLower(filepath.Ext(filename)) { + case ".css": + return template.CSS(b), nil + case ".js": + return template.JS(b), nil + default: + return template.HTML(b), nil + } +} diff --git a/internal/templates/static/script.js b/internal/templates/static/script.js new file mode 100644 index 0000000..76b0d07 --- /dev/null +++ b/internal/templates/static/script.js @@ -0,0 +1,170 @@ +document.addEventListener('DOMContentLoaded', () => { + const entries = Array.from(document.querySelectorAll('section.entry')); + let activeEntry = null; + let markAsReadTimer = null; + let focusTimer = null; + let lastScrollY = window.scrollY; + + function getVisibleEntries() { + return entries.filter(entry => !entry.classList.contains('entry-hidden')); + } + + function markAsRead(entry) { + const visibleEntries = getVisibleEntries(); + const entryIndex = visibleEntries.indexOf(entry); + for (let i = 0; i < entryIndex; i++) { + visibleEntries[i].classList.add('viewed'); + } + } + + function setActiveEntry(entry, preventScroll = false) { + clearTimeout(markAsReadTimer); + + if (activeEntry) { + activeEntry.classList.remove('active'); + } + + activeEntry = entry; + + if (activeEntry) { + activeEntry.classList.add('viewed'); + activeEntry.classList.add('active'); + if (!preventScroll) { + activeEntry.querySelector('summary')?.focus(); + } + + markAsReadTimer = setTimeout(() => { + markAsRead(activeEntry); + }, 1000); + } + } + + function focusEntry(startIndex, direction) { + let nextIndex = startIndex; + + const visibleEntries = getVisibleEntries(); + if (visibleEntries.length === 0) { + setActiveEntry(null); + return; + } + + if (direction === 'next') { + nextIndex = Math.min(startIndex + 1, visibleEntries.length - 1); + } else if (direction === 'prev') { + nextIndex = Math.max(startIndex - 1, 0); + } + + const newEntry = visibleEntries[nextIndex]; + + if (newEntry) { + setActiveEntry(newEntry); + } + } + + function navigate(direction) { + const visibleEntries = getVisibleEntries(); + if (visibleEntries.length === 0) return; + + const currentIndex = activeEntry ? visibleEntries.indexOf(activeEntry) : -1; + + switch (direction) { + case 'next': + focusEntry(currentIndex, 'next'); + break; + case 'prev': + focusEntry(currentIndex, 'prev'); + break; + case 'first': + setActiveEntry(visibleEntries[0]); + break; + case 'last': + setActiveEntry(visibleEntries[visibleEntries.length - 1]); + break; + } + } + + const observer = new IntersectionObserver((intersectionEntries) => { + const scrollDirection = window.scrollY > lastScrollY ? 'down' : 'up'; + lastScrollY = window.scrollY; + + const activeEntryIsVisible = entries.some(entry => { + if (entry === activeEntry) { + const rect = entry.getBoundingClientRect(); + return rect.top >= 0 && rect.bottom <= window.innerHeight; + } + return false; + }); + + if (activeEntryIsVisible) { + return; + } + + intersectionEntries.forEach(intersectionEntry => { + const { + target, + isIntersecting, + boundingClientRect + } = intersectionEntry; + if (scrollDirection === 'down' && !isIntersecting && boundingClientRect.top < 0) { + clearTimeout(focusTimer); + focusTimer = setTimeout(() => { + const visibleEntries = getVisibleEntries(); + const nextEntry = visibleEntries[visibleEntries.indexOf(target) + 1]; + if (nextEntry) { + setActiveEntry(nextEntry, true); + } + }, 450); + } + }); + }, { + threshold: 0.5 + }); + + entries.forEach(entry => observer.observe(entry)); + + if (entries.length) { + setActiveEntry(getVisibleEntries()[0]); + } + + document.addEventListener('click', (event) => { + const target = event.target; + const entry = target.closest('section.entry'); + + if (entry) { + setActiveEntry(entry); + } + }); + + document.addEventListener('keydown', (event) => { + if (['j', 'k', 'v', 'D', 'U', 'V'].includes(event.key)) { + event.preventDefault(); + + switch (event.key) { + case 'j': + navigate('next'); + break; + case 'k': + navigate('prev'); + break; + case 'U': + navigate('last'); + break; + case 'D': + navigate('first'); + break; + case 'V': + if (activeEntry) { + const link = activeEntry.querySelector('a[data-link-internal]'); + if (link && link.href) window.open(link.href, '_blank'); + } + break; + case 'v': + if (activeEntry) { + const link = activeEntry.querySelector('a[data-link-external]'); + if (link && link.href) window.open(link.href, '_blank'); + } + break; + } + } + }); +}); \ No newline at end of file diff --git a/internal/templates/static/style.css b/internal/templates/static/style.css new file mode 100644 index 0000000..3b0ac44 --- /dev/null +++ b/internal/templates/static/style.css @@ -0,0 +1,256 @@ +:root { + --background-color: #f3f4f6; + --text-color: #374151; + --container-background: #ffffff; + --header-title-color: #1f2937; + --header-date-color: #6b7280; + --group-title-color: #6b7280; + --entry-background: #f9fafb; + --entry-border-color: #e5e7eb; + --entry-focus-shadow: rgba(59, 130, 246, 0.5); + --entry-focus-border: #3b82f6; + --entry-summary-color: #1d4ed8; + --entry-open-background: #e0f2fe; + --entry-open-border: #bfdbfe; + --entry-active-shadow: rgba(234, 88, 12, 0.7); + --entry-active-border: #ea580c; + --entry-active-background: #fff7ed; + --entry-meta-text-color: #6b7280; + --entry-meta-border-color: #e5e7eb; + --entry-content-text-color: #4b5563; + --entry-link-color: #3b82f6; + --entry-link-focus-background: rgba(59, 130, 246, 0.1); +} + +@media (prefers-color-scheme: dark) { + :root { + --background-color: #1f2937; + --text-color: #d1d5db; + --container-background: #111827; + --header-title-color: #f9fafb; + --header-date-color: #9ca3af; + --group-title-color: #9ca3af; + --entry-background: #374151; + --entry-border-color: #4b5563; + --entry-focus-shadow: rgba(96, 165, 250, 0.5); + --entry-focus-border: #60a5fa; + --entry-summary-color: #93c5fd; + --entry-open-background: #374151; + --entry-open-border: #4b5563; + --entry-active-shadow: rgba(251, 146, 60, 0.7); + --entry-active-border: #fb923c; + --entry-active-background: #4a2c13; + --entry-meta-text-color: #9ca3af; + --entry-meta-border-color: #4b5563; + --entry-content-text-color: #d1d5db; + --entry-link-color: #00ff00; + --entry-visited-link-color: #00cc00; + --entry-link-focus-background: rgba(96, 165, 250, 0.1); + } +} + +a { + color: var(--entry-link-color); +} + +a:visited { + color: var(--entry-visited-link-color); +} + +body { + font-family: sans-serif; + background-color: var(--background-color); + color: var(--text-color); + line-height: 1.6; + margin: 0; + padding: 0; +} + +.container { + max-width: 800px; + margin: 2rem auto; + padding: 1.5rem; +} + +section.header { + margin-bottom: 1.5rem; + text-align: center; +} + +section.header h1.title { + font-size: 1.875rem; + font-weight: 700; + color: var(--header-title-color); + margin-bottom: 0.25rem; +} + +section.header .date { + font-size: 0.875rem; + color: var(--header-date-color); +} + +section.summary p { + margin: 1rem auto; + padding: 0.5rem 1rem; + background-color: var(--container-background); + border: 1px solid var(--entry-border-color); + border-radius: 0.5rem; +} + +h2.group-entries-title { + font-size: 1.25rem; + font-weight: 500; + color: var(--group-title-color); + margin-top: 0.5rem; + margin-bottom: 0.5rem; + padding-left: 0.5rem; +} + +section.entry { + background-color: var(--entry-background); + border-radius: 0.5rem; + padding: 0; + margin-bottom: 1rem; + border: 1px solid var(--entry-border-color); +} + +section.entry:focus-within { + box-shadow: 0 0 0 3px var(--entry-focus-shadow); + border-color: var(--entry-focus-border); +} + +summary.entry { + font-size: 1.25rem; + font-weight: 450; + padding: 0.5rem; + cursor: pointer; + color: var(--entry-summary-color); + text-decoration: none; + list-style: none; + position: relative; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +details[open] summary.entry { + white-space: normal; + overflow: visible; + text-overflow: initial; +} + +summary.entry:focus { + outline: none; +} + +section.entry:has(details[open]) { + background-color: var(--entry-open-background); + border-color: var(--entry-open-border); +} + +section.entry.active { + box-shadow: 0 0 0 3px var(--entry-active-shadow); + border-color: var(--entry-active-border); + background-color: var(--entry-active-background); +} + +section.entry.viewed:not(.active) { + filter: grayscale(100%); + opacity: 0.75; +} + +.entry-meta { + filter: grayscale(100%); + font-size: 0.875rem; + color: var(--entry-meta-text-color); + display: flex; + flex-direction: row; + gap: 0.25rem; + align-items: center; + justify-content: start; + border-top: 1px solid var(--entry-meta-border-color); +} + +section.entry.active > .entry-meta { + filter: none; +} + +.entry-meta-tab { + padding: 0.25rem 0.5rem; + border-right: 1px solid var(--entry-meta-border-color); + display: flex; +} + +.entry-meta-tab-feed { + display: flex; + align-items: center; + gap: 0.25rem; + flex-grow: 1; + flex-shrink: 1; + flex-basis: 0; + min-width: 0; +} + +.entry-meta-tab:last-child { + border-right: none; +} + +.entry-meta-tab-feed-title { + max-width: 200px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.entry-content { + font-size: 1rem; + color: var(--entry-content-text-color); + padding: 1rem; + overflow-wrap: anywhere; +} + +.entry-content img { + max-width: 100%; + height: auto; + border-radius: 0.25rem; + margin-top: 0.5rem; +} + +.no-entries { + text-align: center; + font-style: italic; + color: var(--text-color); + padding: 2rem; +} + +a.entry-link { + color: var(--entry-link-color); + text-decoration: none; +} + +a.entry-link:hover { + text-decoration: underline; +} + +a.entry-link:focus { + outline: 2px solid var(--entry-link-color); + outline-offset: 2px; + border-radius: 0.25rem; + background-color: var(--entry-link-focus-background); +} + +details.entry {} + +details.entry h1 { + font-size: 1.5rem; + margin-top: 1rem; + margin-bottom: 0.5rem; +} + +details summary { + cursor: pointer; +} + +details summary > * { + display: inline; +} \ No newline at end of file diff --git a/internal/templates/templates.go b/internal/templates/templates.go index f7b39d0..4caeddf 100644 --- a/internal/templates/templates.go +++ b/internal/templates/templates.go @@ -27,7 +27,7 @@ func init() { archiveTemplateName := "entries.gohtml" emailTemplateName := "email.gotxt" - ArchiveTemplate, err = htmlTemplate.New(archiveTemplateName).Funcs(htmlTemplate.FuncMap{ + ArchiveTemplate, err = htmlTemplate.New(archiveTemplateName).Funcs(FuncMap()).Funcs(htmlTemplate.FuncMap{ "htmlEscape": func(s string) htmlTemplate.HTML { return htmlTemplate.HTML(s) }, -- 2.54.0 From e78e33de0ce98a76baa21656f72c8b455d14e127 Mon Sep 17 00:00:00 2001 From: eleith Date: Mon, 18 Aug 2025 21:42:05 -0700 Subject: [PATCH 2/2] lint --- internal/templates/funcs.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/templates/funcs.go b/internal/templates/funcs.go index 1596ed5..476fa71 100644 --- a/internal/templates/funcs.go +++ b/internal/templates/funcs.go @@ -17,7 +17,7 @@ func FuncMap() template.FuncMap { } } -func include(filename string) (interface{}, error) { +func include(filename string) (any, error) { b, err := fs.ReadFile(staticFS, filename) if err != nil { return "", err -- 2.54.0