skeleton init

This commit is contained in:
spinach 2025-12-17 09:00:24 -05:00
commit 24356421a9
19 changed files with 833 additions and 0 deletions

29
.gitignore vendored Normal file
View File

@ -0,0 +1,29 @@
# ---> Go
# If you prefer the allow list template instead of the deny list, see community template:
# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore
#
# Binaries for programs and plugins
*.exe
*.exe~
*.dll
*.so
*.dylib
# Test binary, built with `go test -c`
*.test
# Output of the go coverage tool, specifically when used with LiteIDE
*.out
# Dependency directories (remove the comment below to include it)
# vendor/
# Go workspace file
go.work
priv
# ensures readme still available
!priv/readme.md
# ignore locally created testing content
content/

9
LICENSE Normal file
View File

@ -0,0 +1,9 @@
MIT License
Copyright (c) 2025 Keegan Deppe
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

8
Makefile Normal file
View File

@ -0,0 +1,8 @@
build:
docker build -t kb -f docker/Dockerfile .
up: build
docker compose -f docker/docker-compose.yml --env-file priv/env up
clean:
go clean

6
config/env Normal file
View File

@ -0,0 +1,6 @@
GO_VERSION="1.25.5"
POSTGRES_USER="kb"
POSTGRES_PASSWORD=""
POSTGRES_DB="kb"
POSTGRES_HOSTNAME="postgres"

28
docker/Dockerfile Normal file
View File

@ -0,0 +1,28 @@
# build stage
FROM golang:1.25-alpine AS build
WORKDIR /src
# Download Go modules
COPY go.mod go.sum ./
RUN go mod download
# this could be simplified a lot by being stricter on what we copy.
COPY . .
ENV GOCACHE=/root/.cache/go-build
RUN --mount=type=cache,target="/root/.cache/go-build" CGO_ENABLED=0 GOOS=linux go build -o kb .
# copy only binary and static files to slim image size
FROM alpine
WORKDIR /app
COPY --from=build /src/kb .
COPY --from=build /src/web ./web
# application binds to 80 by default
EXPOSE 80
ENTRYPOINT ["/app/kb"]

10
docker/docker-compose.yml Normal file
View File

@ -0,0 +1,10 @@
services:
kb:
image: kb:latest
ports:
- "8080:80"
environment:
- MARIADB_PASSWORD=${MARIADB_PASSWORD}
- MARIADB_USER=${MARIADB_USER}
- MARIADB_DB=${MARIADB_DB}
- MARIADB_HOSTNAME=${MARIADB_HOSTNAME}

17
go.mod Normal file
View File

@ -0,0 +1,17 @@
module git.keegandeppe.com/kdeppe/kb
go 1.22
require (
github.com/jackc/pgx/v5 v5.7.2
github.com/yuin/goldmark v1.7.13
golang.org/x/crypto v0.31.0
)
require (
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
golang.org/x/sync v0.10.0 // indirect
golang.org/x/text v0.21.0 // indirect
)

30
go.sum Normal file
View File

@ -0,0 +1,30 @@
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.7.2 h1:mLoDLV6sonKlvjIEsV56SkWNCnuNv531l94GaIzO+XI=
github.com/jackc/pgx/v5 v5.7.2/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA=
github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

8
internal/auth/auth.go Normal file
View File

@ -0,0 +1,8 @@
// Package auth provides implements the basic login/signup functionality typical of modern websites as well as integrated TOTP.
package auth
import "fmt"
func main() {
fmt.Println("vim-go")
}

23
internal/crypto/crypto.go Normal file
View File

@ -0,0 +1,23 @@
package crypto
import (
"fmt"
"golang.org/x/crypto/bcrypt"
)
func Test() {
hash("test")
hash("test")
hash("test1")
}
func hash(pwd string) {
hash, err := bcrypt.GenerateFromPassword([]byte(pwd), bcrypt.DefaultCost)
if err != nil {
panic(err)
}
fmt.Printf("Password hash: %x\n", hash)
}

47
internal/db/db.go Normal file
View File

