move webserver files to ./web/

This commit is contained in:
2025-03-26 16:32:57 +01:00
parent f312489aa6
commit 0357c4f4b2
30 changed files with 10 additions and 10 deletions

41
src/web/app/app.go Normal file
View File

@@ -0,0 +1,41 @@
package app
import (
"crowsnest/internal/model/database"
"database/sql"
"net/http"
)
type App struct {
articles *database.ArticleRepository
articleVMs *database.ArticleViewModelRepository
articlePageVMs *database.ArticlePageViewModelRepository
rssItems *database.RSSItemRepository
}
func NewApp(db *sql.DB) *App {
return &App{
articles: &database.ArticleRepository{DB: db},
articleVMs: &database.ArticleViewModelRepository{DB: db},
articlePageVMs: &database.ArticlePageViewModelRepository{DB: db},
rssItems: &database.RSSItemRepository{DB: db},
}
}
func (app *App) Routes() http.Handler {
mux := http.NewServeMux()
// dynamic routes
mux.Handle("GET /rss.xml", http.HandlerFunc(app.RSS))
mux.Handle("GET /", http.HandlerFunc(app.Index))
mux.Handle("GET /page/{id}", http.HandlerFunc(app.Index))
mux.Handle("POST /up/search", http.HandlerFunc(app.UpSearch))
mux.Handle("GET /article/{id}", http.HandlerFunc(app.Article))
// serve files from the "static" directory
mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.Dir("./assets/static"))))
return mux
}

33
src/web/app/article.go Normal file
View File

