Browse Source

refactor

master
Silberengel 4 weeks ago
parent
commit
7679615145
  1. 16
      cmd/server/main.go
  2. 7
      config.yaml.example
  3. 45
      internal/config/config.go
  4. 128
      internal/generator/html.go
  5. 2
      internal/nostr/client.go
  6. 50
      internal/nostr/events.go
  7. 44
      internal/nostr/kinds.go
  8. 6
      internal/nostr/profile.go
  9. 51
      internal/nostr/wiki.go
  10. 12
      internal/server/handlers.go
  11. 23
      package-lock.json
  12. 6
      package.json
  13. 130
      templates/articles.html

16
cmd/server/main.go

@ -52,16 +52,12 @@ func main() {
} }
// Initialize services // Initialize services
// Get the primary wiki kind (first from config) // Use standard Nostr kind constants
wikiKind := cfg.WikiKinds[0] articleKinds := nostr.SupportedArticleKinds()
// Get the primary blog kind (first from config) wikiService := nostr.NewWikiService(nostrClient, articleKinds, nostr.KindWiki, cfg.Relays.AdditionalFallback, nostr.KindIndex, nostr.KindBlog, nostr.KindLongform)
blogKind := cfg.BlogKinds[0] feedService := nostr.NewFeedService(nostrClient, nostr.KindNote)
// Combine wiki and blog kinds for articleKinds (for backward compatibility) issueService := nostr.NewIssueService(nostrClient, nostr.KindIssue, nostr.KindRepoAnnouncement)
articleKinds := append(cfg.WikiKinds, cfg.BlogKinds...) ebooksService := nostr.NewEBooksService(nostrClient, nostr.KindIndex, "wss://theforest.nostr1.com")
wikiService := nostr.NewWikiService(nostrClient, articleKinds, wikiKind, cfg.Relays.AdditionalFallback, cfg.IndexKind, blogKind)
feedService := nostr.NewFeedService(nostrClient, cfg.FeedKind)
issueService := nostr.NewIssueService(nostrClient, cfg.IssueKind, cfg.RepoAnnouncementKind)
ebooksService := nostr.NewEBooksService(nostrClient, cfg.IndexKind, "wss://theforest.nostr1.com")
// Initialize HTML generator // Initialize HTML generator
htmlGenerator, err := generator.NewHTMLGenerator( htmlGenerator, err := generator.NewHTMLGenerator(

7
config.yaml.example

@ -19,10 +19,3 @@ seo:
site_name: "GitCitadel" site_name: "GitCitadel"
site_url: "https://gitcitadel.com" site_url: "https://gitcitadel.com"
default_image: "/static/GitCitadel_Graphic_Landscape.png" default_image: "/static/GitCitadel_Graphic_Landscape.png"
wiki_kind: 30818 # Wiki pages
blog_kind: 30041 # Blog articles
longform_kind: 30023 # Markdown articles
index_kind: 30040 # Index event kind (NKBIP-01)
issue_kind: 1621 # Issue event kind
repo_announcement_kind: 30617 # Repository announcement kind
feed_kind: 1 # Feed event kind (notes)

45
internal/config/config.go

@ -35,13 +35,6 @@ type Config struct {
SiteURL string `yaml:"site_url"` SiteURL string `yaml:"site_url"`
DefaultImage string `yaml:"default_image"` DefaultImage string `yaml:"default_image"`
} `yaml:"seo"` } `yaml:"seo"`
WikiKinds []int `yaml:"wiki_kinds"` // Supported wiki kinds (default: 30818)
BlogKinds []int `yaml:"blog_kinds"` // Supported blog kinds (default: 30041)
ArticleKinds []int `yaml:"article_kinds"` // Supported article kinds (deprecated, use wiki_kinds and blog_kinds)
IndexKind int `yaml:"index_kind"` // Index event kind (default: 30040)
IssueKind int `yaml:"issue_kind"` // Issue event kind (default: 1621)
RepoAnnouncementKind int `yaml:"repo_announcement_kind"` // Repo announcement kind (default: 30617)
FeedKind int `yaml:"feed_kind"` // Feed event kind (default: 1)
} }
// LoadConfig loads configuration from a YAML file // LoadConfig loads configuration from a YAML file
@ -94,44 +87,6 @@ func LoadConfig(path string) (*Config, error) {
config.SEO.DefaultImage = "/static/GitCitadel_Graphic_Landscape.png" config.SEO.DefaultImage = "/static/GitCitadel_Graphic_Landscape.png"
} }
// Backward compatibility: if ArticleKinds is set, use it to populate WikiKinds and BlogKinds
if len(config.ArticleKinds) > 0 {
config.WikiKinds = []int{}
config.BlogKinds = []int{}
for _, kind := range config.ArticleKinds {
switch kind {
case 30818:
config.WikiKinds = append(config.WikiKinds, kind)
case 30041:
config.BlogKinds = append(config.BlogKinds, kind)
}
}
}
// Set default wiki kind if not specified
if len(config.WikiKinds) == 0 {
config.WikiKinds = []int{30818} // Default: wiki (30818)
}
// Set default blog kind if not specified
if len(config.BlogKinds) == 0 {
config.BlogKinds = []int{30041} // Default: blog (30041)
}
// Set default event kinds if not specified
if config.IndexKind == 0 {
config.IndexKind = 30040 // Default: index (30040)
}
if config.IssueKind == 0 {
config.IssueKind = 1621 // Default: issue (1621)
}
if config.RepoAnnouncementKind == 0 {
config.RepoAnnouncementKind = 30617 // Default: repo announcement (30617)
}
if config.FeedKind == 0 {
config.FeedKind = 1 // Default: feed (1)
}
// Validate required fields // Validate required fields
if config.WikiIndex == "" { if config.WikiIndex == "" {
return nil, fmt.Errorf("wiki_index is required") return nil, fmt.Errorf("wiki_index is required")

128
internal/generator/html.go

@ -7,6 +7,7 @@ import (
"fmt" "fmt"
"html/template" "html/template"
"os" "os"
"os/exec"
"path/filepath" "path/filepath"
"strings" "strings"
"time" "time"
@ -97,6 +98,7 @@ type PageData struct {
WikiPages []WikiPageInfo WikiPages []WikiPageInfo
BlogItems []BlogItemInfo BlogItems []BlogItemInfo
BlogSummary string BlogSummary string
ArticleItems []ArticleItemInfo
FeedItems []FeedItemInfo FeedItems []FeedItemInfo
EBooks []EBookInfo EBooks []EBookInfo
Content template.HTML Content template.HTML
@ -123,6 +125,18 @@ type BlogItemInfo struct {
TimeISO string // ISO time TimeISO string // ISO time
} }
// ArticleItemInfo represents info about a longform article item
type ArticleItemInfo struct {
DTag string
Title string
Summary string
Content template.HTML
Author string
CreatedAt int64
Time string // Formatted time
TimeISO string // ISO time
}
// FeedItemInfo represents info about a feed item // FeedItemInfo represents info about a feed item
type FeedItemInfo struct { type FeedItemInfo struct {
Author string Author string
@ -231,6 +245,59 @@ func (g *HTMLGenerator) ProcessAsciiDoc(content string) (string, error) {
return g.asciidocProc.Process(content) return g.asciidocProc.Process(content)
} }
// ProcessMarkdown processes Markdown content to HTML using marked via Node.js
func (g *HTMLGenerator) ProcessMarkdown(markdownContent string) (string, error) {
// Check if node is available
cmd := exec.Command("node", "--version")
if err := cmd.Run(); err != nil {
return "", fmt.Errorf("node.js not found: %w", err)
}
// JavaScript code to run marked
jsCode := `
const { marked } = require('marked');
let content = '';
process.stdin.setEncoding('utf8');
process.stdin.on('data', (chunk) => {
content += chunk;
});
process.stdin.on('end', () => {
try {
// Configure marked options
marked.setOptions({
breaks: true,
gfm: true,
headerIds: true,
mangle: false
});
const html = marked.parse(content);
process.stdout.write(html);
} catch (error) {
console.error('Error converting Markdown:', error.message);
process.exit(1);
}
});
`
// Run node with the JavaScript code, passing content via stdin
cmd = exec.Command("node", "-e", jsCode)
cmd.Stdin = strings.NewReader(markdownContent)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return "", fmt.Errorf("marked conversion failed: %w, stderr: %s", err, stderr.String())
}
return stdout.String(), nil
}
// GenerateLandingPage generates the static landing page // GenerateLandingPage generates the static landing page
func (g *HTMLGenerator) GenerateLandingPage(wikiPages []WikiPageInfo, feedItems []FeedItemInfo) (string, error) { func (g *HTMLGenerator) GenerateLandingPage(wikiPages []WikiPageInfo, feedItems []FeedItemInfo) (string, error) {
// Collect pubkeys from feed items // Collect pubkeys from feed items
@ -385,6 +452,67 @@ func (g *HTMLGenerator) GenerateBlogPage(blogIndex *nostr.IndexEvent, blogItems
return buf.String(), nil return buf.String(), nil
} }
// GenerateArticlesPage generates the articles page with longform articles
func (g *HTMLGenerator) GenerateArticlesPage(articleItems []ArticleItemInfo, feedItems []FeedItemInfo) (string, error) {
description := "Longform articles from " + g.siteName
canonicalURL := g.siteURL + "/articles"
// Format times for article items and collect pubkeys
formattedArticleItems := make([]ArticleItemInfo, len(articleItems))
pubkeys := make([]string, 0, len(articleItems))
for i, item := range articleItems {
formattedArticleItems[i] = item
if item.CreatedAt > 0 {
createdTime := time.Unix(item.CreatedAt, 0)
formattedArticleItems[i].Time = createdTime.Format("Jan 2, 2006")
formattedArticleItems[i].TimeISO = createdTime.Format(time.RFC3339)
}
if item.Author != "" {
pubkeys = append(pubkeys, item.Author)
}
}
// Fetch profiles for article authors
ctx := context.Background()
profiles := g.fetchProfilesBatch(ctx, pubkeys)
data := PageData{
Title: "Articles",
Description: description,
CanonicalURL: canonicalURL,
OGImage: g.siteURL + g.defaultImage,
OGType: "website",
SiteName: g.siteName,
SiteURL: g.siteURL,
CurrentYear: time.Now().Year(),
ArticleItems: formattedArticleItems,
FeedItems: []FeedItemInfo{}, // Empty - feed only on landing page
Content: template.HTML(""),
Profiles: profiles,
}
// Use renderTemplate but with custom data
renderTmpl := template.New("articles-render").Funcs(getTemplateFuncs())
files := []string{
filepath.Join(g.templateDir, "components.html"),
filepath.Join(g.templateDir, "base.html"),
filepath.Join(g.templateDir, "articles.html"),
}
_, err := renderTmpl.ParseFiles(files...)
if err != nil {
return "", fmt.Errorf("failed to parse articles templates: %w", err)
}
var buf bytes.Buffer
if err := renderTmpl.ExecuteTemplate(&buf, "base.html", data); err != nil {
return "", fmt.Errorf("failed to execute articles template: %w", err)
}
return buf.String(), nil
}
// GenerateWikiIndexPage generates the wiki index page // GenerateWikiIndexPage generates the wiki index page
func (g *HTMLGenerator) GenerateWikiIndexPage(wikiIndex *nostr.IndexEvent, wikiPages []WikiPageInfo, feedItems []FeedItemInfo) (string, error) { func (g *HTMLGenerator) GenerateWikiIndexPage(wikiIndex *nostr.IndexEvent, wikiPages []WikiPageInfo, feedItems []FeedItemInfo) (string, error) {
description := "Wiki documentation from " + g.siteName description := "Wiki documentation from " + g.siteName

2
internal/nostr/client.go

@ -221,7 +221,7 @@ func (c *Client) HealthCheck(ctx context.Context, timeout time.Duration) error {
// Try to fetch a recent event to test connectivity // Try to fetch a recent event to test connectivity
// Note: Using kind 1 for health check (this is a standard kind, not configurable) // Note: Using kind 1 for health check (this is a standard kind, not configurable)
filter := nostr.Filter{ filter := nostr.Filter{
Kinds: []int{1}, // kind 1 (notes) for testing Kinds: []int{KindNote}, // kind 1 (notes) for testing
Limit: 1, Limit: 1,
} }

50
internal/nostr/events.go

@ -229,6 +229,56 @@ func ParseBlogEvent(event *nostr.Event, expectedKind int) (*BlogEvent, error) {
return blog, nil return blog, nil
} }
// LongformEvent represents a kind 30023 longform article event
type LongformEvent struct {
Event *nostr.Event
DTag string
Title string
Summary string
Content string
}
// ParseLongformEvent parses a longform article event
func ParseLongformEvent(event *nostr.Event, expectedKind int) (*LongformEvent, error) {
if event.Kind != expectedKind {
return nil, fmt.Errorf("expected kind %d, got %d", expectedKind, event.Kind)
}
longform := &LongformEvent{
Event: event,
Content: event.Content,
}
// Extract d tag (normalized identifier)
for _, tag := range event.Tags {
if len(tag) > 0 && tag[0] == "d" && len(tag) > 1 {
longform.DTag = tag[1]
break
}
}
// Extract title tag (optional, falls back to d tag)
for _, tag := range event.Tags {
if len(tag) > 0 && tag[0] == "title" && len(tag) > 1 {
longform.Title = tag[1]
break
}
}
if longform.Title == "" {
longform.Title = longform.DTag
}
// Extract summary tag (optional)
for _, tag := range event.Tags {
if len(tag) > 0 && tag[0] == "summary" && len(tag) > 1 {
longform.Summary = tag[1]
break
}
}
return longform, nil
}
// NormalizeDTag normalizes a d tag according to NIP-54 rules // NormalizeDTag normalizes a d tag according to NIP-54 rules
func NormalizeDTag(dTag string) string { func NormalizeDTag(dTag string) string {
// Convert to lowercase // Convert to lowercase

44
internal/nostr/kinds.go

@ -0,0 +1,44 @@
package nostr
// Standard Nostr event kinds
// These are protocol-level constants and should not be configurable
const (
// KindProfile is kind 0 - user profile/metadata
KindProfile = 0
// KindNote is kind 1 - regular notes/text posts
KindNote = 1
// KindWiki is kind 30818 - wiki pages (NIP-54)
KindWiki = 30818
// KindBlog is kind 30041 - blog articles
KindBlog = 30041
// KindLongform is kind 30023 - longform markdown articles
KindLongform = 30023
// KindIndex is kind 30040 - publication index events (NKBIP-01)
KindIndex = 30040
// KindIssue is kind 1621 - issue events
KindIssue = 1621
// KindRepoAnnouncement is kind 30617 - repository announcement events
KindRepoAnnouncement = 30617
)
// SupportedWikiKinds returns the list of supported wiki kinds
func SupportedWikiKinds() []int {
return []int{KindWiki}
}
// SupportedBlogKinds returns the list of supported blog kinds
func SupportedBlogKinds() []int {
return []int{KindBlog}
}
// SupportedArticleKinds returns all supported article kinds (wiki + blog)
func SupportedArticleKinds() []int {
return []int{KindWiki, KindBlog}
}

6
internal/nostr/profile.go

@ -109,7 +109,7 @@ func (c *Client) FetchProfilesBatch(ctx context.Context, pubkeys []string) (map[
// Query ALL kind 0 events for these authors in one batch // Query ALL kind 0 events for these authors in one batch
filter := nostr.Filter{ filter := nostr.Filter{
Kinds: []int{0}, Kinds: []int{KindProfile},
Authors: uniquePubkeys, Authors: uniquePubkeys,
Limit: len(uniquePubkeys), // One profile per author Limit: len(uniquePubkeys), // One profile per author
} }
@ -152,8 +152,8 @@ func (c *Client) FetchProfilesBatch(ctx context.Context, pubkeys []string) (map[
// ParseProfile parses a kind 0 profile event // ParseProfile parses a kind 0 profile event
func ParseProfile(event *nostr.Event) (*Profile, error) { func ParseProfile(event *nostr.Event) (*Profile, error) {
if event.Kind != 0 { if event.Kind != KindProfile {
return nil, fmt.Errorf("expected kind 0, got %d", event.Kind) return nil, fmt.Errorf("expected kind %d, got %d", KindProfile, event.Kind)
} }
profile := &Profile{ profile := &Profile{

51
internal/nostr/wiki.go

@ -27,17 +27,19 @@ type WikiService struct {
articleKinds []int // Allowed article kinds (from config) articleKinds []int // Allowed article kinds (from config)
wikiKind int // Primary wiki kind constant (first from wiki_kinds config) wikiKind int // Primary wiki kind constant (first from wiki_kinds config)
blogKind int // Primary blog kind constant (first from blog_kinds config) blogKind int // Primary blog kind constant (first from blog_kinds config)
longformKind int // Longform article kind (from config)
additionalFallback string // Additional fallback relay URL (from config) additionalFallback string // Additional fallback relay URL (from config)
indexKind int // Index event kind (from config) indexKind int // Index event kind (from config)
} }
// NewWikiService creates a new wiki service // NewWikiService creates a new wiki service
func NewWikiService(client *Client, articleKinds []int, wikiKind int, additionalFallback string, indexKind int, blogKind int) *WikiService { func NewWikiService(client *Client, articleKinds []int, wikiKind int, additionalFallback string, indexKind int, blogKind int, longformKind int) *WikiService {
return &WikiService{ return &WikiService{
client: client, client: client,
articleKinds: articleKinds, articleKinds: articleKinds,
wikiKind: wikiKind, wikiKind: wikiKind,
blogKind: blogKind, blogKind: blogKind,
longformKind: longformKind,
additionalFallback: additionalFallback, additionalFallback: additionalFallback,
indexKind: indexKind, indexKind: indexKind,
} }
@ -165,6 +167,53 @@ func (ws *WikiService) GetBlogKind() int {
return ws.blogKind return ws.blogKind
} }
// GetLongformKind returns the longform kind configured in this service
func (ws *WikiService) GetLongformKind() int {
return ws.longformKind
}
// FetchLongformArticles fetches the newest longform articles (kind 30023) from a specific relay
// Queries by kind only, sorted by newest first, limit 1000
func (ws *WikiService) FetchLongformArticles(ctx context.Context, relayURL string, longformKind int, limit int) ([]*nostr.Event, error) {
// Connect to the specific relay
relay, err := ws.client.ConnectToRelay(ctx, relayURL)
if err != nil {
return nil, fmt.Errorf("failed to connect to relay %s: %w", relayURL, err)
}
defer relay.Close()
// Query ALL events of this kind, sorted by newest first
filter := nostr.Filter{
Kinds: []int{longformKind},
Limit: limit,
}
logFilter(filter, fmt.Sprintf("longform articles (kind %d) from %s", longformKind, relayURL))
events, err := relay.QuerySync(ctx, filter)
if err != nil {
return nil, fmt.Errorf("failed to query events: %w", err)
}
// Sort by created_at descending (newest first)
// Note: go-nostr may already return sorted, but we'll ensure it
for i := 0; i < len(events)-1; i++ {
for j := i + 1; j < len(events); j++ {
if events[i].CreatedAt < events[j].CreatedAt {
events[i], events[j] = events[j], events[i]
}
}
}
logger.WithFields(map[string]interface{}{
"events": len(events),
"relay": relayURL,
"kind": longformKind,
}).Debug("Fetched longform articles")
return events, nil
}
// FetchIndexEvents fetches all events of a specific kind referenced in an index // FetchIndexEvents fetches all events of a specific kind referenced in an index
// Only supports article kinds configured in the service // Only supports article kinds configured in the service
// Queries by kind only, then filters locally // Queries by kind only, then filters locally

12
internal/server/handlers.go

@ -28,6 +28,7 @@ func (s *Server) setupRoutes(mux *http.ServeMux) {
mux.HandleFunc("/", s.handleLanding) mux.HandleFunc("/", s.handleLanding)
mux.HandleFunc("/wiki/", s.handleWiki) mux.HandleFunc("/wiki/", s.handleWiki)
mux.HandleFunc("/blog", s.handleBlog) mux.HandleFunc("/blog", s.handleBlog)
mux.HandleFunc("/articles", s.handleArticles)
mux.HandleFunc("/ebooks", s.handleEBooks) mux.HandleFunc("/ebooks", s.handleEBooks)
mux.HandleFunc("/contact", s.handleContact) mux.HandleFunc("/contact", s.handleContact)
@ -95,6 +96,17 @@ func (s *Server) handleBlog(w http.ResponseWriter, r *http.Request) {
s.servePage(w, r, page) s.servePage(w, r, page)
} }
// handleArticles handles the articles page
func (s *Server) handleArticles(w http.ResponseWriter, r *http.Request) {
page, exists := s.cache.Get("/articles")
if !exists {
http.Error(w, "Page not ready", http.StatusServiceUnavailable)
return
}
s.servePage(w, r, page)
}
// handleEBooks handles the e-books listing page // handleEBooks handles the e-books listing page
func (s *Server) handleEBooks(w http.ResponseWriter, r *http.Request) { func (s *Server) handleEBooks(w http.ResponseWriter, r *http.Request) {
page, exists := s.cache.Get("/ebooks") page, exists := s.cache.Get("/ebooks")

23
package-lock.json generated

@ -5,10 +5,8 @@
"packages": { "packages": {
"": { "": {
"dependencies": { "dependencies": {
"@asciidoctor/core": "^3.0.4" "@asciidoctor/core": "^3.0.4",
}, "marked": "^12.0.0"
"devDependencies": {
"lucide": "^0.564.0"
} }
}, },
"node_modules/@asciidoctor/core": { "node_modules/@asciidoctor/core": {
@ -96,12 +94,17 @@
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/lucide": { "node_modules/marked": {
"version": "0.564.0", "version": "12.0.2",
"resolved": "https://registry.npmjs.org/lucide/-/lucide-0.564.0.tgz", "resolved": "https://registry.npmjs.org/marked/-/marked-12.0.2.tgz",
"integrity": "sha512-FasyXKHWon773WIl3HeCQpd5xS6E0aLjqxiQStlHNKktni+HDncc1sqY+6vRUbCfmDsIaKQz43EEQLAUDLZO0g==", "integrity": "sha512-qXUm7e/YKFoqFPYPa3Ukg9xlI5cyAtGmyEIzMfW//m6kXwCy2Ps9DYf5ioijFKQ8qyuscrHoY04iJGctu2Kg0Q==",
"dev": true, "license": "MIT",
"license": "ISC" "bin": {
"marked": "bin/marked.js"
},
"engines": {
"node": ">= 18"
}
}, },
"node_modules/minimatch": { "node_modules/minimatch": {
"version": "5.1.6", "version": "5.1.6",

6
package.json

@ -1,8 +1,6 @@
{ {
"dependencies": { "dependencies": {
"@asciidoctor/core": "^3.0.4" "@asciidoctor/core": "^3.0.4",
}, "marked": "^12.0.0"
"devDependencies": {
"lucide": "^0.564.0"
} }
} }

130
templates/articles.html

@ -0,0 +1,130 @@
{{define "content"}}
<div class="blog-layout">
<!-- Left Sidebar -->
<aside class="blog-sidebar">
<div class="blog-header">
<h1 class="blog-title">Articles</h1>
<p class="blog-description">Longform markdown articles</p>
<div class="blog-tags">
<span class="tag">#articles</span>
<span class="tag">#longform</span>
</div>
</div>
<nav class="blog-nav" aria-label="Articles">
<ul class="article-menu">
{{range $index, $item := .ArticleItems}}
<li>
<a href="#" class="article-link" data-dtag="{{$item.DTag}}" data-index="{{$index}}"{{if eq $index 0}} data-active="true"{{end}}>
<div class="article-link-title"><span class="icon-inline">{{icon "file-text"}}</span> {{$item.Title}}</div>
{{if $item.Time}}
<div class="article-link-meta">
<span class="article-date"><span class="icon-inline">{{icon "clock"}}</span> {{$item.Time}}</span>
{{if $item.Author}}
<span class="article-author">{{template "user-badge-simple" (dict "Pubkey" $item.Author "Profiles" $.Profiles)}}</span>
{{end}}
</div>
{{end}}
</a>
</li>
{{end}}
</ul>
</nav>
</aside>
<!-- Right Content Pane -->
<main class="blog-content">
{{range $index, $item := .ArticleItems}}
<article class="blog-article{{if eq $index 0}} active{{end}}" data-dtag="{{$item.DTag}}" id="article-{{$item.DTag}}">
<header class="article-header">
<h1 class="article-title">{{$item.Title}}</h1>
<p class="article-subtitle">Longform article</p>
</header>
{{if $item.Summary}}<p class="article-summary">{{$item.Summary}}</p>{{end}}
<div class="article-content">
{{$item.Content}}
</div>
</article>
{{else}}
<article class="blog-article active">
<header class="article-header">
<h1 class="article-title"><span class="icon-inline">{{icon "file-x"}}</span> No Articles</h1>
</header>
<div class="article-content">
<p><span class="icon-inline">{{icon "inbox"}}</span> No articles available yet.</p>
</div>
</article>
{{end}}
</main>
</div>
<script>
(function() {
const articleLinks = document.querySelectorAll('.article-link');
const articles = document.querySelectorAll('.blog-article');
function showArticle(dtag) {
// Hide all articles
articles.forEach(article => {
article.classList.remove('active');
});
// Show selected article
const targetArticle = document.querySelector(`.blog-article[data-dtag="${dtag}"]`);
if (targetArticle) {
targetArticle.classList.add('active');
}
// Update active link
articleLinks.forEach(link => {
if (link.dataset.dtag === dtag) {
link.setAttribute('data-active', 'true');
} else {
link.removeAttribute('data-active');
}
});
// Update URL hash without scrolling
if (history.pushState) {
history.pushState(null, null, `#${dtag}`);
} else {
window.location.hash = dtag;
}
}
// Handle link clicks
articleLinks.forEach(link => {
link.addEventListener('click', function(e) {
e.preventDefault();
const dtag = this.dataset.dtag;
showArticle(dtag);
});
});
// Handle initial hash on page load
if (window.location.hash) {
const hash = window.location.hash.substring(1);
const targetLink = document.querySelector(`.article-link[data-dtag="${hash}"]`);
if (targetLink) {
showArticle(hash);
}
}
// Handle browser back/forward
window.addEventListener('popstate', function() {
const hash = window.location.hash.substring(1);
if (hash) {
showArticle(hash);
} else {
// Show first article if no hash
const firstLink = document.querySelector('.article-link');
if (firstLink) {
showArticle(firstLink.dataset.dtag);
}
}
});
})();
</script>
{{end}}
{{/* Feed is defined in components.html */}}
Loading…
Cancel
Save