Compare commits
3 Commits
afd8edaa6c
...
a1471fc310
| Author | SHA1 | Date | |
|---|---|---|---|
| a1471fc310 | |||
| eb2d05f082 | |||
| 92d272b36e |
55
Dockerfile
Normal file
55
Dockerfile
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
############################
|
||||||
|
# 1) Go‑Build
|
||||||
|
############################
|
||||||
|
FROM golang:1.22 AS build
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
COPY . .
|
||||||
|
RUN CGO_ENABLED=0 go build -o /blog ./cmd/blog
|
||||||
|
|
||||||
|
|
||||||
|
############################
|
||||||
|
# 2) Content‑Clone (Stage)
|
||||||
|
############################
|
||||||
|
FROM alpine/git AS content
|
||||||
|
|
||||||
|
# Parameterisierbar beim docker build --build-arg …
|
||||||
|
ARG CONTENT_REPO=https://github.com/username/blog-content.git
|
||||||
|
ARG CONTENT_REF=main
|
||||||
|
|
||||||
|
RUN git clone --depth 1 --branch ${CONTENT_REF} ${CONTENT_REPO} /src
|
||||||
|
|
||||||
|
# ─── Repack: bring alles in eine saubere Struktur ────────────────
|
||||||
|
# • Markdown‑Posts: /src/articles/*.md → /out/content
|
||||||
|
# • Bilder + CSS + JS: /src/web/static/**/* → /out/static
|
||||||
|
# (Pfad‑Anpassung hier nach DEINEM Repository‑Layout)
|
||||||
|
|
||||||
|
RUN mkdir -p /out/content /out/static
|
||||||
|
RUN find /src/articles -name '*.md' -exec cp {} /out/content/ \;
|
||||||
|
RUN cp -r /src/web/static/* /out/static/
|
||||||
|
|
||||||
|
|
||||||
|
############################
|
||||||
|
# 3) Runtime‑Image
|
||||||
|
############################
|
||||||
|
FROM debian:bookworm-slim
|
||||||
|
|
||||||
|
# (optional) MySQL‑Client für später
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
ca-certificates \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# ─── Binärdatei ─────
|
||||||
|
COPY --from=build /blog /usr/local/bin/blog
|
||||||
|
|
||||||
|
# ─── Content + Assets ───
|
||||||
|
COPY --from=content /out/content /content
|
||||||
|
COPY --from=content /out/static /static
|
||||||
|
|
||||||
|
ENV BLOG_CONTENT_DIR=/content
|
||||||
|
ENV BLOG_STATIC_DIR=/static
|
||||||
|
|
||||||
|
EXPOSE 8080
|
||||||
|
CMD ["blog"]
|
||||||
123
cmd/blog/main.go
Normal file
123
cmd/blog/main.go
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"html/template"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.send.nrw/sendnrw/b1tsblog/internal/article"
|
||||||
|
)
|
||||||
|
|
||||||
|
func getenv(k, d string) string {
|
||||||
|
if v := os.Getenv(k); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
func enabled(k string, def bool) bool {
|
||||||
|
b, err := strconv.ParseBool(strings.ToLower(os.Getenv(k)))
|
||||||
|
if err != nil {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
func cacheControl(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
tplList *template.Template
|
||||||
|
tplArticle *template.Template
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
|
||||||
|
funcs := template.FuncMap{
|
||||||
|
"now": time.Now, // jetzt‑Zeit bereitstellen
|
||||||
|
}
|
||||||
|
|
||||||
|
// Basislayout zuerst parsen
|
||||||
|
layout := template.Must(
|
||||||
|
template.New("base").Funcs(funcs).
|
||||||
|
ParseFiles("internal/web/templates/base.html"),
|
||||||
|
)
|
||||||
|
|
||||||
|
// LIST‑Seite: base + list.html
|
||||||
|
tplList = template.Must(layout.Clone())
|
||||||
|
template.Must(tplList.Funcs(funcs).ParseFiles("internal/web/templates/list.html"))
|
||||||
|
|
||||||
|
// ARTICLE‑Instanz
|
||||||
|
tplArticle = template.Must(layout.Clone())
|
||||||
|
template.Must(tplArticle.Funcs(funcs).ParseFiles("internal/web/templates/article.html"))
|
||||||
|
|
||||||
|
tplPage := template.Must(layout.Clone())
|
||||||
|
template.Must(tplPage.ParseFiles("internal/web/templates/page.html"))
|
||||||
|
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
|
||||||
|
staticDir := "internal/web/static"
|
||||||
|
|
||||||
|
articles, err := article.LoadDir("content")
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
staticPages, err := article.LoadStatic("pages")
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println(err)
|
||||||
|
} else {
|
||||||
|
fmt.Println(staticPages)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handler für /
|
||||||
|
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/" {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := tplList.ExecuteTemplate(w, "layout", articles); err != nil {
|
||||||
|
http.Error(w, err.Error(), 500)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
mux.HandleFunc("/post/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
slug := strings.TrimPrefix(r.URL.Path, "/post/")
|
||||||
|
for _, a := range articles {
|
||||||
|
if a.Slug == slug {
|
||||||
|
if err := tplArticle.ExecuteTemplate(w, "layout", a); err != nil {
|
||||||
|
http.Error(w, err.Error(), 500)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
http.NotFound(w, r)
|
||||||
|
})
|
||||||
|
|
||||||
|
mux.HandleFunc("/page/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
slug := strings.TrimPrefix(r.URL.Path, "/page/")
|
||||||
|
p, ok := staticPages[slug]
|
||||||
|
if !ok {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// "layout" kommt aus deinem Template‑Pool (list/article nutzen es ja auch)
|
||||||
|
if err := tplPage.ExecuteTemplate(w, "page", p); err != nil {
|
||||||
|
http.Error(w, err.Error(), 500)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
mux.Handle("/static/",
|
||||||
|
cacheControl(http.StripPrefix("/static/",
|
||||||
|
http.FileServer(http.Dir(staticDir)))))
|
||||||
|
|
||||||
|
http.ListenAndServe(":8080", mux)
|
||||||
|
}
|
||||||
3
content/2025/demo.md
Normal file
3
content/2025/demo.md
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<!--{"title": "ChatGPT + Go: So geht's", "date": "2025-05-04", "slug": "chatgpt-go", "cover": ""}-->
|
||||||
|
# Hello Gophers 🤖
|
||||||
|
*(Markdown‑Inhalt)*
|
||||||
5
content/2025/test - Kopie.md
Normal file
5
content/2025/test - Kopie.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<!--{"title": "Mein erster Markdown‑Posty", "date": "2025-05-04", "slug": "mein-erster-markdown-posty", "cover": "/static/img/placeholder.png"}-->
|
||||||
|
|
||||||
|
# Hello Markdown 🌟
|
||||||
|
|
||||||
|
Dies ist *kursiv*, **fett** und ein [Link](https://example.com).
|
||||||
5
content/2025/test.md
Normal file
5
content/2025/test.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<!--{"title": "Mein erster Markdown‑Post", "date": "2025-05-04", "slug": "mein-erster-markdown-post", "cover": "/static/img/placeholder.png"}-->
|
||||||
|
|
||||||
|
# Hello Markdown 🌟
|
||||||
|
|
||||||
|
Dies ist *kursiv*, **fett** und ein [Link](https://example.com).
|
||||||
5
go.mod
Normal file
5
go.mod
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
module git.send.nrw/sendnrw/b1tsblog
|
||||||
|
|
||||||
|
go 1.23.1
|
||||||
|
|
||||||
|
require github.com/gomarkdown/markdown v0.0.0-20250311123330-531bef5e742b
|
||||||
2
go.sum
Normal file
2
go.sum
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
github.com/gomarkdown/markdown v0.0.0-20250311123330-531bef5e742b h1:EY/KpStFl60qA17CptGXhwfZ+k1sFNJIUNR8DdbcuUk=
|
||||||
|
github.com/gomarkdown/markdown v0.0.0-20250311123330-531bef5e742b/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
|
||||||
194
internal/article/load.go
Normal file
194
internal/article/load.go
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
// internal/article/load.go
|
||||||
|
package article
|
||||||
|
|
||||||
|
// internal/article/load.go (gekürzt)
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"html/template"
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
md "github.com/gomarkdown/markdown"
|
||||||
|
"github.com/gomarkdown/markdown/html"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LoadDir liest alle *.html und *.md unter root/content,
|
||||||
|
// parst Front‑Matter (JSON‑Kommentar in der 1. Zeile) und liefert []Article.
|
||||||
|
func LoadDir(root string) ([]Article, error) {
|
||||||
|
var out []Article
|
||||||
|
seen := make(map[string]bool) // Duplikats‑Check
|
||||||
|
|
||||||
|
mdRenderer := html.NewRenderer(html.RendererOptions{
|
||||||
|
Flags: html.CommonFlags | html.HrefTargetBlank,
|
||||||
|
})
|
||||||
|
|
||||||
|
err := filepath.WalkDir(root, func(path string, d fs.DirEntry, walkErr error) error {
|
||||||
|
if walkErr != nil {
|
||||||
|
return walkErr
|
||||||
|
}
|
||||||
|
if d.IsDir() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
ext := strings.ToLower(filepath.Ext(path))
|
||||||
|
if ext != ".md" && ext != ".html" {
|
||||||
|
return nil // uninteressante Datei
|
||||||
|
}
|
||||||
|
|
||||||
|
raw, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read %s: %w", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
parts := bytes.SplitN(raw, []byte("\n"), 2)
|
||||||
|
if len(parts) < 2 {
|
||||||
|
return fmt.Errorf("%s: missing front‑matter", path)
|
||||||
|
}
|
||||||
|
|
||||||
|
headerJSON, err := extractJSONHeader(parts[0])
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("%s: %w", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var meta struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
Date string `json:"date"`
|
||||||
|
Slug string `json:"slug"`
|
||||||
|
Cover string `json:"cover"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(headerJSON, &meta); err != nil {
|
||||||
|
return fmt.Errorf("%s: %w", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback‑Slug aus Dateinamen
|
||||||
|
if meta.Slug == "" {
|
||||||
|
meta.Slug = strings.TrimSuffix(filepath.Base(path), ext)
|
||||||
|
}
|
||||||
|
if seen[meta.Slug] {
|
||||||
|
return fmt.Errorf("%s: duplicate slug %q", path, meta.Slug)
|
||||||
|
}
|
||||||
|
seen[meta.Slug] = true
|
||||||
|
|
||||||
|
body := parts[1]
|
||||||
|
if ext == ".md" {
|
||||||
|
body = md.ToHTML(body, nil, mdRenderer) // frischer Parser pro Call
|
||||||
|
}
|
||||||
|
|
||||||
|
date, err := time.Parse("2006-01-02", meta.Date)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("%s: parse date: %w", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
out = append(out, Article{
|
||||||
|
Title: meta.Title,
|
||||||
|
Slug: meta.Slug,
|
||||||
|
Date: date,
|
||||||
|
Cover: meta.Cover,
|
||||||
|
Body: template.HTML(body),
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Slice(out, func(i, j int) bool { return out[i].Date.After(out[j].Date) })
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- NEU ----------
|
||||||
|
type StaticPage struct {
|
||||||
|
Title string
|
||||||
|
Slug string
|
||||||
|
Body template.HTML
|
||||||
|
}
|
||||||
|
|
||||||
|
// helper.go – einmal zentral verwenden
|
||||||
|
func extractJSONHeader(line []byte) ([]byte, error) {
|
||||||
|
s := strings.TrimSpace(string(line))
|
||||||
|
s = strings.TrimPrefix(s, "<!--")
|
||||||
|
|
||||||
|
// alles bis zum ersten "-->" abschneiden
|
||||||
|
if idx := strings.Index(s, "-->"); idx != -1 {
|
||||||
|
s = s[:idx]
|
||||||
|
}
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
|
||||||
|
// optional: falls doch kein JSON, besserer Fehler
|
||||||
|
if !strings.HasPrefix(s, "{") {
|
||||||
|
return nil, fmt.Errorf("no JSON front‑matter found")
|
||||||
|
}
|
||||||
|
return []byte(s), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadStatic liest alle .md/.html‑Dateien unter dir und liefert sie als Map[slug]StaticPage.
|
||||||
|
func LoadStatic(dir string) (map[string]StaticPage, error) {
|
||||||
|
pages := make(map[string]StaticPage)
|
||||||
|
|
||||||
|
entries, err := os.ReadDir(dir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, e := range entries {
|
||||||
|
if e.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ext := filepath.Ext(e.Name())
|
||||||
|
if ext != ".md" && ext != ".html" {
|
||||||
|
continue // unbekanntes Format
|
||||||
|
}
|
||||||
|
|
||||||
|
path := filepath.Join(dir, e.Name())
|
||||||
|
raw, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read %s: %w", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Front‑Matter (erste Zeile) herauslösen
|
||||||
|
parts := bytes.SplitN(raw, []byte("\n"), 2)
|
||||||
|
if len(parts) < 2 {
|
||||||
|
return nil, fmt.Errorf("%s: missing front‑matter", path)
|
||||||
|
}
|
||||||
|
|
||||||
|
headerJSON, err := extractJSONHeader(parts[0])
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%s: %w", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var meta struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
Slug string `json:"slug"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(headerJSON, &meta); err != nil {
|
||||||
|
return nil, fmt.Errorf("%s: %w", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: Slug aus Dateinamen ableiten, falls im Header leer
|
||||||
|
if meta.Slug == "" {
|
||||||
|
meta.Slug = strings.TrimSuffix(e.Name(), ext)
|
||||||
|
}
|
||||||
|
if _, dup := pages[meta.Slug]; dup {
|
||||||
|
return nil, fmt.Errorf("%s: duplicate slug %q", path, meta.Slug)
|
||||||
|
}
|
||||||
|
|
||||||
|
body := parts[1]
|
||||||
|
if ext == ".md" {
|
||||||
|
body = md.ToHTML(body, nil, nil) // eigener Parser je Aufruf
|
||||||
|
}
|
||||||
|
|
||||||
|
pages[meta.Slug] = StaticPage{
|
||||||
|
Title: meta.Title,
|
||||||
|
Slug: meta.Slug,
|
||||||
|
Body: template.HTML(body),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return pages, nil
|
||||||
|
}
|
||||||
15
internal/article/model.go
Normal file
15
internal/article/model.go
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
// internal/article/model.go
|
||||||
|
package article
|
||||||
|
|
||||||
|
import (
|
||||||
|
"html/template"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Article struct {
|
||||||
|
Title string
|
||||||
|
Slug string
|
||||||
|
Date time.Time
|
||||||
|
Cover string // NEW
|
||||||
|
Body template.HTML // already trusted
|
||||||
|
}
|
||||||
BIN
internal/web/static/img/#placeholder.png
Normal file
BIN
internal/web/static/img/#placeholder.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.0 MiB |
BIN
internal/web/static/img/placeholder.png
Normal file
BIN
internal/web/static/img/placeholder.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.9 MiB |
151
internal/web/static/main.css
Normal file
151
internal/web/static/main.css
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
/* ---------- Farbpalette ---------- */
|
||||||
|
:root {
|
||||||
|
/* Light theme */
|
||||||
|
--bg: #f5f7fa;
|
||||||
|
--bg-alt: #ffffff;
|
||||||
|
--card-bg: #ffffff;
|
||||||
|
--text: #1f2933;
|
||||||
|
--text-muted: #64748b;
|
||||||
|
--accent: #2563eb; /* Indigo‑600 */
|
||||||
|
--accent-light: #3b82f6; /* Indigo‑500 */
|
||||||
|
--code-bg: #f1f5f9;
|
||||||
|
--code-border: #e2e8f0;
|
||||||
|
--radius: 0.75rem;
|
||||||
|
--gap: 2rem;
|
||||||
|
--shadow: 0 4px 16px rgba(0,0,0,.08);
|
||||||
|
font-size: 16px;
|
||||||
|
font-family: "Inter", system-ui, "Segoe UI", Roboto, sans-serif;
|
||||||
|
color-scheme: light;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark mode (optional) */
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
--bg: #0d1117;
|
||||||
|
--bg-alt: #161b22;
|
||||||
|
--card-bg: #161b22;
|
||||||
|
--text: #c9d1d9;
|
||||||
|
--text-muted: #8b949e;
|
||||||
|
--accent: #3b82f6;
|
||||||
|
--accent-light:#60a5fa;
|
||||||
|
--code-bg: #1e242c;
|
||||||
|
--code-border: #30363d;
|
||||||
|
--shadow: 0 4px 16px rgba(0,0,0,.32);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Grundlayout ---------- */
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
line-height: 1.65;
|
||||||
|
}
|
||||||
|
a {
|
||||||
|
color: var(--accent);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: color .15s;
|
||||||
|
}
|
||||||
|
a:hover { color: var(--accent-light); }
|
||||||
|
|
||||||
|
/* Container in der Mitte */
|
||||||
|
.wrapper {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: var(--gap) calc(var(--gap) / 1.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Kopf & Fuß ---------- */
|
||||||
|
header, footer {
|
||||||
|
background: var(--bg-alt);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
header {
|
||||||
|
position: sticky; top: 0; z-index: 10;
|
||||||
|
display: flex; justify-content: space-between; align-items: center;
|
||||||
|
padding: 1rem calc(var(--gap) / 1.2);
|
||||||
|
}
|
||||||
|
header h1 { margin: 0; font-size: 1.4rem; }
|
||||||
|
footer {
|
||||||
|
text-align: center;
|
||||||
|
padding: 2rem 1rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: .9rem;
|
||||||
|
margin-top: var(--gap);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Karten‑Grid ---------- */
|
||||||
|
.post-list {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--gap);
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: var(--card-bg);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
overflow: hidden;
|
||||||
|
transition: transform .2s ease, box-shadow .2s ease;
|
||||||
|
}
|
||||||
|
.card:hover { transform: translateY(-6px); box-shadow: 0 6px 24px rgba(0,0,0,.1); }
|
||||||
|
.card img {
|
||||||
|
width: 100%; height: 180px; object-fit: cover;
|
||||||
|
}
|
||||||
|
.card-content { padding: 1rem 1.25rem 1.5rem; }
|
||||||
|
.card h2 { margin: .25rem 0 .5rem; font-size: 1.25rem; line-height: 1.3; }
|
||||||
|
.card time { color: var(--text-muted); font-size: .85rem; }
|
||||||
|
|
||||||
|
/* ---------- Artikel ---------- */
|
||||||
|
.hero {
|
||||||
|
width: 100%; height: 320px; object-fit: cover;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
article h1 { font-size: 2.2rem; margin: 1.2rem 0 .3rem; }
|
||||||
|
article time { color: var(--text-muted); font-size: .9rem; }
|
||||||
|
article img:not(.hero), article video {
|
||||||
|
max-width: 100%; height: auto; border-radius: var(--radius);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
margin: 1rem 0;
|
||||||
|
}
|
||||||
|
article pre {
|
||||||
|
background: var(--code-bg);
|
||||||
|
border: 1px solid var(--code-border);
|
||||||
|
padding: 1rem 1.2rem;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
overflow: auto;
|
||||||
|
font-family: "Fira Code", Consolas, monospace;
|
||||||
|
}
|
||||||
|
article blockquote {
|
||||||
|
border-left: 4px solid var(--accent);
|
||||||
|
padding: .5rem 1rem; margin: 1rem 0;
|
||||||
|
background: var(--code-bg);
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.main-nav ul {
|
||||||
|
display: flex;
|
||||||
|
gap: 1.25rem;
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.main-nav a {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.main-nav a:hover {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Responsive ---------- */
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
:root { font-size: 15px; }
|
||||||
|
.hero { height: 200px; }
|
||||||
|
header { flex-direction: column; gap: .5rem; }
|
||||||
|
}
|
||||||
20
internal/web/templates/article.html
Normal file
20
internal/web/templates/article.html
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{{ define "title" }}{{ .Title }} – B1tsblog{{ end }}
|
||||||
|
|
||||||
|
{{ define "body" }}
|
||||||
|
<article>
|
||||||
|
{{ if .Cover }}
|
||||||
|
<img class="hero" src="{{ .Cover }}" alt="">
|
||||||
|
{{ end }}
|
||||||
|
|
||||||
|
<h1>{{ .Title }}</h1>
|
||||||
|
<time datetime="{{ .Date.Format "2006-01-02" }}">{{ .Date.Format "02.01.2006" }}</time>
|
||||||
|
|
||||||
|
<div class="article-content">
|
||||||
|
{{ .Body }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p><a href="/">← Zurück zur Übersicht</a></p>
|
||||||
|
</article>
|
||||||
|
{{ end }}
|
||||||
|
|
||||||
|
{{ define "article" }}{{ template "layout" . }}{{ end }}
|
||||||
36
internal/web/templates/base.html
Normal file
36
internal/web/templates/base.html
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
{{ define "layout" }}
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<title>{{ block "title" . }}B1tsblog{{ end }}</title>
|
||||||
|
<link rel="stylesheet" href="/static/main.css">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com">
|
||||||
|
<!-- Monospace‑Font für Code (optional) -->
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Fira+Code:wght@400;500&display=swap" rel="stylesheet">
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600&display=swap" rel="stylesheet">
|
||||||
|
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<h1><a href="/">B1tsblog</a></h1>
|
||||||
|
<nav class="main-nav">
|
||||||
|
<ul>
|
||||||
|
<li><a href="/">Startseite</a></li>
|
||||||
|
<li><a href="/page/impressum">Impressum</a></li>
|
||||||
|
<!-- später: <li><a href="/page/datenschutz">Datenschutz</a></li> -->
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="wrapper">
|
||||||
|
{{ template "body" . }}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer class="wrapper">
|
||||||
|
© {{ now.Year }} · B1tK1ll3r
|
||||||
|
</footer>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
{{ end }}
|
||||||
19
internal/web/templates/list.html
Normal file
19
internal/web/templates/list.html
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{{ define "body" }}
|
||||||
|
<ul class="post-list">
|
||||||
|
{{ range . }}
|
||||||
|
<li>
|
||||||
|
<a class="card" href="/post/{{ .Slug }}">
|
||||||
|
{{ if .Cover }}
|
||||||
|
<img src="{{ .Cover }}" alt="">
|
||||||
|
{{ else }}
|
||||||
|
<img src="/static/img/placeholder.png" alt="">
|
||||||
|
{{ end }}
|
||||||
|
<div class="card-content">
|
||||||
|
<h2>{{ .Title }}</h2>
|
||||||
|
<time datetime="{{ .Date.Format "2006-01-02" }}">{{ .Date.Format "02.01.2006" }}</time>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{{ end }}
|
||||||
|
</ul>
|
||||||
|
{{ end }}
|
||||||
11
internal/web/templates/page.html
Normal file
11
internal/web/templates/page.html
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{{ define "title" }}{{ .Title }} – B1tsblog{{ end }}
|
||||||
|
|
||||||
|
{{ define "body" }}
|
||||||
|
<article>
|
||||||
|
<h1>{{ .Title }}</h1>
|
||||||
|
<div class="article-content">{{ .Body }}</div>
|
||||||
|
<p><a href="/">← Zurück</a></p>
|
||||||
|
</article>
|
||||||
|
{{ end }}
|
||||||
|
|
||||||
|
{{ define "page" }}{{ template "layout" . }}{{ end }}
|
||||||
6
pages/impressum.md
Normal file
6
pages/impressum.md
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<!--{"title": "Impressum", "slug": "impressum"}-->
|
||||||
|
Max Mustermann
|
||||||
|
Musterstraße 1
|
||||||
|
12345 Musterstadt
|
||||||
|
|
||||||
|
E‑Mail: max@example.com
|
||||||
Reference in New Issue
Block a user