@@ -0,0 +1,33 @@
package app
import (
"crowsnest/web/html"
"net/http"
"strconv"
)
// Enpoint that returns a list of articles given search terms in the post
// request of a search form. Uses the content template.
func (app *App) Article(w http.ResponseWriter, req *http.Request) {
// get id
id, err := strconv.ParseUint(req.PathValue("id"), 10, 64)
if err != nil {
http.NotFound(w, req)
return
}
// get articles
articlePageVM, err := app.articlePageVMs.ById(int64(id))
if err != nil {
// treat as no result
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// render page
err = html.ArticleLayout(articlePageVM).Render(w)
if err != nil {
http.Error(w, "Failed to render template", http.StatusInternalServerError)
return
}
}

42
src/web/app/index.go Normal file
View File

@@ -0,0 +1,42 @@
package app
import (
"crowsnest/web/html"
"net/http"
"strconv"
)
// List the latest articles using the base template.
func (app *App) Index(w http.ResponseWriter, req *http.Request) {
const pageSize = 15
var offset, pageId uint64 = 0, 0
var err error
// get page number
if pageId, err = strconv.ParseUint(req.PathValue("id"), 10, 32); err == nil {
pageId--
offset = pageId * pageSize
}
// get articles
articleVMs, err := app.articleVMs.All(pageSize, int(offset))
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// get count of total articles
totalCount, err := app.articles.CountAll()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
totalCount /= pageSize
// render page
err = html.IndexLayout(articleVMs, uint(pageId+1), totalCount+1).Render(w)
if err != nil {
http.Error(w, "Failed to render template", http.StatusInternalServerError)
return
}
}

28
src/web/app/rss.go Normal file
View File

@@ -0,0 +1,28 @@
package app
import (
"crowsnest/internal/model"
"encoding/xml"
"net/http"
)
// List the latest articles using the base template.
func (app *App) RSS(w http.ResponseWriter, req *http.Request) {
// set response headers
w.Header().Set("Content-Type", "application/rss+xml")
w.WriteHeader(http.StatusOK)
// get articles
feed, err := app.rssItems.All(30)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// write RSS feed to response
encoder := xml.NewEncoder(w)
encoder.Indent("", " ")
if err := encoder.Encode(model.RSSFeedFromItems(feed)); err != nil {
http.Error(w, "Error generating RSS feed", http.StatusInternalServerError)
}
}

32
src/web/app/upsearch.go Normal file
View File

@@ -0,0 +1,32 @@
package app
import (
"crowsnest/web/html"
"net/http"
)
// Enpoint that returns a list of articles given search terms in the post
// request of a search form. Uses the content template.
func (app *App) UpSearch(w http.ResponseWriter, req *http.Request) {
// construct search query
searchTerms := req.FormValue("search")
if searchTerms == "" {
app.Index(w, req)
return
}
// get articles
articleVMs, err := app.articleVMs.Search(searchTerms)
if err != nil {
// treat as no result
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// render page
err = html.IndexLayout(articleVMs, 0, 0).Render(w)
if err != nil {
http.Error(w, "Failed to render template", http.StatusInternalServerError)
return
}
}

39
src/web/html/article.go Normal file
View File

@@ -0,0 +1,39 @@
package html
import (
"crowsnest/internal/model"
"strconv"
g "maragu.dev/gomponents"
h "maragu.dev/gomponents/html"
)
func Articles(articles []*model.ArticleViewModel) g.Node {
return h.Div(h.Class("content max-w-screen-lg flex flex-col mx-auto"), g.Map(articles, Article))
}
func Article(article *model.ArticleViewModel) g.Node {
return h.Div(
h.Class("collapse bg-base-200 shadow mb-4"),
h.TabIndex("0"),
h.Div(h.Class("collapse-title font-medium"), g.Text(article.Title)),
h.Div(
h.Class("collapse-content"),
h.P(
h.Class("pb-2"),
h.Span(h.Class("badge badge-outline"), g.Text(article.ShortSource)),
h.Span(h.Class("badge badge-outline"), g.Text(article.PublishDate)),
),
h.P(h.Class("card-text"), g.Text(article.Summary)),
h.Div(
h.Class("flex flex-row-reverse"),
h.A(
h.Href("/article/"+strconv.Itoa(article.Id)),
h.Class("btn btn-active btn-sm btn-primary"),
g.Attr("up-follow"),
g.Text("Details"),
),
),
),
)
}

View File

@@ -0,0 +1,14 @@
package html
import (
"crowsnest/internal/model"
g "maragu.dev/gomponents"
)
func ArticleLayout(articlePageVMs *model.ArticlePageViewModel) g.Node {
return Layout(
"Crowsnest - "+articlePageVMs.Title,
ArticlePage(articlePageVMs),
)
}

View File

@@ -0,0 +1,55 @@
package html
import (
"crowsnest/internal/model"
g "maragu.dev/gomponents"
h "maragu.dev/gomponents/html"
)
func ArticlePageColumn(key string, value string) g.Node {
return h.P(
h.Span(
h.Class("badge badge-neutral me-4 w-20"),
g.Text(key),
),
g.Text(value),
)
}
func ArticlePage(articlePageVM *model.ArticlePageViewModel) g.Node {
return h.Div(
h.TabIndex("0"),
h.Class("card bg-base-200 shadow mb-4"),
h.Div(
h.Class("card-body"),
h.Div(
h.Class("flex flex-row pb-4"),
h.Div(
h.Class("divider divider-horizontal divider-primary"),
),
h.Div(
h.Class("card-title font-medium"),
g.Text(articlePageVM.Title),
),
),
h.Div(
h.Class("px-5 pb-4 grid gap-y-4 grid-cols-1"),
ArticlePageColumn("Datum", articlePageVM.PublishDate),
ArticlePageColumn("Quelle", articlePageVM.ShortSource),
ArticlePageColumn("TLDR", articlePageVM.Summary),
ArticlePageColumn("Inhalt", articlePageVM.Content),
h.Div(
h.Class("card-actions justify-end"),
h.A(
h.Href(articlePageVM.SourceUrl),
h.Button(
h.Class("btn btn-primary btn-sm"),
g.Text("Seite besuchen"),
),
),
),
),
),
)
}

View File

@@ -0,0 +1,17 @@
package html
import (
"crowsnest/internal/model"
g "maragu.dev/gomponents"
)
func IndexLayout(articleVMs []*model.ArticleViewModel, paginationCurrent uint, paginationTotal uint) g.Node {
return Layout(
"Crowsnest - Artikel",
g.Group{
Articles(articleVMs),
Pagination(paginationCurrent, paginationTotal),
},
)
}

43
src/web/html/layout.go Normal file
View File

@@ -0,0 +1,43 @@
package html
import (
g "maragu.dev/gomponents"
c "maragu.dev/gomponents/components"
h "maragu.dev/gomponents/html"
)
func Layout(title string, body g.Node) g.Node {
completeBody := []g.Node{
Navbar(),
h.Div(
h.Class("content container flex flex-col mx-auto px-4 mt-28"),
body,
),
h.Script(h.Src("https://cdn.tailwindcss.com")),
h.Script(
g.Text(
"up.link.config.followSelectors.push('a[href]')\n" +
"up.link.config.instantSelectors.push('a[href]')",
),
),
}
return c.HTML5(c.HTML5Props{
Title: title,
Language: "de",
Head: layoutHead(),
Body: completeBody,
HTMLAttrs: []g.Node{g.Attr("data-theme", "dark")},
})
}
func layoutHead() []g.Node {
return []g.Node{
h.Meta(h.Name("viewport"), h.Content("width=device-width, initial-scale=1")),
h.Link(h.Rel("stylesheet"), h.Type("text/css"), h.Href("/static/unpoly.min.css")),
h.Script(h.Src("/static/unpoly.min.js")),
h.Link(h.Rel("stylesheet"), h.Type("text/css"), h.Href("/static/daisyui.min.css")),
h.Script(h.Src("/static/tailwindcss.min.js")),
}
}

76
src/web/html/navbar.go Normal file
View File

@@ -0,0 +1,76 @@
package html
import (
g "maragu.dev/gomponents"
"maragu.dev/gomponents-heroicons/v3/outline"
h "maragu.dev/gomponents/html"
)
func NavbarLi(href string, text string, active bool) g.Node {
return h.Li(h.A(h.Href(href), h.TabIndex("0"), g.If(active, h.Class("active")), g.Text(text)))
}
func SearchForm() g.Node {
return h.Form(
h.Role("search"),
h.Method("post"),
h.Action("/up/search"),
g.Attr("up-submit"),
g.Attr("up-autosubmit"),
g.Attr("up-target", ".content"),
h.Label(
h.Class("input input-bordered input-sm flex items-center gap-2"),
h.Input(
h.Name("search"),
h.Type("search"),
h.Class("grow"),
h.Placeholder("Suche"),
),
outline.MagnifyingGlass(
h.Class("h-4 w-4 opacity-70"),
g.Attr("stroke", "currentColor"),
),
),
)
}
func Navbar() g.Node {
return h.Nav(
h.Class("fixed top-0 z-50 w-full p-4"),
h.Div(
h.Class("navbar bg-base-300 rounded-box drop-shadow-md"),
// links for large screens
h.Div(
h.Class("flex-1"),
h.A(h.Href("/"), h.TabIndex("0"), h.Class("btn btn-ghost text-xl"), g.Text("crowsnest")),
h.Ul(
h.Class("menu menu-horizontal hidden sm:flex"),
NavbarLi("/", "Artikel", true),
NavbarLi("/", "Themen", false),
),
),
// search
h.Div(h.Class("hidden sm:flex flex-none pe-4"), SearchForm()),
// small navbar dropdown
h.Div(
h.Class("dropdown dropdown-end sm:hidden"),
// dropdown button
h.Div(
h.TabIndex("0"),
h.Role("button"),
h.Class("btn btn-ghost"),
outline.Bars3BottomRight(
h.Class("h-6 w-6 opacity-70"),
g.Attr("stroke", "currentColor"),
),
),
// dropdown content
h.Ul(
h.Class("menu dropdown-content bg-base-100 rounded-box z-[1] mt-3 w-52 p-2 shadow"),
NavbarLi("/", "Artikel", true),
NavbarLi("/", "Themen", false),
),
),
),
)
}

View File

@@ -0,0 +1,45 @@
package html
import (
"strconv"
g "maragu.dev/gomponents"
h "maragu.dev/gomponents/html"
)
func PaginationButton(content string, active bool, disabled bool) g.Node {
classStr := "join-item btn btn-sm"
if disabled { classStr += " btn-disabled" }
if active { classStr += " btn-active" }
return h.A(
h.Class(classStr),
g.If(!disabled, h.Href("/page/"+content)),
g.If(!disabled, h.TabIndex("0")),
g.Attr("up-follow"),
g.Attr("up-target", ".content"),
g.Text(content),
)
}
func Pagination(currentPage uint, totalPages uint) g.Node {
buttons := make([]g.Node, 0)
if totalPages > 1 {
buttons = append(buttons, PaginationButton("1", currentPage == 1, false))
}
if currentPage > 3 {
buttons = append(buttons, PaginationButton("...", false, true))
}
for i := max(2, currentPage-1); i <= min(totalPages-1, currentPage+1); i++ {
buttons = append(buttons, PaginationButton(strconv.Itoa(int(i)), i == currentPage, false))
}
if currentPage < totalPages-2 {
buttons = append(buttons, PaginationButton("...", false, true))
}
if totalPages > 1 {
buttons = append(buttons, PaginationButton(strconv.Itoa(int(totalPages)), totalPages == currentPage, false))
}
return h.Div(h.Class("join pagination p-5 mx-auto"), h.Span(buttons...))
}

View File

@@ -0,0 +1,35 @@
package middleware
import (
"log"
"net/http"
"time"
)
type wrappedWriter struct {
http.ResponseWriter
statusCode int
}
func (w *wrappedWriter) WriteHeader(statusCode int) {
w.ResponseWriter.WriteHeader(statusCode)
w.statusCode = statusCode
}
// LoggingMiddleware logs details about each incoming HTTP request.
func Logging(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
wrapped := &wrappedWriter{
ResponseWriter: w,
statusCode: http.StatusOK,
}
// Call the next handler
next.ServeHTTP(wrapped, r)
log.Printf("[request] %d %s %s from %s (%v)",
wrapped.statusCode, r.Method, r.URL.Path, r.RemoteAddr, time.Since(start))
})
}

