diff options
| author | Paul Buetow <paul@buetow.org> | 2026-04-09 20:44:58 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-04-09 20:44:58 +0300 |
| commit | 3e61d09873065f5342efc414ee3ea0d5fdc4c767 (patch) | |
| tree | 7d0ac51cfb41b4774db6292deeb0cc3dce93cf07 /internal/generator | |
| parent | 51f95f88ca78471a50b3fc62dbcea8edb609dc80 (diff) | |
add snonux static microblog generator
Full Go implementation with:
- txt/md/image/audio input processing, URL auto-linking in .txt files
- Paginated HTML output with Atom feed
- 11 visual themes: neon, terminal, synthwave, minimal, brutalist, paper,
aurora, matrix, ocean, retro, glass (selectable via --theme flag)
- Keyboard navigation (j/k/arrows, Enter modal, h/l page nav)
- Shared nav templates (navhints, navmodal, navscript) across all themes
- Magefile build automation; integration test suite covering all themes
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'internal/generator')
| -rw-r--r-- | internal/generator/atom.go | 104 | ||||
| -rw-r--r-- | internal/generator/generator.go | 188 | ||||
| -rw-r--r-- | internal/generator/shared.go | 100 | ||||
| -rw-r--r-- | internal/generator/templates.go | 5 | ||||
| -rw-r--r-- | internal/generator/theme_aurora.go | 114 | ||||
| -rw-r--r-- | internal/generator/theme_brutalist.go | 97 | ||||
| -rw-r--r-- | internal/generator/theme_glass.go | 123 | ||||
| -rw-r--r-- | internal/generator/theme_matrix.go | 102 | ||||
| -rw-r--r-- | internal/generator/theme_minimal.go | 96 | ||||
| -rw-r--r-- | internal/generator/theme_neon.go | 224 | ||||
| -rw-r--r-- | internal/generator/theme_ocean.go | 105 | ||||
| -rw-r--r-- | internal/generator/theme_paper.go | 98 | ||||
| -rw-r--r-- | internal/generator/theme_retro.go | 105 | ||||
| -rw-r--r-- | internal/generator/theme_synthwave.go | 111 | ||||
| -rw-r--r-- | internal/generator/theme_terminal.go | 101 | ||||
| -rw-r--r-- | internal/generator/themes.go | 44 |
16 files changed, 1717 insertions, 0 deletions
diff --git a/internal/generator/atom.go b/internal/generator/atom.go new file mode 100644 index 0000000..259301c --- /dev/null +++ b/internal/generator/atom.go @@ -0,0 +1,104 @@ +package generator + +import ( + "encoding/xml" + "fmt" + "os" + "path/filepath" + "time" + + "codeberg.org/snonux/snonux/internal/config" + "codeberg.org/snonux/snonux/internal/post" +) + +// atomFeed is the root element of an Atom 1.0 feed document. +type atomFeed struct { + XMLName xml.Name `xml:"feed"` + XMLNS string `xml:"xmlns,attr"` + Title string `xml:"title"` + Link atomLink `xml:"link"` + Updated string `xml:"updated"` + ID string `xml:"id"` + Entries []atomEntry `xml:"entry"` +} + +type atomLink struct { + Href string `xml:"href,attr"` + Rel string `xml:"rel,attr,omitempty"` +} + +type atomEntry struct { + Title string `xml:"title"` + Link atomLink `xml:"link"` + ID string `xml:"id"` + Updated string `xml:"updated"` + Content atomContent `xml:"content"` +} + +type atomContent struct { + Type string `xml:"type,attr"` + Value string `xml:",chardata"` +} + +// generateAtom writes atom.xml to cfg.OutputDir containing the most recent +// min(len(posts), config.PostsPerPage) entries. +func generateAtom(posts []*post.Post, cfg *config.Config) error { + limit := config.PostsPerPage + if len(posts) < limit { + limit = len(posts) + } + + recent := posts[:limit] + entries := buildAtomEntries(recent, cfg.BaseURL) + + updated := time.Now().UTC().Format(time.RFC3339) + if len(recent) > 0 { + updated = recent[0].Timestamp.UTC().Format(time.RFC3339) + } + + feed := atomFeed{ + XMLNS: "http://www.w3.org/2005/Atom", + Title: "snonux.foo", + Link: atomLink{Href: cfg.BaseURL + "/"}, + Updated: updated, + ID: cfg.BaseURL + "/", + Entries: entries, + } + + return writeAtomFile(feed, filepath.Join(cfg.OutputDir, "atom.xml")) +} + +// buildAtomEntries converts a slice of posts into Atom entry elements. +func buildAtomEntries(posts []*post.Post, baseURL string) []atomEntry { + entries := make([]atomEntry, 0, len(posts)) + + for _, p := range posts { + entryURL := fmt.Sprintf("%s/posts/%s/", baseURL, p.ID) + entry := atomEntry{ + Title: fmt.Sprintf("Post %s", p.ID), + Link: atomLink{Href: entryURL, Rel: "alternate"}, + ID: entryURL, + Updated: p.Timestamp.UTC().Format(time.RFC3339), + Content: atomContent{Type: "html", Value: p.Content}, + } + entries = append(entries, entry) + } + + return entries +} + +// writeAtomFile marshals feed to XML and writes it to path with XML declaration. +func writeAtomFile(feed atomFeed, path string) error { + data, err := xml.MarshalIndent(feed, "", " ") + if err != nil { + return fmt.Errorf("marshal atom feed: %w", err) + } + + content := append([]byte(xml.Header), data...) + + if err := os.WriteFile(path, content, 0o644); err != nil { + return fmt.Errorf("write atom.xml: %w", err) + } + + return nil +} diff --git a/internal/generator/generator.go b/internal/generator/generator.go new file mode 100644 index 0000000..595bb62 --- /dev/null +++ b/internal/generator/generator.go @@ -0,0 +1,188 @@ +// Package generator reads all post directories from outdir/posts/, sorts them by +// timestamp descending, paginates them into HTML pages, and writes atom.xml. +package generator + +import ( + "encoding/json" + "fmt" + "html/template" + "os" + "path/filepath" + "sort" + "strings" + "time" + + "codeberg.org/snonux/snonux/internal/config" + "codeberg.org/snonux/snonux/internal/post" +) + +// pageData holds the template variables for a single HTML page. +type pageData struct { + Posts []postView + PrevPage string // URL of the newer page, empty if none + NextPage string // URL of the older page, empty if none + PrevPageJSON template.JS + NextPageJSON template.JS +} + +// postView is a render-friendly representation of a post for the HTML template. +type postView struct { + FormattedTime string + ContentHTML template.HTML // pre-rendered; trusted — generated by this tool +} + +// Run loads all posts, generates all HTML pages, and writes atom.xml. +func Run(cfg *config.Config) error { + posts, err := loadAllPosts(cfg.OutputDir) + if err != nil { + return err + } + + // Sort newest-first so page 1 (index.html) has the latest content. + sort.Slice(posts, func(i, j int) bool { + return posts[i].Timestamp.After(posts[j].Timestamp) + }) + + pages := paginate(posts, config.PostsPerPage) + + // Combine the theme HTML (which uses {{template "navhints"}} etc.) with the + // shared navDefs sub-templates so a single parse call resolves all references. + combined := getTheme(cfg.Theme) + "\n" + navDefs + tmpl, err := template.New("page").Parse(combined) + if err != nil { + return fmt.Errorf("parse page template: %w", err) + } + + for i, page := range pages { + if err := writePage(tmpl, page, i, len(pages), cfg); err != nil { + return err + } + } + + return generateAtom(posts, cfg) +} + +// loadAllPosts walks outdir/posts/ and deserialises every post.json found. +func loadAllPosts(outputDir string) ([]*post.Post, error) { + postsDir := filepath.Join(outputDir, "posts") + + entries, err := os.ReadDir(postsDir) + if os.IsNotExist(err) { + return nil, nil // no posts yet — normal on first run + } + if err != nil { + return nil, fmt.Errorf("read posts dir: %w", err) + } + + var posts []*post.Post + + for _, entry := range entries { + if !entry.IsDir() { + continue + } + + p, err := post.Load(filepath.Join(postsDir, entry.Name())) + if err != nil { + return nil, err + } + + posts = append(posts, p) + } + + return posts, nil +} + +// paginate splits posts into chunks of size pageSize. +func paginate(posts []*post.Post, pageSize int) [][]*post.Post { + var pages [][]*post.Post + + for i := 0; i < len(posts); i += pageSize { + end := i + pageSize + if end > len(posts) { + end = len(posts) + } + pages = append(pages, posts[i:end]) + } + + return pages +} + +// pageFilename returns "index.html" for page 0 and "pageN.html" for page N>0. +func pageFilename(index int) string { + if index == 0 { + return "index.html" + } + return fmt.Sprintf("page%d.html", index+1) +} + +// writePage renders one HTML page and writes it to cfg.OutputDir. +func writePage(tmpl *template.Template, posts []*post.Post, pageIndex, totalPages int, cfg *config.Config) error { + data := buildPageData(posts, pageIndex, totalPages) + + filename := pageFilename(pageIndex) + path := filepath.Join(cfg.OutputDir, filename) + + f, err := os.Create(path) + if err != nil { + return fmt.Errorf("create %s: %w", filename, err) + } + defer f.Close() + + if err := tmpl.Execute(f, data); err != nil { + return fmt.Errorf("render %s: %w", filename, err) + } + + return nil +} + +// buildPageData constructs the template data for a single page. +func buildPageData(posts []*post.Post, pageIndex, totalPages int) pageData { + views := make([]postView, len(posts)) + for i, p := range posts { + views[i] = postView{ + FormattedTime: formatPostTime(p.Timestamp), + ContentHTML: template.HTML(p.Content), //nolint:gosec // content is tool-generated HTML + } + } + + var prevPage, nextPage string + + // "Prev" means newer — page index decreases. + if pageIndex > 0 { + prevPage = pageFilename(pageIndex - 1) + } + + // "Next" means older — page index increases. + if pageIndex < totalPages-1 { + nextPage = pageFilename(pageIndex + 1) + } + + return pageData{ + Posts: views, + PrevPage: prevPage, + NextPage: nextPage, + PrevPageJSON: jsonStringOrNull(prevPage), + NextPageJSON: jsonStringOrNull(nextPage), + } +} + +// formatPostTime formats a UTC timestamp in the style used on posts: "09.04.26 • 14:30 UTC". +func formatPostTime(t time.Time) string { + utc := t.UTC() + return fmt.Sprintf("%02d.%02d.%02d • %02d:%02d UTC", + utc.Day(), int(utc.Month()), utc.Year()%100, + utc.Hour(), utc.Minute(), + ) +} + +// jsonStringOrNull returns a JS-safe JSON string literal for s, or "null" if empty. +// The result is safe to embed directly in a <script> block as a JS value. +func jsonStringOrNull(s string) template.JS { + if s == "" { + return "null" + } + + b, _ := json.Marshal(s) + + return template.JS(strings.TrimSpace(string(b))) //nolint:gosec // filename is tool-generated +} diff --git a/internal/generator/shared.go b/internal/generator/shared.go new file mode 100644 index 0000000..eed4de3 --- /dev/null +++ b/internal/generator/shared.go @@ -0,0 +1,100 @@ +package generator + +// navDefs is appended to every theme template when parsing. +// It defines three named sub-templates shared across all themes: +// - "navhints" — keyboard shortcut hint bar HTML +// - "navmodal" — full-screen expanded-post modal HTML +// - "navscript" — keyboard navigation JavaScript +// +// Each theme calls {{template "navhints" .}}, {{template "navmodal" .}}, and +// {{template "navscript" .}} at the appropriate points in its HTML. +// All CSS for these elements (colours, borders, backdrop) lives in each theme +// so themes remain self-contained and independently styled. +const navDefs = ` +{{define "navhints"}} +<div class="nav-hints" aria-label="keyboard shortcuts"> + <span><kbd>j</kbd><kbd>k</kbd> or <kbd>↑</kbd><kbd>↓</kbd> select post</span> + <span><kbd>Enter</kbd> expand</span> + <span><kbd>Esc</kbd> close</span> + <span><kbd>h</kbd><kbd>l</kbd> or <kbd>←</kbd><kbd>→</kbd> change page</span> +</div> +{{end}} + +{{define "navmodal"}} +<div class="post-modal" id="post-modal"> + <div class="modal-inner"> + <button class="modal-close" onclick="closeModal()">[ ESC ] CLOSE</button> + <div id="modal-content"></div> + </div> +</div> +{{end}} + +{{define "navscript"}} +<script> + // === KEYBOARD NAVIGATION === + // j / ArrowDown → next post k / ArrowUp → previous post + // h / ArrowLeft → previous page l / ArrowRight → next page + // Enter → expand modal Esc → close modal + const posts = document.querySelectorAll('.post'); + let currentIndex = posts.length > 0 ? 0 : -1; + const prevPageURL = {{.PrevPageJSON}}; + const nextPageURL = {{.NextPageJSON}}; + + if (currentIndex >= 0) selectPost(0); + + function selectPost(index) { + if (posts.length === 0) return; + if (currentIndex >= 0) posts[currentIndex].classList.remove('post-active'); + currentIndex = Math.max(0, Math.min(index, posts.length - 1)); + posts[currentIndex].classList.add('post-active'); + posts[currentIndex].scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + playNavSound(); + } + + // playNavSound generates a short beep via the Web Audio API. + // A fresh AudioContext per call avoids state issues across navigations. + function playNavSound() { + try { + const ctx = new (window.AudioContext || window.webkitAudioContext)(); + const osc = ctx.createOscillator(); + const gain = ctx.createGain(); + osc.connect(gain); gain.connect(ctx.destination); + osc.frequency.value = 220; osc.type = 'sine'; + gain.gain.setValueAtTime(0.15, ctx.currentTime); + gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.08); + osc.start(ctx.currentTime); osc.stop(ctx.currentTime + 0.08); + } catch (_) {} + } + + function openModal() { + if (currentIndex < 0) return; + document.getElementById('modal-content').innerHTML = + posts[currentIndex].querySelector('.post-text').innerHTML; + document.getElementById('post-modal').classList.add('active'); + } + + function closeModal() { + document.getElementById('post-modal').classList.remove('active'); + } + + document.addEventListener('keydown', function(e) { + if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return; + if (document.getElementById('post-modal').classList.contains('active')) { + if (e.key === 'Escape') { closeModal(); e.preventDefault(); } + return; + } + switch (e.key) { + case 'j': case 'ArrowDown': selectPost(currentIndex + 1); e.preventDefault(); break; + case 'k': case 'ArrowUp': selectPost(currentIndex - 1); e.preventDefault(); break; + case 'h': case 'ArrowLeft': + if (prevPageURL) { playNavSound(); window.location.href = prevPageURL; } + e.preventDefault(); break; + case 'l': case 'ArrowRight': + if (nextPageURL) { playNavSound(); window.location.href = nextPageURL; } + e.preventDefault(); break; + case 'Enter': openModal(); e.preventDefault(); break; + } + }); +</script> +{{end}} +` diff --git a/internal/generator/templates.go b/internal/generator/templates.go new file mode 100644 index 0000000..5186794 --- /dev/null +++ b/internal/generator/templates.go @@ -0,0 +1,5 @@ +package generator + +// HTML templates have moved to per-theme files (theme_*.go). +// Shared sub-templates (navhints, navmodal, navscript) are in shared.go. +// The theme registry and selection logic are in themes.go. diff --git a/internal/generator/theme_aurora.go b/internal/generator/theme_aurora.go new file mode 100644 index 0000000..9475320 --- /dev/null +++ b/internal/generator/theme_aurora.go @@ -0,0 +1,114 @@ +package generator + +// auroraTemplate is a dark navy theme with a CSS-animated aurora borealis +// effect — shifting green/purple/teal gradients across the background sky. +const auroraTemplate = `<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>snonux.foo ✦ AURORA</title> + <style> + :root { --green:#00ffb3; --teal:#00cfe8; --purple:#c084fc; --navy:#050d1a; } + * { margin:0; padding:0; box-sizing:border-box; } + body { font-family:'Segoe UI',system-ui,sans-serif; background:var(--navy); + color:#e0f8f0; overflow:hidden; height:100vh; } + /* Animated aurora bands */ + @keyframes aurora1 { 0%,100%{opacity:0.18;transform:scaleX(1) translateY(0)} 50%{opacity:0.28;transform:scaleX(1.15) translateY(-14px)} } + @keyframes aurora2 { 0%,100%{opacity:0.12;transform:scaleX(1) translateY(0)} 50%{opacity:0.22;transform:scaleX(0.88) translateY(10px)} } + @keyframes aurora3 { 0%,100%{opacity:0.10;transform:scaleX(1) skewY(0deg)} 50%{opacity:0.18;transform:scaleX(1.08) skewY(2deg)} } + .aurora-bg { position:fixed; inset:0; z-index:0; overflow:hidden; } + .aurora-bg::before { content:''; position:absolute; left:-20%; top:5%; width:140%; height:45%; + background:radial-gradient(ellipse,rgba(0,255,179,0.38) 0%,rgba(0,207,232,0.22) 40%,transparent 70%); + filter:blur(40px); animation:aurora1 12s ease-in-out infinite; } + .aurora-bg::after { content:''; position:absolute; left:10%; top:20%; width:120%; height:55%; + background:radial-gradient(ellipse,rgba(192,132,252,0.28) 0%,rgba(0,255,179,0.18) 45%,transparent 70%); + filter:blur(50px); animation:aurora2 16s ease-in-out infinite; } + .aurora-band3 { position:fixed; left:-10%; top:35%; width:130%; height:40%; z-index:0; + background:radial-gradient(ellipse,rgba(0,207,232,0.22) 0%,rgba(192,132,252,0.14) 50%,transparent 75%); + filter:blur(45px); animation:aurora3 20s ease-in-out infinite; } + .overlay { position:relative; z-index:10; height:100vh; display:flex; flex-direction:column; } + header { padding:16px 28px; background:rgba(5,13,26,0.78); backdrop-filter:blur(14px); + border-bottom:1px solid rgba(0,255,179,0.25); display:flex; align-items:center; justify-content:space-between; } + .logo { display:flex; align-items:center; gap:14px; } + .logo-mark { font-size:2rem; font-weight:800; background:linear-gradient(90deg,var(--green),var(--teal)); + -webkit-background-clip:text; -webkit-text-fill-color:transparent; } + .logo-title h1 { font-size:1.5rem; font-weight:700; color:#e0f8f0; letter-spacing:1px; } + .logo-title .subtitle { font-size:0.75rem; color:rgba(224,248,240,0.55); margin-top:2px; } + .logo-title .subtitle a { color:var(--green); text-decoration:none; } + .logo-title .subtitle a:hover { text-shadow:0 0 8px var(--green); } + .transmit-btn { border:1px solid var(--teal); color:var(--teal); padding:9px 20px; + border-radius:20px; text-decoration:none; font-size:0.85rem; + transition:all 0.2s; } + .transmit-btn:hover { background:var(--teal); color:var(--navy); } + .nav-hints { background:rgba(5,13,26,0.6); border-bottom:1px solid rgba(0,255,179,0.15); + color:rgba(224,248,240,0.45); padding:5px 28px; display:flex; gap:18px; + font-size:0.68rem; flex-wrap:wrap; } + .nav-hints kbd { background:rgba(0,255,179,0.1); border:1px solid rgba(0,255,179,0.35); + color:var(--green); border-radius:3px; padding:0 5px; margin:0 2px; } + .content { flex:1; overflow-y:auto; padding:20px 28px; + scrollbar-width:thin; scrollbar-color:var(--green) var(--navy); } + .page-nav { display:flex; justify-content:center; margin:14px 0; } + .page-nav a { border:1px solid var(--teal); color:var(--teal); padding:8px 20px; + border-radius:20px; text-decoration:none; font-size:0.82rem; letter-spacing:1px; } + .page-nav a:hover { background:var(--teal); color:var(--navy); } + .post { background:rgba(5,20,35,0.72); border:1px solid rgba(0,255,179,0.2); border-radius:10px; + padding:20px; margin-bottom:14px; cursor:pointer; + transition:all 0.25s; backdrop-filter:blur(6px); } + .post:hover { border-color:var(--green); box-shadow:0 0 20px rgba(0,255,179,0.2); transform:translateY(-2px); } + .post-active { border-color:var(--purple) !important; background:rgba(15,5,40,0.9) !important; + box-shadow:0 0 24px rgba(192,132,252,0.35),inset 3px 0 0 var(--purple) !important; } + .post-header { display:flex; justify-content:space-between; margin-bottom:12px; font-size:0.88rem; } + .post-time { color:var(--teal); font-family:monospace; font-size:0.8rem; } + .post-text { line-height:1.65; font-size:0.95rem; } + .post-text a { color:var(--green); text-decoration:none; } + .post-text a:hover { text-shadow:0 0 8px var(--green); } + .post-image { max-width:100%; border-radius:8px; margin-top:10px; } + .post-audio { width:100%; margin-top:10px; } + .post-modal { display:none; position:fixed; inset:0; z-index:100; + background:rgba(5,13,26,0.95); backdrop-filter:blur(20px); + overflow-y:auto; padding:40px 20px; } + .post-modal.active { display:block; } + .modal-inner { max-width:760px; margin:0 auto; background:rgba(5,20,40,0.97); + border:1px solid var(--green); border-radius:12px; + box-shadow:0 0 60px rgba(0,255,179,0.25); padding:40px; } + .modal-close { float:right; background:none; border:none; color:var(--teal); + font-size:0.9rem; cursor:pointer; letter-spacing:1px; } + @media(max-width:640px) { .nav-hints{display:none;} header{padding:12px 18px;} .content{padding:14px 18px;} } + </style> +</head> +<body> + <div class="aurora-bg"></div> + <div class="aurora-band3"></div> + <div class="overlay"> + <header> + <div class="logo"> + <span class="logo-mark">SN</span> + <div class="logo-title"> + <h1>snonux.foo</h1> + <p class="subtitle">microblog — <a href="https://foo.zone">foo.zone</a> is the real blog</p> + </div> + </div> + <div class="nav"> + <a href="https://foo.zone/about" class="transmit-btn">Transmit</a> + </div> + </header> + {{template "navhints" .}} + <div class="content" id="post-content"> + {{if .PrevPage}}<div class="page-nav"><a href="{{.PrevPage}}">← Newer</a></div>{{end}} + {{range $i, $post := .Posts}} + <div class="post" data-index="{{$i}}" onclick="selectPost({{$i}})"> + <div class="post-header"> + <div><strong>@snonux</strong></div> + <div class="post-time">{{$post.FormattedTime}}</div> + </div> + <div class="post-text">{{$post.ContentHTML}}</div> + </div> + {{end}} + {{if .NextPage}}<div class="page-nav"><a href="{{.NextPage}}">Older →</a></div>{{end}} + </div> + </div> + {{template "navmodal" .}} + {{template "navscript" .}} +</body> +</html>` diff --git a/internal/generator/theme_brutalist.go b/internal/generator/theme_brutalist.go new file mode 100644 index 0000000..214c103 --- /dev/null +++ b/internal/generator/theme_brutalist.go @@ -0,0 +1,97 @@ +package generator + +// brutalistTemplate is a raw brutalist theme — pure black, thick white borders, +// Impact font, red as the only accent. No rounded corners anywhere. +const brutalistTemplate = `<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>SNONUX.FOO</title> + <style> + :root { --red:#ff2200; } + * { margin:0; padding:0; box-sizing:border-box; } + body { font-family:Impact,'Arial Narrow',Arial,sans-serif; + background:#000; color:#fff; overflow:hidden; height:100vh; } + .overlay { height:100vh; display:flex; flex-direction:column; } + header { padding:14px 24px; background:#000; border-bottom:4px solid #fff; + display:flex; align-items:center; justify-content:space-between; } + .logo { display:flex; align-items:center; gap:16px; } + .logo-mark { font-size:2.8rem; color:var(--red); line-height:1; } + .logo-title h1 { font-size:2rem; color:#fff; letter-spacing:0; line-height:1; } + .logo-title .subtitle { font-size:0.78rem; color:#888; margin-top:3px; + font-family:'Courier New',monospace; } + .logo-title .subtitle a { color:var(--red); text-decoration:none; } + .logo-title .subtitle a:hover { text-decoration:underline; } + .transmit-btn { border:3px solid var(--red); color:var(--red); padding:10px 20px; + border-radius:0; text-decoration:none; font-family:Impact; font-size:1.05rem; + letter-spacing:2px; transition:all 0.1s; } + .transmit-btn:hover { background:var(--red); color:#000; } + .nav-hints { background:#111; border-bottom:2px solid #333; color:#888; + padding:5px 24px; display:flex; gap:18px; font-family:'Courier New',monospace; + font-size:0.7rem; flex-wrap:wrap; } + .nav-hints kbd { background:#000; border:1px solid #555; color:#fff; + border-radius:0; padding:0 5px; margin:0 2px; font-size:0.7rem; } + .content { flex:1; overflow-y:auto; padding:20px 24px; + scrollbar-width:thin; scrollbar-color:#fff #000; } + .page-nav { display:flex; justify-content:center; margin:14px 0; } + .page-nav a { border:3px solid #fff; color:#fff; padding:9px 22px; + border-radius:0; text-decoration:none; font-family:Impact; + font-size:1rem; letter-spacing:2px; } + .page-nav a:hover { background:#fff; color:#000; } + .post { background:#000; border:3px solid #fff; border-radius:0; + padding:20px 22px; margin-bottom:14px; cursor:pointer; + transition:border-color 0.1s,background 0.1s; } + .post:hover { border-color:var(--red); } + .post-active { border-color:var(--red) !important; background:#0d0000 !important; + border-left-width:8px !important; box-shadow:none !important; } + .post-header { display:flex; justify-content:space-between; margin-bottom:12px; } + .post-time { color:#aaa; font-family:'Courier New',monospace; font-size:0.82rem; } + .post-text { font-family:'Arial',sans-serif; font-size:1rem; line-height:1.5; } + .post-text a { color:var(--red); text-decoration:underline; } + .post-image { max-width:100%; margin-top:10px; border:3px solid #fff; } + .post-audio { width:100%; margin-top:10px; } + .post-modal { display:none; position:fixed; inset:0; z-index:100; + background:rgba(0,0,0,0.98); overflow-y:auto; padding:40px 20px; } + .post-modal.active { display:block; } + .modal-inner { max-width:780px; margin:0 auto; background:#000; + border:4px solid #fff; border-radius:0; padding:38px; + box-shadow:8px 8px 0 var(--red); } + .modal-close { float:right; background:none; border:none; color:var(--red); + font-family:Impact; font-size:1.3rem; cursor:pointer; letter-spacing:2px; } + @media(max-width:640px) { .nav-hints{display:none;} header{padding:10px 16px;} .logo-mark{font-size:2rem;} } + </style> +</head> +<body> + <div class="overlay"> + <header> + <div class="logo"> + <span class="logo-mark">SN</span> + <div class="logo-title"> + <h1>SNONUX.FOO</h1> + <p class="subtitle">MICROBLOG — <a href="https://foo.zone">FOO.ZONE</a> IS THE REAL BLOG</p> + </div> + </div> + <div class="nav"> + <a href="https://foo.zone/about" class="transmit-btn">TRANSMIT</a> + </div> + </header> + {{template "navhints" .}} + <div class="content" id="post-content"> + {{if .PrevPage}}<div class="page-nav"><a href="{{.PrevPage}}">← NEWER</a></div>{{end}} + {{range $i, $post := .Posts}} + <div class="post" data-index="{{$i}}" onclick="selectPost({{$i}})"> + <div class="post-header"> + <div><strong>@SNONUX</strong></div> + <div class="post-time">{{$post.FormattedTime}}</div> + </div> + <div class="post-text">{{$post.ContentHTML}}</div> + </div> + {{end}} + {{if .NextPage}}<div class="page-nav"><a href="{{.NextPage}}">OLDER →</a></div>{{end}} + </div> + </div> + {{template "navmodal" .}} + {{template "navscript" .}} +</body> +</html>` diff --git a/internal/generator/theme_glass.go b/internal/generator/theme_glass.go new file mode 100644 index 0000000..520f9b0 --- /dev/null +++ b/internal/generator/theme_glass.go @@ -0,0 +1,123 @@ +package generator + +// glassTemplate is a glassmorphism theme — semi-transparent frosted panels +// using backdrop-filter:blur over a blurred gradient background. +// Light mode with subtle purple/blue gradient blobs and white glass cards. +const glassTemplate = `<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>snonux.foo · glass</title> + <style> + :root { --blue:#6366f1; --purple:#a855f7; --pink:#ec4899; --text:#1e1b4b; } + * { margin:0; padding:0; box-sizing:border-box; } + body { font-family:'Segoe UI',system-ui,sans-serif; overflow:hidden; height:100vh; + background:#f0f4ff; color:var(--text); } + /* Blurred gradient blobs that sit behind all glass panels */ + .bg-blobs { position:fixed; inset:0; z-index:0; overflow:hidden; } + .bg-blobs::before { content:''; position:absolute; top:-20%; left:-10%; width:60%; height:70%; + border-radius:50%; background:radial-gradient(circle,rgba(99,102,241,0.35),rgba(168,85,247,0.2),transparent 70%); + filter:blur(60px); } + .bg-blobs::after { content:''; position:absolute; bottom:-10%; right:-10%; width:65%; height:65%; + border-radius:50%; background:radial-gradient(circle,rgba(236,72,153,0.28),rgba(99,102,241,0.18),transparent 70%); + filter:blur(70px); } + .blob3 { position:fixed; top:40%; left:30%; width:40%; height:50%; z-index:0; + border-radius:60% 40% 70% 30%; background:radial-gradient(circle,rgba(168,85,247,0.18),transparent 65%); + filter:blur(50px); } + .overlay { position:relative; z-index:10; height:100vh; display:flex; flex-direction:column; } + header { padding:16px 28px; background:rgba(255,255,255,0.55); backdrop-filter:blur(20px); + border-bottom:1px solid rgba(255,255,255,0.6); display:flex; align-items:center; justify-content:space-between; + box-shadow:0 2px 12px rgba(99,102,241,0.08); } + .logo { display:flex; align-items:center; gap:14px; } + .logo-mark { font-size:2rem; font-weight:800; + background:linear-gradient(135deg,var(--blue),var(--purple)); + -webkit-background-clip:text; -webkit-text-fill-color:transparent; } + .logo-title h1 { font-size:1.5rem; font-weight:700; color:var(--text); } + .logo-title .subtitle { font-size:0.75rem; color:#6b7280; margin-top:1px; } + .logo-title .subtitle a { color:var(--blue); text-decoration:none; } + .logo-title .subtitle a:hover { text-decoration:underline; } + .transmit-btn { border:1px solid rgba(99,102,241,0.4); color:var(--blue); padding:9px 20px; + border-radius:20px; text-decoration:none; font-size:0.85rem; + background:rgba(255,255,255,0.5); backdrop-filter:blur(8px); + transition:all 0.2s; } + .transmit-btn:hover { background:var(--blue); color:#fff; border-color:var(--blue); } + .nav-hints { background:rgba(255,255,255,0.35); backdrop-filter:blur(10px); + border-bottom:1px solid rgba(255,255,255,0.5); color:#6b7280; + padding:4px 28px; display:flex; gap:18px; font-size:0.68rem; flex-wrap:wrap; } + .nav-hints kbd { background:rgba(255,255,255,0.7); border:1px solid rgba(99,102,241,0.25); + color:var(--blue); border-radius:4px; padding:0 5px; margin:0 2px; font-size:0.68rem; } + .content { flex:1; overflow-y:auto; padding:20px 28px; + scrollbar-width:thin; scrollbar-color:rgba(99,102,241,0.4) transparent; } + .page-nav { display:flex; justify-content:center; margin:14px 0; } + .page-nav a { border:1px solid rgba(99,102,241,0.35); color:var(--blue); padding:8px 20px; + border-radius:20px; text-decoration:none; font-size:0.82rem; + background:rgba(255,255,255,0.45); backdrop-filter:blur(8px); } + .page-nav a:hover { background:var(--blue); color:#fff; } + /* Glass card */ + .post { background:rgba(255,255,255,0.45); backdrop-filter:blur(18px); + border:1px solid rgba(255,255,255,0.6); border-radius:14px; + padding:22px; margin-bottom:14px; cursor:pointer; + box-shadow:0 4px 20px rgba(99,102,241,0.08); + transition:all 0.25s; } + .post:hover { background:rgba(255,255,255,0.6); box-shadow:0 8px 30px rgba(99,102,241,0.18); + transform:translateY(-2px); } + .post-active { border-color:var(--blue) !important; + background:rgba(238,240,255,0.75) !important; + box-shadow:0 0 0 2px rgba(99,102,241,0.3),0 8px 30px rgba(99,102,241,0.2), + inset 3px 0 0 var(--blue) !important; } + .post-header { display:flex; justify-content:space-between; margin-bottom:12px; font-size:0.88rem; } + .post-time { color:#9ca3af; font-family:monospace; font-size:0.8rem; } + .post-text { line-height:1.65; font-size:0.95rem; } + .post-text a { color:var(--blue); text-decoration:none; } + .post-text a:hover { text-decoration:underline; } + .post-image { max-width:100%; border-radius:10px; margin-top:10px; + border:1px solid rgba(255,255,255,0.5); } + .post-audio { width:100%; margin-top:10px; } + .post-modal { display:none; position:fixed; inset:0; z-index:100; + background:rgba(240,244,255,0.85); backdrop-filter:blur(28px); + overflow-y:auto; padding:40px 20px; } + .post-modal.active { display:block; } + .modal-inner { max-width:760px; margin:0 auto; background:rgba(255,255,255,0.7); + backdrop-filter:blur(24px); border:1px solid rgba(255,255,255,0.75); + border-radius:16px; box-shadow:0 20px 60px rgba(99,102,241,0.18); padding:40px; } + .modal-close { float:right; background:none; border:none; color:#9ca3af; + font-size:0.9rem; cursor:pointer; } + @media(max-width:640px) { .nav-hints{display:none;} header{padding:12px 18px;} .content{padding:14px 18px;} } + </style> +</head> +<body> + <div class="bg-blobs"></div> + <div class="blob3"></div> + <div class="overlay"> + <header> + <div class="logo"> + <span class="logo-mark">SN</span> + <div class="logo-title"> + <h1>snonux.foo</h1> + <p class="subtitle">microblog — <a href="https://foo.zone">foo.zone</a> is the real blog</p> + </div> + </div> + <div class="nav"> + <a href="https://foo.zone/about" class |
