Browse Source

bug-fixes

master
Silberengel 4 weeks ago
parent
commit
07311db23a
  1. 16
      cmd/server/main.go
  2. 13
      config.yaml.example
  3. 2
      go.mod
  4. 49
      internal/config/config.go
  5. 277
      internal/generator/html.go
  6. 182
      internal/nostr/client.go
  7. 154
      internal/nostr/ebooks.go
  8. 66
      internal/nostr/events.go
  9. 22
      internal/nostr/feed.go
  10. 87
      internal/nostr/issues.go
  11. 213
      internal/nostr/wiki.go
  12. 118
      internal/server/handlers.go
  13. 22
      internal/server/server.go
  14. 390
      static/css/main.css
  15. 16
      static/css/responsive.css
  16. 32
      templates/404.html
  17. 32
      templates/500.html
  18. 29
      templates/base.html
  19. 159
      templates/blog.html
  20. 21
      templates/components.html
  21. 153
      templates/contact.html
  22. 107
      templates/ebooks.html
  23. 18
      templates/landing.html
  24. 18
      templates/page.html
  25. 18
      templates/wiki.html

16
cmd/server/main.go

@ -37,16 +37,23 @@ func main() { @@ -37,16 +37,23 @@ func main() {
feedCache := cache.NewFeedCache()
// Initialize Nostr client
nostrClient := nostr.NewClient(cfg.Relays.Primary, cfg.Relays.Fallback)
nostrClient := nostr.NewClient(cfg.Relays.Primary, cfg.Relays.Fallback, cfg.Relays.AdditionalFallback)
ctx := context.Background()
if err := nostrClient.Connect(ctx); err != nil {
log.Printf("Warning: Failed to connect to relays: %v", err)
}
// Initialize services
wikiService := nostr.NewWikiService(nostrClient)
feedService := nostr.NewFeedService(nostrClient)
issueService := nostr.NewIssueService(nostrClient)
// Get the primary wiki kind (first from config)
wikiKind := cfg.WikiKinds[0]
// Get the primary blog kind (first from config)
blogKind := cfg.BlogKinds[0]
// Combine wiki and blog kinds for articleKinds (for backward compatibility)
articleKinds := append(cfg.WikiKinds, cfg.BlogKinds...)
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
htmlGenerator, err := generator.NewHTMLGenerator(
@ -66,6 +73,7 @@ func main() { @@ -66,6 +73,7 @@ func main() {
feedCache,
wikiService,
feedService,
ebooksService,
htmlGenerator,
cfg.WikiIndex,
cfg.BlogIndex,

13
config.yaml.example

@ -4,6 +4,7 @@ repo_announcement: "naddr1qvzqqqrhnypzplfq3m5v3u5r0q9f255fdeyz8nyac6lagssx8zy4wu @@ -4,6 +4,7 @@ repo_announcement: "naddr1qvzqqqrhnypzplfq3m5v3u5r0q9f255fdeyz8nyac6lagssx8zy4wu
relays:
primary: "wss://theforest.nostr1.com"
fallback: "wss://nostr.land"
additional_fallback: "wss://thecitadel.nostr1.com"
link_base_url: "https://alexandria.gitcitadel.eu"
cache:
refresh_interval_minutes: 30
@ -18,3 +19,15 @@ seo: @@ -18,3 +19,15 @@ seo:
site_name: "GitCitadel"
site_url: "https://gitcitadel.com"
default_image: "/static/GitCitadel_Graphic_Landscape.png"
wiki_kinds:
- 30818 # Wiki pages
blog_kinds:
- 30041 # Blog articles
# article_kinds is deprecated, use wiki_kinds and blog_kinds instead
# article_kinds:
# - 30041 # Blog articles
# - 30818 # Wiki pages
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)

2
go.mod

@ -3,13 +3,13 @@ module gitcitadel-online @@ -3,13 +3,13 @@ module gitcitadel-online
go 1.22.2
require (
github.com/btcsuite/btcd/btcutil v1.1.5
github.com/nbd-wtf/go-nostr v0.27.0
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/btcsuite/btcd/btcec/v2 v2.3.2 // indirect
github.com/btcsuite/btcd/btcutil v1.1.5 // indirect
github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0 // indirect
github.com/cespare/xxhash/v2 v2.2.0 // indirect
github.com/decred/dcrd/crypto/blake256 v1.0.1 // indirect

49
internal/config/config.go

@ -15,6 +15,7 @@ type Config struct { @@ -15,6 +15,7 @@ type Config struct {
Relays struct {
Primary string `yaml:"primary"`
Fallback string `yaml:"fallback"`
AdditionalFallback string `yaml:"additional_fallback"`
} `yaml:"relays"`
LinkBaseURL string `yaml:"link_base_url"`
Cache struct {
@ -34,6 +35,13 @@ type Config struct { @@ -34,6 +35,13 @@ type Config struct {
SiteURL string `yaml:"site_url"`
DefaultImage string `yaml:"default_image"`
} `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
@ -55,6 +63,9 @@ func LoadConfig(path string) (*Config, error) { @@ -55,6 +63,9 @@ func LoadConfig(path string) (*Config, error) {
if config.Relays.Fallback == "" {
config.Relays.Fallback = "wss://nostr.land"
}
if config.Relays.AdditionalFallback == "" {
config.Relays.AdditionalFallback = "wss://thecitadel.nostr1.com"
}
if config.LinkBaseURL == "" {
config.LinkBaseURL = "https://alexandria.gitcitadel.eu"
}
@ -83,6 +94,44 @@ func LoadConfig(path string) (*Config, error) { @@ -83,6 +94,44 @@ func LoadConfig(path string) (*Config, error) {
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
if config.WikiIndex == "" {
return nil, fmt.Errorf("wiki_index is required")

277
internal/generator/html.go

@ -2,6 +2,7 @@ package generator @@ -2,6 +2,7 @@ package generator
import (
"bytes"
"encoding/json"
"fmt"
"html/template"
"path/filepath"
@ -14,6 +15,7 @@ import ( @@ -14,6 +15,7 @@ import (
// HTMLGenerator generates HTML pages from wiki events
type HTMLGenerator struct {
templates *template.Template
templateDir string
asciidocProc *asciidoc.Processor
linkBaseURL string
siteName string
@ -31,10 +33,13 @@ type PageData struct { @@ -31,10 +33,13 @@ type PageData struct {
OGType string
StructuredData template.JS
SiteName string
SiteURL string
CurrentYear int
WikiPages []WikiPageInfo
BlogItems []BlogItemInfo
BlogSummary string
FeedItems []FeedItemInfo
EBooks []EBookInfo
Content template.HTML
Summary string
TableOfContents template.HTML
@ -52,6 +57,10 @@ type BlogItemInfo struct { @@ -52,6 +57,10 @@ type BlogItemInfo struct {
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
@ -63,19 +72,42 @@ type FeedItemInfo struct { @@ -63,19 +72,42 @@ type FeedItemInfo struct {
Link string
}
// EBookInfo represents info about an e-book (top-level index event)
type EBookInfo struct {
EventID string
Title string
DTag string
Author string
Summary string
Type string
CreatedAt int64
Naddr string
Time string // Formatted time
TimeISO string // ISO time
}
// NewHTMLGenerator creates a new HTML generator
func NewHTMLGenerator(templateDir string, linkBaseURL, siteName, siteURL, defaultImage string) (*HTMLGenerator, error) {
tmpl := template.New("base").Funcs(template.FuncMap{
"year": func() int { return time.Now().Year() },
"json": func(v interface{}) (string, error) {
b, err := json.Marshal(v)
if err != nil {
return "", err
}
return string(b), nil
},
})
// Load all templates
templateFiles := []string{
"components.html", // Reusable components (feed, alerts, etc.)
"base.html",
"landing.html",
"page.html",
"blog.html",
"feed_sidebar.html",
"wiki.html",
"contact.html",
"404.html",
"500.html",
}
@ -90,6 +122,7 @@ func NewHTMLGenerator(templateDir string, linkBaseURL, siteName, siteURL, defaul @@ -90,6 +122,7 @@ func NewHTMLGenerator(templateDir string, linkBaseURL, siteName, siteURL, defaul
return &HTMLGenerator{
templates: tmpl,
templateDir: templateDir,
asciidocProc: asciidoc.NewProcessor(linkBaseURL),
linkBaseURL: linkBaseURL,
siteName: siteName,
@ -112,6 +145,7 @@ func (g *HTMLGenerator) GenerateLandingPage(wikiPages []WikiPageInfo, feedItems @@ -112,6 +145,7 @@ func (g *HTMLGenerator) GenerateLandingPage(wikiPages []WikiPageInfo, feedItems
OGImage: g.siteURL + g.defaultImage,
OGType: "website",
SiteName: g.siteName,
SiteURL: g.siteURL,
CurrentYear: time.Now().Year(),
WikiPages: wikiPages,
FeedItems: feedItems,
@ -142,6 +176,7 @@ func (g *HTMLGenerator) GenerateWikiPage(wiki *nostr.WikiEvent, wikiPages []Wiki @@ -142,6 +176,7 @@ func (g *HTMLGenerator) GenerateWikiPage(wiki *nostr.WikiEvent, wikiPages []Wiki
OGImage: g.siteURL + g.defaultImage,
OGType: "article",
SiteName: g.siteName,
SiteURL: g.siteURL,
CurrentYear: time.Now().Year(),
WikiPages: wikiPages,
FeedItems: feedItems,
@ -164,6 +199,17 @@ func (g *HTMLGenerator) GenerateBlogPage(blogIndex *nostr.IndexEvent, blogItems @@ -164,6 +199,17 @@ func (g *HTMLGenerator) GenerateBlogPage(blogIndex *nostr.IndexEvent, blogItems
canonicalURL := g.siteURL + "/blog"
// Format times for blog items
formattedBlogItems := make([]BlogItemInfo, len(blogItems))
for i, item := range blogItems {
formattedBlogItems[i] = item
if item.CreatedAt > 0 {
createdTime := time.Unix(item.CreatedAt, 0)
formattedBlogItems[i].Time = createdTime.Format("Jan 2, 2006")
formattedBlogItems[i].TimeISO = createdTime.Format(time.RFC3339)
}
}
data := PageData{
Title: "Blog",
Description: description,
@ -171,16 +217,126 @@ func (g *HTMLGenerator) GenerateBlogPage(blogIndex *nostr.IndexEvent, blogItems @@ -171,16 +217,126 @@ func (g *HTMLGenerator) GenerateBlogPage(blogIndex *nostr.IndexEvent, blogItems
OGImage: g.siteURL + g.defaultImage,
OGType: "website",
SiteName: g.siteName,
SiteURL: g.siteURL,
CurrentYear: time.Now().Year(),
BlogItems: blogItems,
BlogItems: formattedBlogItems,
BlogSummary: blogIndex.Summary,
FeedItems: feedItems,
Content: template.HTML(""),
}
// Add blog index metadata to template data using a map
type BlogPageData struct {
PageData
BlogIndexTitle string
BlogIndexAuthor string
BlogIndexImage string
BlogIndexSummary string
}
blogData := BlogPageData{
PageData: data,
BlogIndexTitle: blogIndex.Title,
BlogIndexAuthor: blogIndex.Author,
BlogIndexImage: blogIndex.Image,
BlogIndexSummary: blogIndex.Summary,
}
// Use renderTemplate but with custom data
renderTmpl := template.New("blog-render").Funcs(template.FuncMap{
"year": func() int { return time.Now().Year() },
"json": func(v interface{}) (string, error) {
b, err := json.Marshal(v)
if err != nil {
return "", err
}
return string(b), nil
},
})
files := []string{
filepath.Join(g.templateDir, "components.html"),
filepath.Join(g.templateDir, "base.html"),
filepath.Join(g.templateDir, "blog.html"),
}
return g.renderTemplate("blog.html", data)
_, err := renderTmpl.ParseFiles(files...)
if err != nil {
return "", fmt.Errorf("failed to parse blog templates: %w", err)
}
var buf bytes.Buffer
if err := renderTmpl.ExecuteTemplate(&buf, "base.html", blogData); err != nil {
return "", fmt.Errorf("failed to execute blog template: %w", err)
}
return buf.String(), nil
}
// GenerateWikiIndexPage generates the wiki index page
func (g *HTMLGenerator) GenerateWikiIndexPage(wikiIndex *nostr.IndexEvent, wikiPages []WikiPageInfo, feedItems []FeedItemInfo) (string, error) {
description := "Wiki documentation from " + g.siteName
summary := ""
if wikiIndex != nil {
if wikiIndex.Summary != "" {
description = wikiIndex.Summary
summary = wikiIndex.Summary
}
}
canonicalURL := g.siteURL + "/wiki"
data := PageData{
Title: "Wiki",
Description: description,
CanonicalURL: canonicalURL,
OGImage: g.siteURL + g.defaultImage,
OGType: "website",
SiteName: g.siteName,
SiteURL: g.siteURL,
CurrentYear: time.Now().Year(),
WikiPages: wikiPages,
FeedItems: feedItems,
Summary: summary,
}
return g.renderTemplate("wiki.html", data)
}
// GenerateEBooksPage generates the e-books listing page
func (g *HTMLGenerator) GenerateEBooksPage(ebooks []EBookInfo, feedItems []FeedItemInfo) (string, error) {
canonicalURL := g.siteURL + "/ebooks"
// Format times for display
formattedEBooks := make([]EBookInfo, len(ebooks))
for i, ebook := range ebooks {
formattedEBooks[i] = ebook
createdTime := time.Unix(ebook.CreatedAt, 0)
formattedEBooks[i].Time = createdTime.Format("2006-01-02 15:04:05")
formattedEBooks[i].TimeISO = createdTime.Format(time.RFC3339)
}
data := PageData{
Title: "E-Books",
Description: "Browse top-level publications (index events) from Nostr",
CanonicalURL: canonicalURL,
OGImage: g.siteURL + g.defaultImage,
OGType: "website",
SiteName: g.siteName,
SiteURL: g.siteURL,
CurrentYear: time.Now().Year(),
WikiPages: []WikiPageInfo{},
FeedItems: feedItems,
EBooks: formattedEBooks,
Content: template.HTML(""), // Content comes from template
}
return g.renderTemplate("ebooks.html", data)
}
// GenerateContactPage generates the contact form page
func (g *HTMLGenerator) GenerateContactPage(success bool, errorMsg string, eventID string, formData map[string]string) (string, error) {
func (g *HTMLGenerator) GenerateContactPage(success bool, errorMsg string, eventID string, formData map[string]string, repoAnnouncement *nostr.RepoAnnouncement, feedItems []FeedItemInfo) (string, error) {
// Prepare form data with defaults
subject := ""
content := ""
@ -214,9 +370,10 @@ func (g *HTMLGenerator) GenerateContactPage(success bool, errorMsg string, event @@ -214,9 +370,10 @@ func (g *HTMLGenerator) GenerateContactPage(success bool, errorMsg string, event
OGImage: g.siteURL + g.defaultImage,
OGType: "website",
SiteName: g.siteName,
SiteURL: g.siteURL,
CurrentYear: time.Now().Year(),
WikiPages: []WikiPageInfo{}, // Will be populated if needed
FeedItems: []FeedItemInfo{}, // Will be populated if needed
FeedItems: feedItems,
},
Success: success,
Error: errorMsg,
@ -228,12 +385,28 @@ func (g *HTMLGenerator) GenerateContactPage(success bool, errorMsg string, event @@ -228,12 +385,28 @@ func (g *HTMLGenerator) GenerateContactPage(success bool, errorMsg string, event
},
}
// Render contact template with base template
// The base template uses {{block "content" .}} which will be filled by contact.html
// We need to pass the full ContactPageData so the template can access all fields
baseTmpl := g.templates.Lookup("base.html")
if baseTmpl == nil {
return "", fmt.Errorf("template base.html not found")
// Parse base.html together with contact.html to ensure correct blocks are used
renderTmpl := template.New("render").Funcs(template.FuncMap{
"year": func() int { return time.Now().Year() },
"json": func(v interface{}) (string, error) {
b, err := json.Marshal(v)
if err != nil {
return "", err
}
return string(b), nil
},
})
// Parse base.html, components.html, and contact.html together
files := []string{
filepath.Join(g.templateDir, "components.html"), // Reusable components
filepath.Join(g.templateDir, "base.html"),
filepath.Join(g.templateDir, "contact.html"),
}
_, err := renderTmpl.ParseFiles(files...)
if err != nil {
return "", fmt.Errorf("failed to parse templates: %w", err)
}
// Create a map that includes both PageData fields and contact-specific fields
@ -244,6 +417,7 @@ func (g *HTMLGenerator) GenerateContactPage(success bool, errorMsg string, event @@ -244,6 +417,7 @@ func (g *HTMLGenerator) GenerateContactPage(success bool, errorMsg string, event
"OGImage": data.OGImage,
"OGType": data.OGType,
"SiteName": data.SiteName,
"SiteURL": data.SiteURL,
"CurrentYear": data.CurrentYear,
"WikiPages": data.WikiPages,
"BlogItems": data.BlogItems,
@ -254,17 +428,27 @@ func (g *HTMLGenerator) GenerateContactPage(success bool, errorMsg string, event @@ -254,17 +428,27 @@ func (g *HTMLGenerator) GenerateContactPage(success bool, errorMsg string, event
"FormData": data.FormData,
}
// Execute the base template, which will use the contact.html "content" block
// Add repo announcement data for JavaScript
if repoAnnouncement != nil {
templateData["RepoAnnouncement"] = map[string]interface{}{
"Pubkey": repoAnnouncement.Pubkey,
"DTag": repoAnnouncement.DTag,
"Relays": repoAnnouncement.Relays,
"Maintainers": repoAnnouncement.Maintainers,
}
}
// Execute base.html which will use the blocks from contact.html
var buf bytes.Buffer
if err := baseTmpl.ExecuteTemplate(&buf, "base.html", templateData); err != nil {
if err := renderTmpl.ExecuteTemplate(&buf, "base.html", templateData); err != nil {
return "", fmt.Errorf("failed to execute base template: %w", err)
}
return buf.String(), nil
}
// GenerateErrorPage generates an error page (404 or 500)
func (g *HTMLGenerator) GenerateErrorPage(statusCode int, siteName string) (string, error) {
// GenerateErrorPage generates an error page (404 or 500) using base.html
func (g *HTMLGenerator) GenerateErrorPage(statusCode int, feedItems []FeedItemInfo) (string, error) {
var title, message string
switch statusCode {
case 404:
@ -278,36 +462,61 @@ func (g *HTMLGenerator) GenerateErrorPage(statusCode int, siteName string) (stri @@ -278,36 +462,61 @@ func (g *HTMLGenerator) GenerateErrorPage(statusCode int, siteName string) (stri
message = "An error occurred."
}
data := map[string]interface{}{
"SiteName": siteName,
"Title": title,
"Message": message,
canonicalURL := g.siteURL
if statusCode == 404 {
canonicalURL = g.siteURL + "/404"
} else if statusCode == 500 {
canonicalURL = g.siteURL + "/500"
}
tmpl := g.templates.Lookup(fmt.Sprintf("%d.html", statusCode))
if tmpl == nil {
return "", fmt.Errorf("template for status %d not found", statusCode)
}
var buf bytes.Buffer
if err := tmpl.Execute(&buf, data); err != nil {
return "", err
data := PageData{
Title: title,
Description: message,
CanonicalURL: canonicalURL,
OGImage: g.siteURL + g.defaultImage,
OGType: "website",
SiteName: g.siteName,
SiteURL: g.siteURL,
CurrentYear: time.Now().Year(),
WikiPages: []WikiPageInfo{},
FeedItems: feedItems,
Content: template.HTML(""), // Content will come from the template
}
return buf.String(), nil
return g.renderTemplate(fmt.Sprintf("%d.html", statusCode), data)
}
// renderTemplate renders a template with the base template
func (g *HTMLGenerator) renderTemplate(templateName string, data PageData) (string, error) {
// The templateName (e.g., "landing.html") defines blocks that are used by base.html
// We need to execute base.html, which will use the blocks from templateName
baseTmpl := g.templates.Lookup("base.html")
if baseTmpl == nil {
return "", fmt.Errorf("template base.html not found")
// Parse base.html together with the specific template to ensure correct blocks are used
// This avoids the issue where all templates are parsed together and the last "content" block wins
renderTmpl := template.New("render").Funcs(template.FuncMap{
"year": func() int { return time.Now().Year() },
"json": func(v interface{}) (string, error) {
b, err := json.Marshal(v)
if err != nil {
return "", err
}
return string(b), nil
},
})
// Parse base.html, components.html, and the specific template together
// This ensures the correct "content" block and reusable components are available
files := []string{
filepath.Join(g.templateDir, "components.html"), // Reusable components
filepath.Join(g.templateDir, "base.html"),
filepath.Join(g.templateDir, templateName),
}
_, err := renderTmpl.ParseFiles(files...)
if err != nil {
return "", fmt.Errorf("failed to parse templates: %w", err)
}
// Execute base.html which will use the blocks from templateName
var buf bytes.Buffer
if err := baseTmpl.ExecuteTemplate(&buf, "base.html", data); err != nil {
if err := renderTmpl.ExecuteTemplate(&buf, "base.html", data); err != nil {
return "", fmt.Errorf("failed to execute base template: %w", err)
}

182
internal/nostr/client.go

@ -3,6 +3,7 @@ package nostr @@ -3,6 +3,7 @@ package nostr
import (
"context"
"fmt"
"log"
"sync"
"time"
@ -13,15 +14,17 @@ import ( @@ -13,15 +14,17 @@ import (
type Client struct {
primaryRelay string
fallbackRelay string
additionalFallback string
mu sync.RWMutex
lastError error
}
// NewClient creates a new Nostr client with primary and fallback relays
func NewClient(primaryRelay, fallbackRelay string) *Client {
// NewClient creates a new Nostr client with primary, fallback, and additional fallback relays
func NewClient(primaryRelay, fallbackRelay, additionalFallback string) *Client {
return &Client{
primaryRelay: primaryRelay,
fallbackRelay: fallbackRelay,
additionalFallback: additionalFallback,
}
}
@ -45,60 +48,176 @@ func (c *Client) connectToRelay(ctx context.Context, url string) (*nostr.Relay, @@ -45,60 +48,176 @@ func (c *Client) connectToRelay(ctx context.Context, url string) (*nostr.Relay,
return c.ConnectToRelay(ctx, url)
}
// FetchEvent fetches a single event by filter, trying primary relay first, then fallback
// FetchEvent fetches a single event by querying all three relays in parallel with 10-second timeout
// Returns the newest event (highest created_at) if multiple events are found
func (c *Client) FetchEvent(ctx context.Context, filter nostr.Filter) (*nostr.Event, error) {
// Try primary relay first
relay, err := c.connectToRelay(ctx, c.primaryRelay)
if err == nil {
events, err := relay.QuerySync(ctx, filter)
relay.Close()
if err == nil && len(events) > 0 {
return events[0], nil
// Create context with 10-second timeout
queryCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
// Query all three relays in parallel
relays := []string{c.primaryRelay, c.fallbackRelay, c.additionalFallback}
type result struct {
relay string
events []*nostr.Event
err error
}
results := make(chan result, len(relays))
var wg sync.WaitGroup
for _, relayURL := range relays {
if relayURL == "" {
continue
}
wg.Add(1)
go func(url string) {
defer wg.Done()
log.Printf("Querying relay %s with filter: Kinds=%v, Authors=%v, IDs=%v, Tags=%v",
url, filter.Kinds, filter.Authors, filter.IDs, filter.Tags)
// Try fallback relay
relay, err = c.connectToRelay(ctx, c.fallbackRelay)
relay, err := c.connectToRelay(queryCtx, url)
if err != nil {
return nil, fmt.Errorf("failed to connect to both relays: %w", err)
log.Printf("Relay %s: connection failed: %v", url, err)
results <- result{relay: url, events: nil, err: err}
return
}
defer relay.Close()
events, err := relay.QuerySync(ctx, filter)
events, err := relay.QuerySync(queryCtx, filter)
if err != nil {
return nil, fmt.Errorf("failed to fetch event: %w", err)
log.Printf("Relay %s: query failed: %v", url, err)
results <- result{relay: url, events: nil, err: err}
return
}
if len(events) == 0 {
return nil, fmt.Errorf("event not found")
log.Printf("Relay %s: returned %d event(s)", url, len(events))
results <- result{relay: url, events: events, err: nil}
}(relayURL)
}
return events[0], nil
// Wait for all queries to complete
go func() {
wg.Wait()
close(results)
}()
// Collect all events from all relays
var allEvents []*nostr.Event
var lastErr error
for res := range results {
if res.err != nil {
lastErr = res.err
continue
}
if len(res.events) > 0 {
allEvents = append(allEvents, res.events...)
}
}
if len(allEvents) == 0 {
if lastErr != nil {
return nil, fmt.Errorf("event not found from any relay: %w", lastErr)
}
return nil, fmt.Errorf("event not found from any relay")
}
// Find the newest event (highest created_at)
newest := allEvents[0]
for _, event := range allEvents[1:] {
if event.CreatedAt > newest.CreatedAt {
newest = event
}
}
log.Printf("Selected newest event (created_at: %d) from %d total events across all relays", newest.CreatedAt, len(allEvents))
return newest, nil
}
// FetchEvents fetches multiple events by filter, trying primary relay first, then fallback
// FetchEvents fetches multiple events by querying all three relays in parallel with 10-second timeout
// Returns deduplicated events, keeping the newest version of each event
func (c *Client) FetchEvents(ctx context.Context, filter nostr.Filter) ([]*nostr.Event, error) {
// Try primary relay first
relay, err := c.connectToRelay(ctx, c.primaryRelay)
if err == nil {
events, err := relay.QuerySync(ctx, filter)
relay.Close()
if err == nil {
return events, nil
// Create context with 10-second timeout
queryCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
// Query all three relays in parallel
relays := []string{c.primaryRelay, c.fallbackRelay, c.additionalFallback}
type result struct {
relay string
events []*nostr.Event
err error
}
results := make(chan result, len(relays))
var wg sync.WaitGroup
for _, relayURL := range relays {
if relayURL == "" {
continue
}
wg.Add(1)
go func(url string) {
defer wg.Done()
log.Printf("Querying relay %s with filter: Kinds=%v, Authors=%v, IDs=%v, Tags=%v, Limit=%d",
url, filter.Kinds, filter.Authors, filter.IDs, filter.Tags, filter.Limit)
// Try fallback relay
relay, err = c.connectToRelay(ctx, c.fallbackRelay)
relay, err := c.connectToRelay(queryCtx, url)
if err != nil {
return nil, fmt.Errorf("failed to connect to both relays: %w", err)
log.Printf("Relay %s: connection failed: %v", url, err)
results <- result{relay: url, events: nil, err: err}
return
}
defer relay.Close()
events, err := relay.QuerySync(ctx, filter)
events, err := relay.QuerySync(queryCtx, filter)
if err != nil {
return nil, fmt.Errorf("failed to fetch events: %w", err)
log.Printf("Relay %s: query failed: %v", url, err)
results <- result{relay: url, events: nil, err: err}
return
}
log.Printf("Relay %s: returned %d event(s)", url, len(events))
results <- result{relay: url, events: events, err: nil}
}(relayURL)
}
// Wait for all queries to complete
go func() {
wg.Wait()
close(results)
}()
// Collect all events from all relays, deduplicating by ID and keeping newest
eventMap := make(map[string]*nostr.Event)
var lastErr error
for res := range results {
if res.err != nil {
lastErr = res.err
continue
}
for _, event := range res.events {
existing, exists := eventMap[event.ID]
if !exists || event.CreatedAt > existing.CreatedAt {
eventMap[event.ID] = event
}
}
}
if len(eventMap) == 0 {
if lastErr != nil {
return nil, fmt.Errorf("no events found from any relay: %w", lastErr)
}
return nil, fmt.Errorf("no events found from any relay")
}
// Convert map to slice
events := make([]*nostr.Event, 0, len(eventMap))
for _, event := range eventMap {
events = append(events, event)
}
log.Printf("Returning %d unique event(s) from all relays", len(events))
return events, nil
}
@ -142,6 +261,7 @@ func (c *Client) HealthCheck(ctx context.Context, timeout time.Duration) error { @@ -142,6 +261,7 @@ func (c *Client) HealthCheck(ctx context.Context, timeout time.Duration) error {
defer cancel()
// Try to fetch a recent event to test connectivity
// Note: Using kind 1 for health check (this is a standard kind, not configurable)
filter := nostr.Filter{
Kinds: []int{1}, // kind 1 (notes) for testing
Limit: 1,

154
internal/nostr/ebooks.go

@ -0,0 +1,154 @@ @@ -0,0 +1,154 @@
package nostr
import (
"context"
"fmt"
"log"
"github.com/nbd-wtf/go-nostr"
)
// EBooksService handles fetching top-level 30040 events
type EBooksService struct {
client *Client
indexKind int
relayURL string
}
// NewEBooksService creates a new e-books service
func NewEBooksService(client *Client, indexKind int, relayURL string) *EBooksService {
return &EBooksService{
client: client,
indexKind: indexKind,
relayURL: relayURL,
}
}
// EBookInfo represents information about a top-level index event
type EBookInfo struct {
EventID string
Title string
DTag string
Author string
Summary string
Type string
CreatedAt int64
Naddr string
}
// FetchTopLevelIndexEvents fetches all top-level 30040 events from the specified relay
// Top-level means the event is not referenced in any other 30040 event's 'a' tags
func (es *EBooksService) FetchTopLevelIndexEvents(ctx context.Context) ([]EBookInfo, error) {
// Connect to the specific relay
relay, err := es.client.ConnectToRelay(ctx, es.relayURL)
if err != nil {
return nil, fmt.Errorf("failed to connect to relay %s: %w", es.relayURL, err)
}
defer relay.Close()
// Fetch all 30040 events (limit 10k)
filter := nostr.Filter{
Kinds: []int{es.indexKind},
Limit: 10000,
}
logFilter(filter, fmt.Sprintf("all index events (kind %d) from %s", es.indexKind, es.relayURL))
events, err := relay.QuerySync(ctx, filter)
if err != nil {
return nil, fmt.Errorf("failed to query events: %w", err)
}
log.Printf("Fetched %d index events from %s", len(events), es.relayURL)
// Build a set of all referenced kind:pubkey:dtag from 'a' tags
referencedSet := make(map[string]bool)
for _, event := range events {
for _, tag := range event.Tags {
if len(tag) > 0 && tag[0] == "a" && len(tag) > 1 {
// tag[1] is the kind:pubkey:dtag reference
referencedSet[tag[1]] = true
}
}
}
log.Printf("Found %d referenced index events", len(referencedSet))
// Filter to only top-level events (not referenced in any other event)
var topLevelEvents []*nostr.Event
for _, event := range events {
// Build the kind:pubkey:dtag identifier for this event
var dTag string
for _, tag := range event.Tags {
if len(tag) > 0 && tag[0] == "d" && len(tag) > 1 {
dTag = tag[1]
break
}
}
if dTag == "" {
continue // Skip events without d tag
}
// Check if this event is referenced by any other event
eventRef := fmt.Sprintf("%d:%s:%s", es.indexKind, event.PubKey, dTag)
if !referencedSet[eventRef] {
topLevelEvents = append(topLevelEvents, event)
}
}
log.Printf("Found %d top-level index events", len(topLevelEvents))
// Convert to EBookInfo
ebooks := make([]EBookInfo, 0, len(topLevelEvents))
for _, event := range topLevelEvents {
ebook := EBookInfo{
EventID: event.ID,
Author: event.PubKey,
CreatedAt: int64(event.CreatedAt),
}
// Extract d tag
for _, tag := range event.Tags {
if len(tag) > 0 && tag[0] == "d" && len(tag) > 1 {
ebook.DTag = tag[1]
break
}
}
// Extract title
for _, tag := range event.Tags {
if len(tag) > 0 && tag[0] == "title" && len(tag) > 1 {
ebook.Title = tag[1]
break
}
}
if ebook.Title == "" {
ebook.Title = ebook.DTag // Fallback to d tag
}
// Extract summary
for _, tag := range event.Tags {
if len(tag) > 0 && tag[0] == "summary" && len(tag) > 1 {
ebook.Summary = tag[1]
break
}
}
// Extract type
for _, tag := range event.Tags {
if len(tag) > 0 && tag[0] == "type" && len(tag) > 1 {
ebook.Type = tag[1]
break
}
}
// Build naddr
// Format: naddr1 + bech32 encoded (kind:pubkey:dtag:relay)
// For simplicity, we'll use the format: kind:pubkey:dtag
ebook.Naddr = fmt.Sprintf("%d:%s:%s", es.indexKind, ebook.Author, ebook.DTag)
ebooks = append(ebooks, ebook)
}
return ebooks, nil
}

66
internal/nostr/events.go

@ -30,10 +30,10 @@ type IndexItem struct { @@ -30,10 +30,10 @@ type IndexItem struct {
EventID string // Optional event ID for version tracking
}
// ParseIndexEvent parses a kind 30040 index event according to NKBIP-01
func ParseIndexEvent(event *nostr.Event) (*IndexEvent, error) {
if event.Kind != 30040 {
return nil, fmt.Errorf("expected kind 30040, got %d", event.Kind)
// ParseIndexEvent parses an index event according to NKBIP-01
func ParseIndexEvent(event *nostr.Event, expectedKind int) (*IndexEvent, error) {
if event.Kind != expectedKind {
return nil, fmt.Errorf("expected kind %d, got %d", expectedKind, event.Kind)
}
index := &IndexEvent{
@ -138,10 +138,10 @@ type WikiEvent struct { @@ -138,10 +138,10 @@ type WikiEvent struct {
Content string
}
// ParseWikiEvent parses a kind 30818 wiki event according to NIP-54
func ParseWikiEvent(event *nostr.Event) (*WikiEvent, error) {
if event.Kind != 30818 {
return nil, fmt.Errorf("expected kind 30818, got %d", event.Kind)
// ParseWikiEvent parses a wiki event according to NIP-54
func ParseWikiEvent(event *nostr.Event, expectedKind int) (*WikiEvent, error) {
if event.Kind != expectedKind {
return nil, fmt.Errorf("expected kind %d, got %d", expectedKind, event.Kind)
}
wiki := &WikiEvent{
@ -179,6 +179,56 @@ func ParseWikiEvent(event *nostr.Event) (*WikiEvent, error) { @@ -179,6 +179,56 @@ func ParseWikiEvent(event *nostr.Event) (*WikiEvent, error) {
return wiki, nil
}
// BlogEvent represents a kind 30041 blog event
type BlogEvent struct {
Event *nostr.Event
DTag string
Title string
Summary string
Content string
}
// ParseBlogEvent parses a blog event
func ParseBlogEvent(event *nostr.Event, expectedKind int) (*BlogEvent, error) {
if event.Kind != expectedKind {
return nil, fmt.Errorf("expected kind %d, got %d", expectedKind, event.Kind)
}
blog := &BlogEvent{
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 {
blog.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 {
blog.Title = tag[1]
break
}
}
if blog.Title == "" {
blog.Title = blog.DTag
}
// Extract summary tag (optional)
for _, tag := range event.Tags {
if len(tag) > 0 && tag[0] == "summary" && len(tag) > 1 {
blog.Summary = tag[1]
break
}
}
return blog, nil
}
// NormalizeDTag normalizes a d tag according to NIP-54 rules
func NormalizeDTag(dTag string) string {
// Convert to lowercase

22
internal/nostr/feed.go

@ -8,28 +8,38 @@ import ( @@ -8,28 +8,38 @@ import (
"github.com/nbd-wtf/go-nostr"
)
// FeedService handles kind 1 feed operations
// FeedService handles feed operations
type FeedService struct {
client *Client
feedKind int // Feed event kind (from config)
}
// NewFeedService creates a new feed service
func NewFeedService(client *Client) *FeedService {
func NewFeedService(client *Client, feedKind int) *FeedService {
return &FeedService{
client: client,
feedKind: feedKind,
}
}
// FetchFeedItems fetches recent kind 1 events
// FetchFeedItems fetches recent feed events from a specific relay
func (fs *FeedService) FetchFeedItems(ctx context.Context, relay string, maxEvents int) ([]FeedItem, error) {
filter := nostr.Filter{
Kinds: []int{1},
Kinds: []int{fs.feedKind},
Limit: maxEvents,
}
logFilter(filter, fmt.Sprintf("feed (kind %d)", fs.feedKind))
events, err := fs.client.FetchEvents(ctx, filter)
// Connect to the specific feed relay (not the default client relays)
relayConn, err := fs.client.ConnectToRelay(ctx, relay)
if err != nil {
return nil, fmt.Errorf("failed to fetch feed events: %w", err)
return nil, fmt.Errorf("failed to connect to feed relay %s: %w", relay, err)
}
defer relayConn.Close()
events, err := relayConn.QuerySync(ctx, filter)
if err != nil {
return nil, fmt.Errorf("failed to fetch feed events from %s: %w", relay, err)
}
items := make([]FeedItem, 0, len(events))

87
internal/nostr/issues.go

@ -9,15 +9,19 @@ import ( @@ -9,15 +9,19 @@ import (
"github.com/nbd-wtf/go-nostr"
)
// IssueService handles publishing kind 1621 issue events
// IssueService handles publishing issue events
type IssueService struct {
client *Client
issueKind int // Issue event kind (from config)
repoAnnouncementKind int // Repo announcement kind (from config)
}
// NewIssueService creates a new issue service
func NewIssueService(client *Client) *IssueService {
func NewIssueService(client *Client, issueKind int, repoAnnouncementKind int) *IssueService {
return &IssueService{
client: client,
issueKind: issueKind,
repoAnnouncementKind: repoAnnouncementKind,
}
}
@ -30,10 +34,10 @@ type RepoAnnouncement struct { @@ -30,10 +34,10 @@ type RepoAnnouncement struct {
Maintainers []string
}
// ParseRepoAnnouncement parses a kind 30617 repository announcement event
func ParseRepoAnnouncement(event *nostr.Event) (*RepoAnnouncement, error) {
if event.Kind != 30617 {
return nil, fmt.Errorf("expected kind 30617, got %d", event.Kind)
// ParseRepoAnnouncement parses a repository announcement event
func ParseRepoAnnouncement(event *nostr.Event, expectedKind int) (*RepoAnnouncement, error) {
if event.Kind != expectedKind {
return nil, fmt.Errorf("expected kind %d, got %d", expectedKind, event.Kind)
}
repo := &RepoAnnouncement{
@ -74,18 +78,19 @@ func (s *IssueService) FetchRepoAnnouncement(ctx context.Context, repoNaddr stri @@ -74,18 +78,19 @@ func (s *IssueService) FetchRepoAnnouncement(ctx context.Context, repoNaddr stri
return nil, fmt.Errorf("failed to parse repo naddr: %w", err)
}
if naddr.Kind != 30617 {
return nil, fmt.Errorf("expected kind 30617, got %d", naddr.Kind)
if naddr.Kind != s.repoAnnouncementKind {
return nil, fmt.Errorf("expected kind %d, got %d", s.repoAnnouncementKind, naddr.Kind)
}
// Fetch the event
filter := naddr.ToFilter()
logFilter(filter, fmt.Sprintf("repo announcement (kind %d)", s.repoAnnouncementKind))
event, err := s.client.FetchEvent(ctx, filter)
if err != nil {
return nil, fmt.Errorf("failed to fetch repo announcement: %w", err)
}
return ParseRepoAnnouncement(event)
return ParseRepoAnnouncement(event, s.repoAnnouncementKind)
}
// IssueRequest represents a request to create an issue
@ -124,15 +129,15 @@ func (s *IssueService) PublishIssue(ctx context.Context, repoAnnouncement *RepoA @@ -124,15 +129,15 @@ func (s *IssueService) PublishIssue(ctx context.Context, repoAnnouncement *RepoA
// Create the issue event
event := &nostr.Event{
PubKey: pubkey,
Kind: 1621,
Kind: s.issueKind,
Content: req.Content,
CreatedAt: nostr.Timestamp(time.Now().Unix()),
Tags: nostr.Tags{},
}
// Add 'a' tag pointing to the repository announcement
// Format: ["a", "30617:<pubkey>:<d-tag>"]
event.Tags = append(event.Tags, nostr.Tag{"a", fmt.Sprintf("30617:%s:%s", repoAnnouncement.Pubkey, repoAnnouncement.DTag)})
// Format: ["a", "<kind>:<pubkey>:<d-tag>"]
event.Tags = append(event.Tags, nostr.Tag{"a", fmt.Sprintf("%d:%s:%s", s.repoAnnouncementKind, repoAnnouncement.Pubkey, repoAnnouncement.DTag)})
// Add 'p' tag for repository owner
event.Tags = append(event.Tags, nostr.Tag{"p", repoAnnouncement.Pubkey})
@ -193,3 +198,61 @@ func (s *IssueService) PublishIssue(ctx context.Context, repoAnnouncement *RepoA @@ -193,3 +198,61 @@ func (s *IssueService) PublishIssue(ctx context.Context, repoAnnouncement *RepoA
return event.ID, nil
}
// PublishSignedIssue publishes a pre-signed issue event (signed by browser)
func (s *IssueService) PublishSignedIssue(ctx context.Context, signedEvent *nostr.Event) (string, error) {
// Validate the event
if signedEvent.Kind != s.issueKind {
return "", fmt.Errorf("expected kind %d, got %d", s.issueKind, signedEvent.Kind)
}
// Verify the event signature
valid, err := signedEvent.CheckSignature()
if err != nil {
return "", fmt.Errorf("failed to check signature: %w", err)
}
if !valid {
return "", fmt.Errorf("invalid event signature")
}
// Determine which relays to publish to
// Try to extract relays from the event tags or use defaults
relays := []string{s.client.primaryRelay, s.client.fallbackRelay}
// Look for relay hints in tags
for _, tag := range signedEvent.Tags {
if len(tag) > 0 && tag[0] == "relays" {
if len(tag) > 1 {
relays = tag[1:]
break
}
}
}
// Publish to relays
var lastErr error
successCount := 0
for _, relayURL := range relays {
relay, err := s.client.ConnectToRelay(ctx, relayURL)
if err != nil {
lastErr = err
continue
}
err = relay.Publish(ctx, *signedEvent)
relay.Close()
if err != nil {
lastErr = err
continue
}
// Publish succeeded
successCount++
}
if successCount == 0 && lastErr != nil {
return "", fmt.Errorf("failed to publish to any relay: %w", lastErr)
}
return signedEvent.ID, nil
}

213
internal/nostr/wiki.go

@ -3,23 +3,40 @@ package nostr @@ -3,23 +3,40 @@ package nostr
import (
"context"
"fmt"
"log"
"github.com/nbd-wtf/go-nostr"
)
// logFilter logs the exact filter being used for debugging
func logFilter(filter nostr.Filter, context string) {
log.Printf("FILTER [%s]: Kinds=%v, Authors=%v, IDs=%v, Tags=%v, Limit=%d",
context, filter.Kinds, filter.Authors, filter.IDs, filter.Tags, filter.Limit)
}
// WikiService handles wiki-specific operations
type WikiService struct {
client *Client
articleKinds []int // Allowed article kinds (from config)
wikiKind int // Primary wiki kind constant (first from wiki_kinds config)
blogKind int // Primary blog kind constant (first from blog_kinds config)
additionalFallback string // Additional fallback relay URL (from config)
indexKind int // Index event kind (from config)
}
// NewWikiService creates a new wiki service
func NewWikiService(client *Client) *WikiService {
func NewWikiService(client *Client, articleKinds []int, wikiKind int, additionalFallback string, indexKind int, blogKind int) *WikiService {
return &WikiService{
client: client,
articleKinds: articleKinds,
wikiKind: wikiKind,
blogKind: blogKind,
additionalFallback: additionalFallback,
indexKind: indexKind,
}
}
// FetchWikiIndex fetches a wiki index (kind 30040) by naddr
// FetchWikiIndex fetches a wiki index by naddr
func (ws *WikiService) FetchWikiIndex(ctx context.Context, naddrStr string) (*IndexEvent, error) {
// Parse naddr
naddr, err := ParseNaddr(naddrStr)
@ -29,6 +46,7 @@ func (ws *WikiService) FetchWikiIndex(ctx context.Context, naddrStr string) (*In @@ -29,6 +46,7 @@ func (ws *WikiService) FetchWikiIndex(ctx context.Context, naddrStr string) (*In
// Create filter for the index event
filter := naddr.ToFilter()
logFilter(filter, fmt.Sprintf("wiki index (kind %d)", ws.indexKind))
// Fetch the event
event, err := ws.client.FetchEvent(ctx, filter)
@ -37,7 +55,7 @@ func (ws *WikiService) FetchWikiIndex(ctx context.Context, naddrStr string) (*In @@ -37,7 +55,7 @@ func (ws *WikiService) FetchWikiIndex(ctx context.Context, naddrStr string) (*In
}
// Parse the index event
index, err := ParseIndexEvent(event)
index, err := ParseIndexEvent(event, ws.indexKind)
if err != nil {
return nil, fmt.Errorf("failed to parse index event: %w", err)
}
@ -45,63 +63,234 @@ func (ws *WikiService) FetchWikiIndex(ctx context.Context, naddrStr string) (*In @@ -45,63 +63,234 @@ func (ws *WikiService) FetchWikiIndex(ctx context.Context, naddrStr string) (*In
return index, nil
}
// FetchWikiEvents fetches all wiki events (kind 30818) referenced in an index
// FetchWikiEvents fetches all wiki events referenced in an index
func (ws *WikiService) FetchWikiEvents(ctx context.Context, index *IndexEvent) ([]*WikiEvent, error) {
var wikiEvents []*WikiEvent
for _, item := range index.Items {
if item.Kind != 30818 {
if item.Kind != ws.wikiKind {
continue // Skip non-wiki items
}
// Create filter for this wiki event
filter := nostr.Filter{
Kinds: []int{30818},
Kinds: []int{ws.wikiKind},
Authors: []string{item.Pubkey},
Tags: map[string][]string{
"d": {item.DTag},
},
}
// If event ID is specified, use it
// If event ID is specified, use it for more reliable fetching
if item.EventID != "" {
filter.IDs = []string{item.EventID}
log.Printf("Using event ID %s for %s", item.EventID[:16]+"...", item.DTag)
}
// Fetch the event
event, err := ws.client.FetchEvent(ctx, filter)
logFilter(filter, fmt.Sprintf("wiki event %s", item.DTag))
// Use relay hint if available, otherwise use default client relays
var event *nostr.Event
var err error
if item.RelayHint != "" {
// Connect to the relay hint
log.Printf("Trying relay hint %s for %s", item.RelayHint, item.DTag)
relay, relayErr := ws.client.ConnectToRelay(ctx, item.RelayHint)
if relayErr == nil {
events, queryErr := relay.QuerySync(ctx, filter)
relay.Close()
if queryErr == nil {
if len(events) > 0 {
event = events[0]
log.Printf("Successfully fetched %s from relay hint %s", item.DTag, item.RelayHint)
} else {
log.Printf("Relay hint %s returned 0 events for %s (event ID: %v)", item.RelayHint, item.DTag, item.EventID != "")
}
} else {
log.Printf("Error querying relay hint %s for %s: %v", item.RelayHint, item.DTag, queryErr)
}
} else {
log.Printf("Error connecting to relay hint %s for %s: %v", item.RelayHint, item.DTag, relayErr)
}
}
// Fallback to default client relays if relay hint failed or wasn't provided
// Client now queries all three relays automatically
if event == nil {
log.Printf("Querying all relays for %s", item.DTag)
event, err = ws.client.FetchEvent(ctx, filter)
if err != nil {
// If still not found and event ID was specified, try without event ID to get latest replaceable event
if item.EventID != "" {
log.Printf("Trying without event ID for %s to get latest replaceable event", item.DTag)
filterWithoutID := nostr.Filter{
Kinds: []int{ws.wikiKind},
Authors: []string{item.Pubkey},
Tags: map[string][]string{
"d": {item.DTag},
},
}
logFilter(filterWithoutID, fmt.Sprintf("wiki event %s (without event ID)", item.DTag))
event, err = ws.client.FetchEvent(ctx, filterWithoutID)
if err == nil {
log.Printf("Successfully fetched %s (latest) from all relays", item.DTag)
}
}
} else {
log.Printf("Successfully fetched %s from all relays", item.DTag)
}
if event == nil {
// Log error but continue with other events
log.Printf("Error fetching wiki event for %s (pubkey: %s, event ID: %v): %v", item.DTag, item.Pubkey, item.EventID != "", err)
continue
}
}
// Parse the wiki event
wiki, err := ParseWikiEvent(event)
wiki, err := ParseWikiEvent(event, ws.wikiKind)
if err != nil {
log.Printf("Error parsing wiki event for %s: %v", item.DTag, err)
continue
}
wikiEvents = append(wikiEvents, wiki)
}
if len(wikiEvents) == 0 && len(index.Items) > 0 {
log.Printf("Warning: No wiki events could be fetched from %d index items", len(index.Items))
// Return empty slice instead of error to allow landing page generation
}
return wikiEvents, nil
}
// GetBlogKind returns the blog kind configured in this service
func (ws *WikiService) GetBlogKind() int {
return ws.blogKind
}
// FetchIndexEvents fetches all events of a specific kind referenced in an index
// Only supports article kinds configured in the service
func (ws *WikiService) FetchIndexEvents(ctx context.Context, index *IndexEvent, targetKind int) ([]*nostr.Event, error) {
// Check if the target kind is in the allowed article kinds
allowed := false
for _, kind := range ws.articleKinds {
if kind == targetKind {
allowed = true
break
}
}
if !allowed {
return nil, fmt.Errorf("unsupported event kind: %d (only %v are supported)", targetKind, ws.articleKinds)
}
var events []*nostr.Event
for _, item := range index.Items {
// Only process items of the target kind
if item.Kind != targetKind {
continue // Skip items that don't match the target kind
}
// Create filter for this event
filter := nostr.Filter{
Kinds: []int{targetKind},
Authors: []string{item.Pubkey},
Tags: map[string][]string{
"d": {item.DTag},
},
}
// If event ID is specified, use it for more reliable fetching
if item.EventID != "" {
filter.IDs = []string{item.EventID}
}
logFilter(filter, fmt.Sprintf("index event kind %d %s", targetKind, item.DTag))
// Use relay hint if available, otherwise use default client relays
var event *nostr.Event
var err error
if item.RelayHint != "" {
// Connect to the relay hint
relay, relayErr := ws.client.ConnectToRelay(ctx, item.RelayHint)
if relayErr == nil {
relayEvents, queryErr := relay.QuerySync(ctx, filter)
relay.Close()
if queryErr == nil {
if len(relayEvents) > 0 {
event = relayEvents[0]
} else {
log.Printf("Relay hint %s returned 0 events for %s (kind %d, event ID: %v)", item.RelayHint, item.DTag, targetKind, item.EventID != "")
}
} else {
log.Printf("Error querying relay hint %s for %s (kind %d): %v", item.RelayHint, item.DTag, targetKind, queryErr)
}
} else {
log.Printf("Error connecting to relay hint %s for %s (kind %d): %v", item.RelayHint, item.DTag, targetKind, relayErr)
}
}
// Fallback to default client relays if relay hint failed or wasn't provided
// Client now queries all three relays automatically
if event == nil {
log.Printf("Querying all relays for %s (kind %d)", item.DTag, targetKind)
event, err = ws.client.FetchEvent(ctx, filter)
if err != nil {
// If still not found and event ID was specified, try without event ID to get latest replaceable event
if item.EventID != "" {
log.Printf("Trying without event ID for %s (kind %d) to get latest replaceable event", item.DTag, targetKind)
filterWithoutID := nostr.Filter{
Kinds: []int{targetKind},
Authors: []string{item.Pubkey},
Tags: map[string][]string{
"d": {item.DTag},
},
}
logFilter(filterWithoutID, fmt.Sprintf("index event kind %d %s (without event ID)", targetKind, item.DTag))
event, err = ws.client.FetchEvent(ctx, filterWithoutID)
if err == nil {
log.Printf("Successfully fetched %s (kind %d, latest) from all relays", item.DTag, targetKind)
}
}
} else {
log.Printf("Successfully fetched %s (kind %d) from all relays", item.DTag, targetKind)
}
if event == nil {
// Log error but continue with other events
log.Printf("Error fetching event for %s (kind %d, pubkey: %s, event ID: %v): %v", item.DTag, targetKind, item.Pubkey, item.EventID != "", err)
continue
}
}
events = append(events, event)
}
if len(events) == 0 && len(index.Items) > 0 {
log.Printf("Warning: No events of kind %d could be fetched from %d index items", targetKind, len(index.Items))
}
return events, nil
}
// FetchWikiEventByDTag fetches a single wiki event by d tag
func (ws *WikiService) FetchWikiEventByDTag(ctx context.Context, pubkey, dTag string) (*WikiEvent, error) {
filter := nostr.Filter{
Kinds: []int{30818},
Kinds: []int{ws.wikiKind},
Authors: []string{pubkey},
Tags: map[string][]string{
"d": {dTag},
},
Limit: 1,
}
logFilter(filter, fmt.Sprintf("wiki by d-tag %s", dTag))
event, err := ws.client.FetchEvent(ctx, filter)
if err != nil {
return nil, fmt.Errorf("failed to fetch wiki event: %w", err)
}
return ParseWikiEvent(event)
return ParseWikiEvent(event, ws.wikiKind)
}

118
internal/server/handlers.go

@ -2,12 +2,15 @@ package server @@ -2,12 +2,15 @@ package server
import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"strings"
"time"
gonostr "github.com/nbd-wtf/go-nostr"
"gitcitadel-online/internal/cache"
"gitcitadel-online/internal/nostr"
)
@ -21,6 +24,7 @@ func (s *Server) setupRoutes(mux *http.ServeMux) { @@ -21,6 +24,7 @@ func (s *Server) setupRoutes(mux *http.ServeMux) {
mux.HandleFunc("/", s.handleLanding)
mux.HandleFunc("/wiki/", s.handleWiki)
mux.HandleFunc("/blog", s.handleBlog)
mux.HandleFunc("/ebooks", s.handleEBooks)
mux.HandleFunc("/contact", s.handleContact)
// Health and metrics
@ -30,6 +34,9 @@ func (s *Server) setupRoutes(mux *http.ServeMux) { @@ -30,6 +34,9 @@ func (s *Server) setupRoutes(mux *http.ServeMux) {
// SEO
mux.HandleFunc("/sitemap.xml", s.handleSitemap)
mux.HandleFunc("/robots.txt", s.handleRobots)
// API endpoints
mux.HandleFunc("/api/contact", s.handleContactAPI)
}
// handleLanding handles the landing page
@ -48,9 +55,22 @@ func (s *Server) handleLanding(w http.ResponseWriter, r *http.Request) { @@ -48,9 +55,22 @@ func (s *Server) handleLanding(w http.ResponseWriter, r *http.Request) {
s.servePage(w, r, page)
}
// handleWiki handles wiki article pages
// handleWiki handles wiki article pages and wiki index
func (s *Server) handleWiki(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path
// Handle wiki index page (/wiki or /wiki/)
if path == "/wiki" || path == "/wiki/" {
page, exists := s.cache.Get("/wiki")
if !exists {
http.Error(w, "Page not ready", http.StatusServiceUnavailable)
return
}
s.servePage(w, r, page)
return
}
// Handle individual wiki pages (/wiki/{dTag})
page, exists := s.cache.Get(path)
if !exists {
s.handle404(w, r)
@ -71,11 +91,39 @@ func (s *Server) handleBlog(w http.ResponseWriter, r *http.Request) { @@ -71,11 +91,39 @@ func (s *Server) handleBlog(w http.ResponseWriter, r *http.Request) {
s.servePage(w, r, page)
}
// handleEBooks handles the e-books listing page
func (s *Server) handleEBooks(w http.ResponseWriter, r *http.Request) {
page, exists := s.cache.Get("/ebooks")
if !exists {
http.Error(w, "Page not ready", http.StatusServiceUnavailable)
return
}
s.servePage(w, r, page)
}
// handleContact handles the contact form (GET and POST)
func (s *Server) handleContact(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet {
// Fetch repo announcement for embedding in page
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var repoAnnouncement *nostr.RepoAnnouncement
var err error
if s.repoAnnouncement != "" {
repoAnnouncement, err = s.issueService.FetchRepoAnnouncement(ctx, s.repoAnnouncement)
if err != nil {
log.Printf("Failed to fetch repo announcement for contact page: %v", err)
// Continue without repo announcement - form will show error
}
}
// Get feed items from cache
feedItems := s.convertFeedItemsToInfo(s.feedCache.Get())
// Render the contact form
html, err := s.htmlGenerator.GenerateContactPage(false, "", "", nil)
html, err := s.htmlGenerator.GenerateContactPage(false, "", "", nil, repoAnnouncement, feedItems)
if err != nil {
http.Error(w, "Failed to generate contact page", http.StatusInternalServerError)
return
@ -92,7 +140,8 @@ func (s *Server) handleContact(w http.ResponseWriter, r *http.Request) { @@ -92,7 +140,8 @@ func (s *Server) handleContact(w http.ResponseWriter, r *http.Request) {
// Parse form data
if err := r.ParseForm(); err != nil {
html, _ := s.htmlGenerator.GenerateContactPage(false, "Failed to parse form data", "", nil)
feedItems := s.convertFeedItemsToInfo(s.feedCache.Get())
html, _ := s.htmlGenerator.GenerateContactPage(false, "Failed to parse form data", "", nil, nil, feedItems)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write([]byte(html))
return
@ -104,12 +153,13 @@ func (s *Server) handleContact(w http.ResponseWriter, r *http.Request) { @@ -104,12 +153,13 @@ func (s *Server) handleContact(w http.ResponseWriter, r *http.Request) {
// Validate required fields
if subject == "" || content == "" {
feedItems := s.convertFeedItemsToInfo(s.feedCache.Get())
formData := map[string]string{
"subject": subject,
"content": content,
"labels": labelsStr,
}
html, _ := s.htmlGenerator.GenerateContactPage(false, "Subject and message are required", "", formData)
html, _ := s.htmlGenerator.GenerateContactPage(false, "Subject and message are required", "", formData, nil, feedItems)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write([]byte(html))
return
@ -134,12 +184,13 @@ func (s *Server) handleContact(w http.ResponseWriter, r *http.Request) { @@ -134,12 +184,13 @@ func (s *Server) handleContact(w http.ResponseWriter, r *http.Request) {
repoAnnouncement, err := s.issueService.FetchRepoAnnouncement(ctx, s.repoAnnouncement)
if err != nil {
log.Printf("Failed to fetch repo announcement: %v", err)
feedItems := s.convertFeedItemsToInfo(s.feedCache.Get())
formData := map[string]string{
"subject": subject,
"content": content,
"labels": labelsStr,
}
html, _ := s.htmlGenerator.GenerateContactPage(false, "Failed to connect to repository. Please try again later.", "", formData)
html, _ := s.htmlGenerator.GenerateContactPage(false, "Failed to connect to repository. Please try again later.", "", formData, nil, feedItems)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write([]byte(html))
return
@ -156,19 +207,21 @@ func (s *Server) handleContact(w http.ResponseWriter, r *http.Request) { @@ -156,19 +207,21 @@ func (s *Server) handleContact(w http.ResponseWriter, r *http.Request) {
eventID, err := s.issueService.PublishIssue(ctx, repoAnnouncement, issueReq, "")
if err != nil {
log.Printf("Failed to publish issue: %v", err)
feedItems := s.convertFeedItemsToInfo(s.feedCache.Get())
formData := map[string]string{
"subject": subject,
"content": content,
"labels": labelsStr,
}
html, _ := s.htmlGenerator.GenerateContactPage(false, "Failed to submit your message. Please try again later.", "", formData)
html, _ := s.htmlGenerator.GenerateContactPage(false, "Failed to submit your message. Please try again later.", "", formData, nil, feedItems)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write([]byte(html))
return
}
// Success - render success page
html, err := s.htmlGenerator.GenerateContactPage(true, "", eventID, nil)
feedItems := s.convertFeedItemsToInfo(s.feedCache.Get())
html, err := s.htmlGenerator.GenerateContactPage(true, "", eventID, nil, repoAnnouncement, feedItems)
if err != nil {
http.Error(w, "Failed to generate success page", http.StatusInternalServerError)
return
@ -177,6 +230,49 @@ func (s *Server) handleContact(w http.ResponseWriter, r *http.Request) { @@ -177,6 +230,49 @@ func (s *Server) handleContact(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(html))
}
// handleContactAPI handles API requests for contact form with browser-signed events
func (s *Server) handleContactAPI(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Parse JSON request
var req struct {
Event *gonostr.Event `json:"event"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest)
return
}
if req.Event == nil {
http.Error(w, "Event is required", http.StatusBadRequest)
return
}
// Validate event kind (will be validated again in PublishSignedIssue)
// Note: issueKind is stored in issueService, validation happens there
// Publish the signed event
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
eventID, err := s.issueService.PublishSignedIssue(ctx, req.Event)
if err != nil {
log.Printf("Failed to publish signed issue: %v", err)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
fmt.Fprintf(w, `{"error": "Failed to publish issue: %s"}`, err.Error())
return
}
// Return success response
w.Header().Set("Content-Type", "application/json")
fmt.Fprintf(w, `{"success": true, "event_id": "%s"}`, eventID)
}
// handleStatic serves static files
func (s *Server) handleStatic(w http.ResponseWriter, r *http.Request) {
// Serve static files from the static directory
@ -219,8 +315,14 @@ func (s *Server) handleRobots(w http.ResponseWriter, r *http.Request) { @@ -219,8 +315,14 @@ func (s *Server) handleRobots(w http.ResponseWriter, r *http.Request) {
// handle404 handles 404 errors
func (s *Server) handle404(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNotFound)
// TODO: Serve custom 404 page from cache
feedItems := s.convertFeedItemsToInfo(s.feedCache.Get())
html, err := s.htmlGenerator.GenerateErrorPage(404, feedItems)
if err != nil {
w.Write([]byte("404 - Page Not Found"))
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write([]byte(html))
}
// servePage serves a cached page with proper headers

22
internal/server/server.go

@ -10,7 +10,10 @@ import ( @@ -10,7 +10,10 @@ import (
"syscall"
"time"
gonostr "github.com/nbd-wtf/go-nostr"
"gitcitadel-online/internal/cache"
"gitcitadel-online/internal/generator"
"gitcitadel-online/internal/nostr"
)
@ -29,11 +32,13 @@ type Server struct { @@ -29,11 +32,13 @@ type Server struct {
type IssueServiceInterface interface {
FetchRepoAnnouncement(ctx context.Context, repoNaddr string) (*nostr.RepoAnnouncement, error)
PublishIssue(ctx context.Context, repoAnnouncement *nostr.RepoAnnouncement, req *nostr.IssueRequest, privateKey string) (string, error)
PublishSignedIssue(ctx context.Context, signedEvent *gonostr.Event) (string, error)
}
// HTMLGeneratorInterface defines the interface for HTML generator
type HTMLGeneratorInterface interface {
GenerateContactPage(success bool, errorMsg string, eventID string, formData map[string]string) (string, error)
GenerateContactPage(success bool, errorMsg string, eventID string, formData map[string]string, repoAnnouncement *nostr.RepoAnnouncement, feedItems []generator.FeedItemInfo) (string, error)
GenerateErrorPage(statusCode int, feedItems []generator.FeedItemInfo) (string, error)
}
// NewServer creates a new HTTP server
@ -90,3 +95,18 @@ func (s *Server) WaitForShutdown() { @@ -90,3 +95,18 @@ func (s *Server) WaitForShutdown() {
log.Println("Server exited")
}
// convertFeedItemsToInfo converts cache.FeedItem to generator.FeedItemInfo
func (s *Server) convertFeedItemsToInfo(items []cache.FeedItem) []generator.FeedItemInfo {
feedItems := make([]generator.FeedItemInfo, 0, len(items))
for _, item := range items {
feedItems = append(feedItems, generator.FeedItemInfo{
Author: item.Author,
Content: item.Content,
Time: item.Time.Format("2006-01-02 15:04:05"),
TimeISO: item.Time.Format(time.RFC3339),
Link: item.Link,
})
}
return feedItems
}

390
static/css/main.css

@ -20,6 +20,18 @@ @@ -20,6 +20,18 @@
box-sizing: border-box;
}
/* Prevent text overflow - applied globally but can be overridden */
*, *::before, *::after {
overflow-wrap: break-word;
word-wrap: break-word;
}
/* Reset max-width for specific elements that should maintain aspect ratio */
img, video, iframe, svg {
max-width: 100%;
height: auto;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
font-size: 16px;
@ -170,6 +182,70 @@ header { @@ -170,6 +182,70 @@ header {
flex: 1;
}
.wiki-layout {
/* Three-column layout for wiki pages: wiki-sidebar | main-content | feed-sidebar */
}
.wiki-sidebar {
width: 250px;
flex-shrink: 0;
}
.wiki-nav {
background: var(--bg-secondary);
padding: 1.5rem;
border-radius: 8px;
border: 1px solid var(--border-color);
position: sticky;
top: 2rem;
}
.wiki-nav h2 {
font-size: 1.25rem;
margin-bottom: 1rem;
color: var(--text-primary);
}
.wiki-menu {
list-style: none;
padding: 0;
margin: 0;
}
.wiki-menu li {
margin-bottom: 0.5rem;
}
.wiki-menu a {
display: block;
padding: 0.5rem 0.75rem;
color: var(--link-color);
text-decoration: none;
border-radius: 4px;
transition: background 0.2s, color 0.2s;
}
.wiki-menu a:hover {
background: var(--bg-primary);
color: var(--link-hover);
}
.wiki-menu a:focus {
outline: 2px solid var(--focus-color);
outline-offset: 2px;
}
.wiki-menu a.active {
background: var(--accent-color);
color: var(--bg-primary);
font-weight: 500;
}
.wiki-menu a.active:hover {
background: var(--link-hover);
color: var(--bg-primary);
}
.main-content {
flex: 1;
min-width: 0;
@ -203,6 +279,17 @@ a { @@ -203,6 +279,17 @@ a {
transition: color 0.2s;
}
/* Long URLs and addresses should break */
a[href^="http"], a[href^="nostr:"], .nostr-address {
word-break: break-all;
overflow-wrap: anywhere;
}
code, pre {
word-break: break-all;
overflow-wrap: anywhere;
}
a:hover {
color: var(--link-hover);
}
@ -304,37 +391,70 @@ article { @@ -304,37 +391,70 @@ article {
.feed-container h3 {
margin-bottom: 1rem;
font-size: 1.25rem;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.5rem;
}
.feed-item {
padding: 1rem 0;
border-bottom: 1px solid var(--border-color);
overflow-wrap: break-word;
word-wrap: break-word;
}
.feed-item:last-child {
border-bottom: none;
}
.feed-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
flex-wrap: wrap;
gap: 0.5rem;
}
.feed-author {
font-weight: 600;
color: var(--text-primary);
margin-bottom: 0.5rem;
word-break: break-all;
overflow-wrap: anywhere;
max-width: 100%;
}
.feed-content {
color: var(--text-secondary);
margin-bottom: 0.5rem;
font-size: 0.9rem;
overflow-wrap: break-word;
word-wrap: break-word;
}
.feed-time {
color: var(--text-secondary);
font-size: 0.85rem;
margin-bottom: 0.5rem;
white-space: nowrap;
}
.feed-footer {
margin-top: 0.5rem;
}
.feed-link {
font-size: 0.85rem;
word-break: break-all;
overflow-wrap: anywhere;
display: inline-block;
max-width: 100%;
}
.feed-link-header {
font-size: 0.875rem;
font-weight: normal;
margin-left: 0.5rem;
}
.feed-empty {
@ -372,50 +492,194 @@ footer { @@ -372,50 +492,194 @@ footer {
}
/* Blog */
.blog-nav {
/* Blog Layout - Two Column Alexandria Style */
.blog-layout {
display: flex;
gap: 2rem;
max-width: 1400px;
margin: 0 auto;
padding: 2rem;
min-height: calc(100vh - 200px);
}
.blog-sidebar {
width: 350px;
flex-shrink: 0;
background-color: var(--bg-secondary);
border-radius: 8px;
padding: 2rem;
position: sticky;
top: 2rem;
max-height: calc(100vh - 4rem);
overflow-y: auto;
}
.blog-header {
margin-bottom: 2rem;
padding-bottom: 2rem;
border-bottom: 1px solid var(--border-color);
}
.article-menu {
list-style: none;
.blog-author-handle {
font-size: 0.9em;
color: var(--text-secondary);
margin-bottom: 1rem;
font-family: monospace;
}
.article-menu li {
.blog-image {
margin-bottom: 1rem;
padding: 1rem;
background: var(--bg-secondary);
}
.blog-image img {
width: 100%;
max-width: 200px;
height: auto;
border-radius: 4px;
border: 1px solid var(--border-color);
object-fit: cover;
}
.article-menu a {
.blog-title {
font-size: 1.8rem;
margin: 0 0 0.5rem 0;
color: var(--text-primary);
}
.blog-byline {
color: var(--text-secondary);
font-size: 0.95em;
margin: 0 0 1rem 0;
}
.blog-description {
color: var(--text-secondary);
line-height: 1.6;
margin: 0 0 1rem 0;
}
.blog-tags {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 1rem;
}
.blog-tags .tag {
display: inline-block;
padding: 0.25rem 0.75rem;
background-color: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 12px;
font-size: 0.85em;
color: var(--text-secondary);
}
.blog-nav {
margin-top: 1rem;
}
.article-menu {
list-style: none;
padding: 0;
margin: 0;
}
.article-menu li {
margin-bottom: 0.5rem;
}
.article-link {
display: block;
padding: 1rem;
text-decoration: none;
border-radius: 6px;
transition: background-color 0.2s;
border: 1px solid transparent;
}
.article-link:hover {
background-color: var(--bg-primary);
border-color: var(--border-color);
}
.article-menu h3 {
.article-link[data-active="true"] {
background-color: var(--bg-primary);
border-color: var(--accent-color);
}
.article-link-title {
font-weight: 600;
color: var(--text-primary);
margin-bottom: 0.5rem;
font-size: 1em;
}
.article-menu p {
.article-link-meta {
display: flex;
flex-direction: column;
gap: 0.25rem;
font-size: 0.85em;
color: var(--text-secondary);
margin: 0;
}
.article-date {
color: var(--text-secondary);
}
.article-author {
font-family: monospace;
font-size: 0.9em;
color: var(--text-secondary);
opacity: 0.8;
}
/* Blog Content Area */
.blog-content {
flex: 1;
min-width: 0;
background-color: var(--bg-secondary);
border-radius: 8px;
padding: 3rem;
position: relative;
}
.blog-article {
margin-bottom: 3rem;
padding-bottom: 2rem;
display: none;
}
.blog-article.active {
display: block;
}
.article-header {
margin-bottom: 2rem;
padding-bottom: 1.5rem;
border-bottom: 1px solid var(--border-color);
}
.blog-article:last-child {
border-bottom: none;
.article-title {
font-size: 2.5rem;
margin: 0 0 0.5rem 0;
color: var(--text-primary);
line-height: 1.2;
}
.article-subtitle {
color: var(--text-secondary);
font-size: 0.95em;
margin: 0;
font-style: italic;
}
.article-summary {
color: var(--text-secondary);
font-size: 1.1rem;
margin-bottom: 1rem;
font-size: 1.1em;
line-height: 1.6;
margin: 1.5rem 0;
padding: 1rem;
background-color: var(--bg-primary);
border-left: 3px solid var(--accent-color);
border-radius: 4px;
}
/* Code blocks */
@ -609,3 +873,91 @@ textarea:focus { @@ -609,3 +873,91 @@ textarea:focus {
font-size: 0.875rem;
opacity: 0.8;
}
/* E-Books page styles */
.ebooks-page {
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
}
.ebooks-container {
margin-top: 2rem;
}
.ebooks-table {
width: 100%;
border-collapse: collapse;
background-color: var(--bg-secondary);
border-radius: 8px;
overflow: hidden;
}
.ebooks-table thead {
background-color: var(--bg-primary);
}
.ebooks-table th {
padding: 1rem;
text-align: left;
font-weight: 600;
color: var(--text-primary);
border-bottom: 2px solid var(--border-color);
}
.ebooks-table th.sortable {
cursor: pointer;
user-select: none;
position: relative;
}
.ebooks-table th.sortable:hover {
background-color: var(--bg-secondary);
}
.ebooks-table th .sort-indicator {
margin-left: 0.5rem;
font-size: 0.9em;
opacity: 0.6;
}
.ebooks-table tbody tr {
border-bottom: 1px solid var(--border-color);
transition: background-color 0.2s;
}
.ebooks-table tbody tr:hover {
background-color: var(--bg-primary);
}
.ebooks-table td {
padding: 1rem;
color: var(--text-primary);
vertical-align: top;
}
.ebooks-table td code.pubkey {
font-size: 0.85em;
background-color: var(--bg-primary);
padding: 0.25rem 0.5rem;
border-radius: 4px;
word-break: break-all;
}
.ebooks-table td .text-muted {
color: var(--text-secondary);
font-size: 0.9em;
margin-top: 0.5rem;
display: block;
}
.ebooks-table td .btn-sm {
padding: 0.5rem 1rem;
font-size: 0.9em;
}
.ebooks-table td .text-center {
text-align: center;
color: var(--text-secondary);
font-style: italic;
}

16
static/css/responsive.css

@ -6,13 +6,25 @@ @@ -6,13 +6,25 @@
display: none;
}
.wiki-sidebar {
display: none;
}
.layout-container {
flex-direction: column;
padding: 1rem;
gap: 1rem;
}
.main-content {
width: 100%;
min-width: 0;
}
/* Ensure no overflow on mobile */
article, .feed-container, .nav-container {
overflow-x: hidden;
word-wrap: break-word;
}
.mobile-menu-toggle {
@ -75,6 +87,10 @@ @@ -75,6 +87,10 @@
width: 250px;
}
.wiki-sidebar {
width: 200px;
}
.layout-container {
padding: 1.5rem;
}

32
templates/404.html

@ -1,29 +1,9 @@ @@ -1,29 +1,9 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>404 - Page Not Found - {{.SiteName}}</title>
<link rel="icon" type="image/svg+xml" href="/static/GitCitadel_Icon_Gradient.svg">
<link rel="stylesheet" href="/static/css/main.css">
<link rel="stylesheet" href="/static/css/responsive.css">
</head>
<body>
<header>
<nav class="navbar">
<div class="nav-container">
<a href="/" class="logo">
<img src="/static/GitCitadel_Icon_Gradient.svg" alt="GitCitadel Logo" class="logo-icon">
<span class="logo-text">{{.SiteName}}</span>
</a>
</div>
</nav>
</header>
<main class="error-page">
{{define "content"}}
<article class="error-page">
<h1>404</h1>
<p>The page you're looking for doesn't exist.</p>
<a href="/" class="btn">Go Home</a>
</main>
</body>
</html>
</article>
{{end}}
{{/* Feed is defined in components.html */}}

32
templates/500.html

@ -1,29 +1,9 @@ @@ -1,29 +1,9 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>500 - Server Error - {{.SiteName}}</title>
<link rel="icon" type="image/svg+xml" href="/static/GitCitadel_Icon_Gradient.svg">
<link rel="stylesheet" href="/static/css/main.css">
<link rel="stylesheet" href="/static/css/responsive.css">
</head>
<body>
<header>
<nav class="navbar">
<div class="nav-container">
<a href="/" class="logo">
<img src="/static/GitCitadel_Icon_Gradient.svg" alt="GitCitadel Logo" class="logo-icon">
<span class="logo-text">{{.SiteName}}</span>
</a>
</div>
</nav>
</header>
<main class="error-page">
{{define "content"}}
<article class="error-page">
<h1>500</h1>
<p>Something went wrong on our end. Please try again later.</p>
<a href="/" class="btn">Go Home</a>
</main>
</body>
</html>
</article>
{{end}}
{{/* Feed is defined in components.html */}}

29
templates/base.html

@ -48,9 +48,7 @@ @@ -48,9 +48,7 @@
<ul class="nav-menu">
<li><a href="/">Home</a></li>
{{range .WikiPages}}
<li><a href="/wiki/{{.DTag}}">{{.Title}}</a></li>
{{end}}
<li><a href="/wiki">About The Project</a></li>
<li class="dropdown">
<a href="/blog" class="dropdown-toggle">Blog</a>
<ul class="dropdown-menu">
@ -59,19 +57,34 @@ @@ -59,19 +57,34 @@
{{end}}
</ul>
</li>
<li><a href="/ebooks">E-Books</a></li>
<li><a href="/contact">Contact</a></li>
</ul>
</div>
</nav>
</header>
<div class="layout-container">
<div class="layout-container{{if .WikiPages}} wiki-layout{{end}}">
{{if .WikiPages}}
<aside class="wiki-sidebar" aria-label="About The Project navigation">
<nav class="wiki-nav">
<h2>About The Project</h2>
<ul class="wiki-menu">
<li><a href="/wiki"{{if eq .CanonicalURL (printf "%s/wiki" .SiteURL)}} class="active"{{end}}>Project Overview</a></li>
{{range .WikiPages}}
<li><a href="/wiki/{{.DTag}}"{{if eq $.CanonicalURL (printf "%s/wiki/%s" $.SiteURL .DTag)}} class="active"{{end}}>{{.Title}}</a></li>
{{end}}
</ul>
</nav>
</aside>
{{end}}
<main id="main-content" class="main-content">
{{block "content" .}}{{end}}
</main>
<aside class="feed-sidebar" aria-label="Recent notes">
{{block "feed" .}}{{end}}
{{template "feed" .}}
</aside>
</div>
@ -80,13 +93,15 @@ @@ -80,13 +93,15 @@
</footer>
<script>
// Mobile menu toggle
document.querySelector('.mobile-menu-toggle')?.addEventListener('click', function() {
// Mobile menu toggle (optional - menu works without JS)
if (document.querySelector('.mobile-menu-toggle')) {
document.querySelector('.mobile-menu-toggle').addEventListener('click', function() {
const menu = document.querySelector('.nav-menu');
const isExpanded = this.getAttribute('aria-expanded') === 'true';
this.setAttribute('aria-expanded', !isExpanded);
menu.classList.toggle('active');
});
}
</script>
</body>
</html>

159
templates/blog.html

@ -1,50 +1,143 @@ @@ -1,50 +1,143 @@
{{define "content"}}
<article class="blog-page">
<header class="page-header">
<h1>Blog</h1>
{{if .BlogSummary}}<p class="page-summary">{{.BlogSummary}}</p>{{end}}
</header>
<div class="blog-layout">
<!-- Left Sidebar -->
<aside class="blog-sidebar">
<div class="blog-header">
{{if .BlogIndexAuthor}}
<div class="blog-author-handle">@{{.BlogIndexAuthor}}</div>
{{end}}
{{if .BlogIndexImage}}
<div class="blog-image">
<img src="{{.BlogIndexImage}}" alt="{{.BlogIndexTitle}}" />
</div>
{{end}}
<h1 class="blog-title">{{if .BlogIndexTitle}}{{.BlogIndexTitle}}{{else}}Blog{{end}}</h1>
{{if .BlogIndexAuthor}}
<p class="blog-byline">by {{.BlogIndexAuthor}}</p>
{{end}}
{{if .BlogIndexSummary}}
<p class="blog-description">{{.BlogIndexSummary}}</p>
{{end}}
<div class="blog-tags">
<span class="tag">#company</span>
<span class="tag">#GitCitadel</span>
</div>
</div>
<nav class="blog-nav" aria-label="Blog articles">
<h2>Articles</h2>
<ul class="article-menu">
{{range .BlogItems}}
{{range $index, $item := .BlogItems}}
<li>
<a href="/blog#{{.DTag}}" id="{{.DTag}}">
<h3>{{.Title}}</h3>
{{if .Summary}}<p>{{.Summary}}</p>{{end}}
<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">{{$item.Title}}</div>
{{if $item.Time}}
<div class="article-link-meta">
<span class="article-date">{{$item.Time}}</span>
{{if $item.Author}}
<span class="article-author">{{$item.Author}}</span>
{{end}}
</div>
{{end}}
</a>
</li>
{{end}}
</ul>
</nav>
</aside>
{{range .BlogItems}}
<section class="blog-article" id="article-{{.DTag}}">
<h2>{{.Title}}</h2>
{{if .Summary}}<p class="article-summary">{{.Summary}}</p>{{end}}
<!-- Right Content Pane -->
<main class="blog-content">
{{range $index, $item := .BlogItems}}
<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">This entry originally appeared in this blog.</p>
</header>
{{if $item.Summary}}<p class="article-summary">{{$item.Summary}}</p>{{end}}
<div class="article-content">
{{.Content}}
</div>
</section>
{{end}}
</article>
{{end}}
{{define "feed"}}
<div class="feed-container">
<h3>Recent Notes</h3>
<div class="feed-items">
{{range .FeedItems}}
<div class="feed-item">
<div class="feed-author">{{.Author}}</div>
<div class="feed-content">{{.Content}}</div>
<div class="feed-time">{{.Time}}</div>
<a href="{{.Link}}" class="feed-link">View on Alexandria</a>
{{$item.Content}}
</div>
</article>
{{else}}
<p class="feed-empty">No recent notes available.</p>
{{end}}
<article class="blog-article active">
<header class="article-header">
<h1 class="article-title">No Articles</h1>
</header>
<div class="article-content">
<p>No blog 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 */}}

21
templates/feed_sidebar.html → templates/components.html

@ -1,5 +1,7 @@ @@ -1,5 +1,7 @@
<div class="feed-sidebar">
<h3>Recent Notes</h3>
{{/* Feed Component - Reusable feed sidebar */}}
{{define "feed"}}
<div class="feed-container">
<h3>Recent Notes <a href="https://aitherboard.imwald.eu/feed/relay/theforest.nostr1.com" target="_blank" rel="noopener noreferrer" class="feed-link-header">View Full Feed</a></h3>
<div class="feed-items">
{{range .FeedItems}}
<article class="feed-item">
@ -17,3 +19,18 @@ @@ -17,3 +19,18 @@
{{end}}
</div>
</div>
{{end}}
{{/* Alert Component - Success message */}}
{{define "alert-success"}}
<div class="alert alert-success" role="alert">
<strong>Success!</strong> {{.}}
</div>
{{end}}
{{/* Alert Component - Error message */}}
{{define "alert-error"}}
<div class="alert alert-error" role="alert">
<strong>Error:</strong> {{.}}
</div>
{{end}}

153
templates/contact.html

@ -13,12 +13,12 @@ @@ -13,12 +13,12 @@
{{end}}
{{if .Error}}
<div class="alert alert-error" role="alert">
<strong>Error:</strong> {{.Error}}
</div>
{{template "alert-error" .Error}}
{{end}}
<form method="POST" action="/contact" class="contact-form">
<form id="contact-form" method="POST" action="/contact" class="contact-form">
<div id="nostr-status" class="alert" style="display: none;"></div>
<div class="form-group">
<label for="subject">Subject <span class="required">*</span></label>
<input type="text" id="subject" name="subject" required
@ -44,9 +44,152 @@ @@ -44,9 +44,152 @@
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">Submit</button>
<button type="submit" id="submit-btn" class="btn btn-primary">Submit</button>
<button type="reset" class="btn btn-secondary">Clear</button>
</div>
</form>
{{if .RepoAnnouncement}}
<script type="application/json" id="repo-announcement-data">{{json .RepoAnnouncement}}</script>
{{end}}
<script>
(function() {
const form = document.getElementById('contact-form');
const submitBtn = document.getElementById('submit-btn');
const statusDiv = document.getElementById('nostr-status');
// Get repo announcement data from JSON script tag
let repoAnnouncement = null;
const repoDataEl = document.getElementById('repo-announcement-data');
if (repoDataEl) {
try {
repoAnnouncement = JSON.parse(repoDataEl.textContent);
} catch (e) {
console.error('Failed to parse repo announcement data:', e);
}
}
function showStatus(message, isError) {
statusDiv.textContent = message;
statusDiv.className = 'alert ' + (isError ? 'alert-error' : 'alert-success');
statusDiv.style.display = 'block';
}
function hideStatus() {
statusDiv.style.display = 'none';
}
form.addEventListener('submit', async function(e) {
e.preventDefault();
// Check if Nostr extension is available
if (!window.nostr) {
showStatus('Nostr extension not found. Please install a Nostr browser extension (e.g., nos2x, Alby) to submit issues.', true);
return;
}
if (!repoAnnouncement) {
showStatus('Repository configuration not available. Please try again later.', true);
return;
}
const subject = document.getElementById('subject').value.trim();
const content = document.getElementById('content').value.trim();
const labelsStr = document.getElementById('labels').value.trim();
if (!subject || !content) {
showStatus('Subject and message are required.', true);
return;
}
// Disable submit button
submitBtn.disabled = true;
submitBtn.textContent = 'Signing...';
hideStatus();
try {
// Get user's public key
const pubkey = await window.nostr.getPublicKey();
// Parse labels
const labels = labelsStr ? labelsStr.split(',').map(l => l.trim()).filter(l => l) : [];
// Build event tags
const tags = [];
// Add 'a' tag for repository announcement
tags.push(['a', `30617:${repoAnnouncement.pubkey}:${repoAnnouncement.dTag}`]);
// Add 'p' tag for repository owner
tags.push(['p', repoAnnouncement.pubkey]);
// Add maintainers as 'p' tags
if (repoAnnouncement.maintainers && repoAnnouncement.maintainers.length > 0) {
repoAnnouncement.maintainers.forEach(maintainer => {
tags.push(['p', maintainer]);
});
}
// Add subject tag
if (subject) {
tags.push(['subject', subject]);
}
// Add label tags
labels.forEach(label => {
if (label) {
tags.push(['t', label]);
}
});
// Add relays tag if available
if (repoAnnouncement.relays && repoAnnouncement.relays.length > 0) {
tags.push(['relays', ...repoAnnouncement.relays]);
}
// Create unsigned event
const unsignedEvent = {
kind: 1621,
pubkey: pubkey,
created_at: Math.floor(Date.now() / 1000),
tags: tags,
content: content
};
// Sign the event
submitBtn.textContent = 'Publishing...';
const signedEvent = await window.nostr.signEvent(unsignedEvent);
// Send to API
const response = await fetch('/api/contact', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ event: signedEvent })
});
const result = await response.json();
if (response.ok && result.success) {
// Redirect to success page
window.location.href = '/contact?success=true&event_id=' + result.event_id;
} else {
showStatus('Failed to publish issue: ' + (result.error || 'Unknown error'), true);
submitBtn.disabled = false;
submitBtn.textContent = 'Submit';
}
} catch (error) {
console.error('Error:', error);
showStatus('Error: ' + error.message, true);
submitBtn.disabled = false;
submitBtn.textContent = 'Submit';
}
});
})();
</script>
</article>
{{end}}
{{/* Feed is defined in components.html */}}

107
templates/ebooks.html

@ -0,0 +1,107 @@ @@ -0,0 +1,107 @@
{{define "content"}}
<article class="ebooks-page">
<header class="page-header">
<h1>E-Books</h1>
<p class="page-summary">Top-level publications (index events) from Nostr. These are publications that are not contained in any higher-level index event.</p>
</header>
<div class="ebooks-container">
<table id="ebooks-table" class="ebooks-table" aria-label="E-Books listing">
<thead>
<tr>
<th data-sort="title" class="sortable">Title <span class="sort-indicator"></span></th>
<th data-sort="author" class="sortable">Author <span class="sort-indicator"></span></th>
<th data-sort="type" class="sortable">Type <span class="sort-indicator"></span></th>
<th data-sort="created" class="sortable">Created <span class="sort-indicator"></span></th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{{range .EBooks}}
<tr>
<td>
<strong>{{.Title}}</strong>
{{if .Summary}}<br><small class="text-muted">{{.Summary}}</small>{{end}}
</td>
<td><code class="pubkey">{{.Author}}</code></td>
<td>{{if .Type}}{{.Type}}{{else}}—{{end}}</td>
<td>
<time datetime="{{.TimeISO}}">{{.Time}}</time>
</td>
<td>
<a href="https://alexandria.gitcitadel.eu/naddr/{{.Naddr}}" target="_blank" rel="noopener noreferrer" class="btn btn-sm">View</a>
</td>
</tr>
{{else}}
<tr>
<td colspan="5" class="text-center">No e-books found.</td>
</tr>
{{end}}
</tbody>
</table>
</div>
</article>
<script>
(function() {
const table = document.getElementById('ebooks-table');
if (!table) return;
const tbody = table.querySelector('tbody');
const headers = table.querySelectorAll('th.sortable');
let currentSort = { column: null, direction: 'asc' };
headers.forEach(header => {
header.addEventListener('click', function() {
const column = this.dataset.sort;
const direction = currentSort.column === column && currentSort.direction === 'asc' ? 'desc' : 'asc';
// Update sort indicators
headers.forEach(h => {
const indicator = h.querySelector('.sort-indicator');
if (h === this) {
indicator.textContent = direction === 'asc' ? '↑' : '↓';
} else {
indicator.textContent = '↕';
}
});
// Sort rows
const rows = Array.from(tbody.querySelectorAll('tr'));
rows.sort((a, b) => {
let aVal, bVal;
const aCell = a.cells[Array.from(headers).indexOf(this)];
const bCell = b.cells[Array.from(headers).indexOf(this)];
if (column === 'title') {
aVal = aCell.querySelector('strong')?.textContent || '';
bVal = bCell.querySelector('strong')?.textContent || '';
} else if (column === 'created') {
aVal = aCell.querySelector('time')?.getAttribute('datetime') || '';
bVal = bCell.querySelector('time')?.getAttribute('datetime') || '';
} else {
aVal = aCell.textContent.trim();
bVal = bCell.textContent.trim();
}
if (direction === 'asc') {
return aVal.localeCompare(bVal);
} else {
return bVal.localeCompare(aVal);
}
});
// Re-append sorted rows
rows.forEach(row => tbody.appendChild(row));
currentSort = { column, direction };
});
// Add cursor pointer style
header.style.cursor = 'pointer';
});
})();
</script>
{{end}}
{{/* Feed is defined in components.html */}}

18
templates/landing.html

@ -28,20 +28,4 @@ @@ -28,20 +28,4 @@
</article>
{{end}}
{{define "feed"}}
<div class="feed-container">
<h3>Recent Notes</h3>
<div class="feed-items">
{{range .FeedItems}}
<div class="feed-item">
<div class="feed-author">{{.Author}}</div>
<div class="feed-content">{{.Content}}</div>
<div class="feed-time">{{.Time}}</div>
<a href="{{.Link}}" class="feed-link">View on Alexandria</a>
</div>
{{else}}
<p class="feed-empty">No recent notes available.</p>
{{end}}
</div>
</div>
{{end}}
{{/* Feed is defined in components.html */}}

18
templates/page.html

@ -26,20 +26,4 @@ @@ -26,20 +26,4 @@
</article>
{{end}}
{{define "feed"}}
<div class="feed-container">
<h3>Recent Notes</h3>
<div class="feed-items">
{{range .FeedItems}}
<div class="feed-item">
<div class="feed-author">{{.Author}}</div>
<div class="feed-content">{{.Content}}</div>
<div class="feed-time">{{.Time}}</div>
<a href="{{.Link}}" class="feed-link">View on Alexandria</a>
</div>
{{else}}
<p class="feed-empty">No recent notes available.</p>
{{end}}
</div>
</div>
{{end}}
{{/* Feed is defined in components.html */}}

18
templates/wiki.html

@ -0,0 +1,18 @@ @@ -0,0 +1,18 @@
{{define "content"}}
<article class="wiki-index-page">
<header class="page-header">
<h1>Wiki</h1>
{{if .Summary}}<p class="page-summary">{{.Summary}}</p>{{end}}
</header>
<div class="wiki-index-content">
{{if .WikiPages}}
<p>Select an article from the sidebar to get started.</p>
{{else}}
<p>No wiki articles available yet.</p>
{{end}}
</div>
</article>
{{end}}
{{/* Feed is defined in components.html */}}
Loading…
Cancel
Save