View File

@@ -0,0 +1,15 @@
package middleware
import "net/http"
type Middleware func(http.Handler) http.Handler
func CreateStack(xs ...Middleware) Middleware {
return func(next http.Handler) http.Handler {
for i := len(xs) - 1; i >= 0; i-- {
x := xs[i]
next = x(next)
}
return next
}
}

20
src/web/static/daisyui.min.css vendored Normal file

File diff suppressed because one or more lines are too long

83
src/web/static/tailwindcss.min.js vendored Normal file

File diff suppressed because one or more lines are too long

6
src/web/static/unpoly.min.css vendored Normal file
View File

@@ -0,0 +1,6 @@
[hidden][hidden]{display:none !important}
up-wrapper{display:inline-block}
up-bounds{position:absolute}.up-focus-hidden:focus-visible{outline-color:rgba(0,0,0,0) !important;outline-style:none !important}body.up-scrollbar-away{padding-right:calc(var(--up-scrollbar-width) + var(--up-original-padding-right)) !important}body.up-scrollbar-away,html:has(>body.up-scrollbar-away){overflow-y:hidden !important}body.up-scrollbar-away .up-scrollbar-away{right:calc(var(--up-scrollbar-width) + var(--up-original-right)) !important}
.up-request-loader{display:none}up-progress-bar{position:fixed;top:0;left:0;z-index:999999999;height:3px;background-color:#007bff}
up-focus-trap{position:fixed;top:0;left:0;width:0;height:0}up-cover-viewport,up-drawer-viewport,up-modal-viewport,up-drawer-backdrop,up-modal-backdrop,up-cover,up-drawer,up-modal{top:0;left:0;bottom:0;right:0}up-drawer-box,up-modal-box{box-shadow:0 0 10px 1px rgba(0,0,0,.3)}up-popup{box-shadow:0 0 4px rgba(0,0,0,.3)}up-popup:focus,up-cover-box:focus,up-drawer-box:focus,up-modal-box:focus,up-cover:focus,up-drawer:focus,up-modal:focus,up-popup:focus-visible,up-cover-box:focus-visible,up-drawer-box:focus-visible,up-modal-box:focus-visible,up-cover:focus-visible,up-drawer:focus-visible,up-modal:focus-visible{outline:none}up-cover,up-drawer,up-modal{z-index:2000;position:fixed}up-drawer-backdrop,up-modal-backdrop{position:absolute;background:rgba(0,0,0,.4)}up-cover-viewport,up-drawer-viewport,up-modal-viewport{position:absolute;overflow-y:scroll;overflow-x:hidden;overscroll-behavior:contain;display:flex;align-items:flex-start;justify-content:center}up-popup,up-cover-box,up-drawer-box,up-modal-box{position:relative;box-sizing:border-box;max-width:100%;background-color:#fff;padding:20px;overflow-x:hidden}up-popup-content,up-cover-content,up-drawer-content,up-modal-content{display:block}up-popup{z-index:1000}up-popup-dismiss,up-cover-dismiss,up-drawer-dismiss,up-modal-dismiss{color:#888;position:absolute;top:10px;right:10px;font-size:1.7rem;line-height:.5;cursor:pointer}up-modal[nesting="0"] up-modal-viewport{padding:25px 15px}up-modal[nesting="1"] up-modal-viewport{padding:50px 30px}up-modal[nesting="2"] up-modal-viewport{padding:75px 45px}up-modal[nesting="3"] up-modal-viewport{padding:100px 60px}up-modal[nesting="4"] up-modal-viewport{padding:125px 75px}up-modal[size=small] up-modal-box{width:350px}up-modal[size=medium] up-modal-box{width:650px}up-modal[size=large] up-modal-box{width:1000px}up-modal[size=grow] up-modal-box{width:auto}up-modal[size=full] up-modal-box{width:100%}up-drawer-viewport{justify-content:flex-start}up-drawer[position=right] up-drawer-viewport{justify-content:flex-end}up-drawer-box{min-height:100vh}up-drawer[size=small] up-drawer-box{width:150px}up-drawer[size=medium] up-drawer-box{width:340px}up-drawer[size=large] up-drawer-box{width:600px}up-drawer[size=grow] up-drawer-box{width:auto}up-drawer[size=full] up-drawer-box{width:100%}up-cover-box{width:100%;min-height:100vh;padding:0}up-popup{padding:15px;text-align:left}up-popup[size=small]{width:180px}up-popup[size=medium]{width:300px}up-popup[size=large]{width:550px}up-popup[size=grow] up-popup{width:auto}up-popup[size=full] up-popup{width:100%}
[up-clickable][role=link]{cursor:pointer}[up-expand]:not([role]),[up-expand][role=link]{cursor:pointer}

1
src/web/static/unpoly.min.js vendored Normal file

File diff suppressed because one or more lines are too long