@ -0,0 +1,47 @@
// package db serves as a database wrapper for adonis
package db
import (
"context"
"fmt"
"os"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
type Database struct {
conn *pgxpool.Pool
}
func NewDatabase(username, password, hostname, database string) *Database {
// pool ensures thread safety
url := url(username, password, hostname, database)
dbpool, err := pgxpool.New(context.Background(), url)
for err != nil {
dbpool, err = pgxpool.New(context.Background(), url)
fmt.Fprintf(os.Stderr, "Unable to create connection pool: %v\nRetrying...\n", err)
//os.Exit(1)
time.Sleep(1 * time.Second)
}
var greeting string
err = dbpool.QueryRow(context.Background(), "select 'Hello, world!'").Scan(&greeting)
if err != nil {
fmt.Fprintf(os.Stderr, "QueryRow failed: %v\n", err)
os.Exit(1)
}
fmt.Println(greeting)
return &Database{conn: dbpool}
}
// this function assumes default port because i cant be bothered
func url(username, password, hostname, database string) string {
// generate url based on passed params
// safe to disable SSL as comms occur over docker network
return fmt.Sprintf("postgresql://%s:%s@%s/%s?sslmode=disable",
username, password, hostname, database)
}

10
internal/routes/routes.go Normal file
View File

@ -0,0 +1,10 @@
// Package routes is probably dog ass and a bad way to do things but i am a lemming
package routes
import (
"fmt"
"net/http"
"os"
"text/template"
)

305
main.go Normal file
View File

@ -0,0 +1,305 @@
// main.go
package main
import (
"html/template"
"log"
"net/http"
"os"
"path/filepath"
"strings"
"fmt"
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/extension"
"github.com/yuin/goldmark/parser"
"github.com/yuin/goldmark/renderer/html"
)
const (
contentDir = "./content"
port = "80"
)
var (
md = goldmark.New(
goldmark.WithExtensions(
extension.GFM,
extension.Table,
extension.Strikethrough,
extension.TaskList,
),
goldmark.WithParserOptions(
parser.WithAutoHeadingID(),
),
goldmark.WithRendererOptions(
html.WithHardWraps(),
html.WithXHTML(),
),
)
templates = template.Must(template.ParseGlob("web/templates/*.html"))
)
type Page struct {
Title string
Content template.HTML
RawText string
Path string
IsEdit bool
}
func main() {
// Ensure content directory exists
if err := os.MkdirAll(contentDir, 0755); err != nil {
log.Fatalf("Failed to create content directory: %v", err)
}
// Test write permissions
testFile := filepath.Join(contentDir, ".write-test")
if err := os.WriteFile(testFile, []byte("test"), 0644); err != nil {
log.Fatalf("Content directory not writable: %v", err)
}
os.Remove(testFile)
http.HandleFunc("/", handleIndex)
http.HandleFunc("/page/", handlePage)
http.HandleFunc("/edit/", handleEdit)
http.HandleFunc("/save", handleSave)
log.Printf("Starting knowledge base server on %s\n", port)
log.Printf("Content directory: %s\n", contentDir)
if err := http.ListenAndServe(fmt.Sprintf("0.0.0.0:%s",port), nil); err != nil {
log.Fatalf("Server failed to start: %v", err)
}
}
func handleIndex(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
http.NotFound(w, r)
return
}
pages, err := listPages()
if err != nil {
log.Printf("Error listing pages: %v", err)
http.Error(w, "Error loading page list", http.StatusInternalServerError)
return
}
if err := templates.ExecuteTemplate(w, "index.html", map[string]interface{}{
"Pages": pages,
}); err != nil {
log.Printf("Error rendering index: %v", err)
// Headers already sent, can't send error response
}
}
func handlePage(w http.ResponseWriter, r *http.Request) {
pagePath := strings.TrimPrefix(r.URL.Path, "/page/")
if pagePath == "" {
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
content, err := readPage(pagePath)
if err != nil {
if os.IsNotExist(err) {
// Page doesn't exist, redirect to create it
http.Redirect(w, r, "/edit/"+pagePath, http.StatusSeeOther)
return
}
// Other error (permissions, I/O error, etc.)
log.Printf("Error reading page %s: %v", pagePath, err)
http.Error(w, "Error loading page", http.StatusInternalServerError)
return
}
var htmlContent strings.Builder
if err := md.Convert([]byte(content), &htmlContent); err != nil {
log.Printf("Error converting markdown for %s: %v", pagePath, err)
http.Error(w, "Error rendering page", http.StatusInternalServerError)
return
}
page := Page{
Title: formatTitle(pagePath),
Content: template.HTML(htmlContent.String()),
Path: pagePath,
IsEdit: false,
}
if err := templates.ExecuteTemplate(w, "page.html", page); err != nil {
log.Printf("Error executing template for %s: %v", pagePath, err)
// Can't use http.Error here - headers already sent
// Just log it; user will see partial page
}
}
func handleEdit(w http.ResponseWriter, r *http.Request) {
pagePath := strings.TrimPrefix(r.URL.Path, "/edit/")
if pagePath == "" {
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
content, err := readPage(pagePath)
if err != nil {
// New page
content = "# " + formatTitle(pagePath) + "\n\nStart writing your content here..."
}
page := Page{
Title: formatTitle(pagePath),
RawText: content,
Path: pagePath,
IsEdit: true,
}
templates.ExecuteTemplate(w, "edit.html", page)
}
func handleSave(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Parse form data
if err := r.ParseForm(); err != nil {
log.Printf("Error parsing form: %v", err)
http.Error(w, "Invalid form data", http.StatusBadRequest)
return
}
pagePath := r.FormValue("path")
content := r.FormValue("content")
if pagePath == "" {
http.Error(w, "Path is required", http.StatusBadRequest)
return
}
// Validate/sanitize path to prevent directory traversal
if strings.Contains(pagePath, "..") {
log.Printf("Attempted directory traversal: %s", pagePath)
http.Error(w, "Invalid path", http.StatusBadRequest)
return
}
if err := savePage(pagePath, content); err != nil {
log.Printf("Error saving page %s: %v", pagePath, err)
if os.IsPermission(err) {
http.Error(w, "Permission denied", http.StatusForbidden)
return
}
http.Error(w, "Error saving page", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/page/"+pagePath, http.StatusSeeOther)
}
func listPages() ([]string, error) {
var pages []string
err := filepath.Walk(contentDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
// Log but continue walking - don't fail entire listing for one bad file
log.Printf("Error accessing %s: %v", path, err)
return nil // Return nil to continue walking
}
if info.IsDir() {
return nil
}
if strings.HasSuffix(path, ".md") {
relPath, err := filepath.Rel(contentDir, path)
if err != nil {
log.Printf("Error getting relative path for %s: %v", path, err)
return nil // Continue despite error
}
pagePath := strings.TrimSuffix(relPath, ".md")
pages = append(pages, pagePath)
}
return nil
})
if err != nil {
return nil, fmt.Errorf("walking content directory: %w", err)
}
return pages, nil
}
func formatTitle(pagePath string) string {
parts := strings.Split(pagePath, "/")
title := parts[len(parts)-1]
title = strings.ReplaceAll(title, "-", " ")
title = strings.ReplaceAll(title, "_", " ")
return strings.Title(title)
}
func readPage(pagePath string) (string, error) {
filePath := filepath.Join(contentDir, pagePath+".md")
// Security: ensure the path is within contentDir
absPath, err := filepath.Abs(filePath)
if err != nil {
return "", fmt.Errorf("invalid path: %w", err)
}
absContentDir, err := filepath.Abs(contentDir)
if err != nil {
return "", fmt.Errorf("invalid content dir: %w", err)
}
if !strings.HasPrefix(absPath, absContentDir) {
return "", fmt.Errorf("path outside content directory")
}
data, err := os.ReadFile(filePath)
if err != nil {
return "", err // Let caller distinguish NotExist vs other errors
}
return string(data), nil
}
func savePage(pagePath, content string) error {
filePath := filepath.Join(contentDir, pagePath+".md")
// Security check (same as readPage)
absPath, err := filepath.Abs(filePath)
if err != nil {
return fmt.Errorf("invalid path: %w", err)
}
absContentDir, err := filepath.Abs(contentDir)
if err != nil {
return fmt.Errorf("invalid content dir: %w", err)
}
if !strings.HasPrefix(absPath, absContentDir) {
return fmt.Errorf("path outside content directory")
}
// Create subdirectories if needed
dir := filepath.Dir(filePath)
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("creating directory: %w", err)
}
if err := os.WriteFile(filePath, []byte(content), 0644); err != nil {
return fmt.Errorf("writing file: %w", err)
}
return nil
}

9
readme.md Normal file
View File

@ -0,0 +1,9 @@
# kb
## Goals
basic knowledgebase to be usable solely in the browser
## Setup
`make up` runs `docker compose -f docker/docker-compose.yml --env-file priv/env up`

176
web/css/style.css Normal file
View File

@ -0,0 +1,176 @@
/* static/style.css */
:root {
--primary: #0066cc;
--primary-hover: #0052a3;
--bg: #ffffff;
--text: #333333;
--border: #dddddd;
--code-bg: #f5f5f5;
}
* {
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
line-height: 1.6;
color: var(--text);
background-color: var(--bg);
margin: 0;
padding: 0;
}
.container {
max-width: 900px;
margin: 0 auto;
padding: 20px;
}
header {
border-bottom: 2px solid var(--border);
margin-bottom: 30px;
padding-bottom: 20px;
}
header h1 {
margin: 0;
font-size: 2em;
}
nav {
display: flex;
justify-content: space-between;
align-items: center;
}
nav a {
color: var(--primary);
text-decoration: none;
}
nav a:hover {
text-decoration: underline;
}
.content {
font-size: 16px;
}
.content h1, .content h2, .content h3 {
margin-top: 1.5em;
margin-bottom: 0.5em;
}
.content code {
background-color: var(--code-bg);
padding: 2px 6px;
border-radius: 3px;
font-family: 'Courier New', monospace;
}
.content pre {
background-color: var(--code-bg);
padding: 15px;
border-radius: 5px;
overflow-x: auto;
}
.content pre code {
background: none;
padding: 0;
}
.page-list {
list-style: none;
padding: 0;
}
.page-list li {
padding: 10px;
border-bottom: 1px solid var(--border);
display: flex;
justify-content: space-between;
}
.page-list a {
color: var(--primary);
text-decoration: none;
}
.page-list a:hover {
text-decoration: underline;
}
.edit-link {
font-size: 0.9em;
color: #666;
}
.btn {
display: inline-block;
padding: 8px 16px;
background-color: var(--primary);
color: white;
text-decoration: none;
border-radius: 4px;
border: none;
cursor: pointer;
font-size: 14px;
}
.btn:hover {
background-color: var(--primary-hover);
}
.btn-primary {
background-color: #28a745;
}
.btn-primary:hover {
background-color: #218838;
}
.button-group {
margin-top: 20px;
display: flex;
gap: 10px;
}
#editor {
width: 100%;
min-height: 500px;
font-family: 'Courier New', monospace;
font-size: 14px;
}
.CodeMirror {
height: 600px;
border: 1px solid var(--border);
border-radius: 4px;
}
```
## Dependencies
Create a `go.mod` file:
```
module knowledgebase
go 1.21
require github.com/yuin/goldmark v1.6.0
```
## Project Structure
```
knowledgebase/
main.go
go.mod
content/ (created automatically)
static/
style.css
templates/
index.html
page.html
edit.html

1
web/js/htmx.min.js vendored Normal file

File diff suppressed because one or more lines are too long

59
web/templates/edit.html Normal file
View File

@ -0,0 +1,59 @@
<!-- templates/edit.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Edit: {{.Title}} - Knowledge Base</title>
<link rel="stylesheet" href="/css/style.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.2/codemirror.min.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.2/theme/base16-dark.min.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.2/codemirror.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.2/mode/markdown/markdown.min.js"></script>
</head>
<body>
<div class="container">
<header>
<nav>
<a href="/page/{{.Path}}">← Cancel</a>
<h1>Edit: {{.Title}}</h1>
</nav>
</header>
<main>
<form method="POST" action="/save" id="editForm">
<input type="hidden" name="path" value="{{.Path}}">
<textarea id="editor" name="content">{{.RawText}}</textarea>
<div class="button-group">
<button type="submit" class="btn btn-primary">Save</button>
<a href="/page/{{.Path}}" class="btn">Cancel</a>
</div>
</form>
</main>
</div>
<script>
var editor = CodeMirror.fromTextArea(document.getElementById('editor'), {
mode: 'markdown',
theme: 'base16-dark',
lineNumbers: true,
lineWrapping: true,
autoCloseBrackets: true,
matchBrackets: true,
indentUnit: 4,
tabSize: 4,
indentWithTabs: false
});
// Keyboard shortcut for save (Ctrl+S or Cmd+S)
editor.setOption("extraKeys", {
"Ctrl-S": function(cm) {
document.getElementById('editForm').submit();
},
"Cmd-S": function(cm) {
document.getElementById('editForm').submit();
}
});
</script>
</body>
</html>

33
web/templates/index.html Normal file
View File

@ -0,0 +1,33 @@
<!-- templates/index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Knowledge Base</title>
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<div class="container">
<header>
<h1>📚 Knowledge Base</h1>
</header>
<main>
<h2>All Pages</h2>
{{if .Pages}}
<ul class="page-list">
{{range .Pages}}
<li>
<a href="/page/{{.}}">{{.}}</a>
<a href="/edit/{{.}}" class="edit-link">edit</a>
</li>
{{end}}
</ul>
{{else}}
<p>No pages yet. <a href="/edit/home">Create your first page</a></p>
{{end}}
</main>
</div>
</body>
</html>

25
web/templates/page.html Normal file
View File

@ -0,0 +1,25 @@
<!-- templates/page.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{.Title}} - Knowledge Base</title>
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<div class="container">
<header>
<nav>
<a href="/">← Home</a>
<h1>{{.Title}}</h1>
<a href="/edit/{{.Path}}" class="btn">Edit</a>
</nav>
</header>
<main class="content">
{{.Content}}
</main>
</div>
</body>
</html>