This commit is contained in:
icefox 2025-12-26 11:49:56 -03:00
commit 38a5c47dab
No known key found for this signature in database
11 changed files with 550 additions and 0 deletions

26
.gitignore vendored Normal file
View file

@ -0,0 +1,26 @@
# Ignore everything
*
# Whitelist gitignore itself
!.gitignore
# Whitelist Go files
!*.go
!go.mod
!go.sum
# Whitelist templates
!templates/
!templates/*.html
# Whitelist assets
!assets/
!assets/*.js
!assets/*.css
# Whitelist nix
!flake.nix
!flake.lock
# Whitelist database
!*.sqlite

5
assets/alpine.min.js vendored Normal file

File diff suppressed because one or more lines are too long

1
assets/htmx.min.js vendored Normal file

File diff suppressed because one or more lines are too long

BIN
db.sqlite Executable file

Binary file not shown.

61
flake.lock generated Normal file
View file

@ -0,0 +1,61 @@
{
"nodes": {
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1731533236,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1766651565,
"narHash": "sha256-QEhk0eXgyIqTpJ/ehZKg9IKS7EtlWxF3N7DXy42zPfU=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "3e2499d5539c16d0d173ba53552a4ff8547f4539",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",
"version": 7
}

34
flake.nix Normal file
View file

@ -0,0 +1,34 @@
{
description = "Henna - Go web server with SQLite";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
flake-utils.url = "github:numtide/flake-utils";
};
outputs = { self, nixpkgs, flake-utils }:
flake-utils.lib.eachDefaultSystem (system:
let
pkgs = nixpkgs.legacyPackages.${system};
in
{
packages.default = pkgs.buildGoModule {
pname = "henna";
version = "0.1.0";
src = ./.;
vendorHash = null; # Update after running go mod tidy
buildInputs = [ pkgs.sqlite ];
CGO_ENABLED = 1;
};
devShells.default = pkgs.mkShell {
buildInputs = with pkgs; [
go
sqlite
gcc
];
};
});
}

5
go.mod Normal file
View file

@ -0,0 +1,5 @@
module henna
go 1.23
require github.com/mattn/go-sqlite3 v1.14.24

2
go.sum Normal file
View file

@ -0,0 +1,2 @@
github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM=
github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=

296
main.go Normal file
View file

@ -0,0 +1,296 @@
package main
import (
"database/sql"
"embed"
"flag"
"fmt"
"html/template"
"log"
"math/rand"
"net/http"
"net/url"
"strconv"
"strings"
_ "github.com/mattn/go-sqlite3"
)
//go:embed templates/*
var templatesFS embed.FS
//go:embed assets/*
var assetsFS embed.FS
type CoverData struct {
Comic int
Path string
Prefix string
}
var (
db *sql.DB
tmpl *template.Template
imaginaryURL string
galleryPath string
databasePath string
randomCovers []CoverData
)
func main() {
flag.StringVar(&imaginaryURL, "imaginary", "http://192.168.88.54:10001", "imaginary root URL")
flag.StringVar(&galleryPath, "gallery", "/home/user/mnt/panda/galleries/", "gallery path")
flag.StringVar(&databasePath, "database", "./db.sqlite", "database path")
flag.Parse()
var err error
db, err = sql.Open("sqlite3", "./db.sqlite")
if err != nil {
log.Fatal(err)
}
defer db.Close()
if err = db.Ping(); err != nil {
log.Fatal(err)
}
log.Println("SQLite connected")
tmpl, err = template.ParseFS(templatesFS, "templates/*.html")
if err != nil {
log.Fatal(err)
}
http.HandleFunc("/gallery/", handleGallery)
http.HandleFunc("/random/", handleRandom)
http.HandleFunc("/random", handleRandom)
http.HandleFunc("/", handleIndex)
http.Handle("/assets/", http.FileServer(http.FS(assetsFS)))
http.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir(galleryPath))))
log.Println("Server listening on :8080")
log.Fatal(http.ListenAndServe(":10000", nil))
}
func handleRandom(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/random")
path = strings.TrimPrefix(path, "/")
// If accessing /random without chapter, shuffle and redirect to /random/0
if path == "" {
query := `
select page.id_comic, comic.directory || '/' || page.filename path, page.prefix
from page
join comic on comic.id_comic = page.id_comic
where page.number = 0
`
rows, err := db.Query(query)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
defer rows.Close()
randomCovers = nil
for rows.Next() {
var c CoverData
if err := rows.Scan(&c.Comic, &c.Path, &c.Prefix); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
randomCovers = append(randomCovers, c)
}
rand.Shuffle(len(randomCovers), func(i, j int) {
randomCovers[i], randomCovers[j] = randomCovers[j], randomCovers[i]
})
http.Redirect(w, r, "/random/0", http.StatusFound)
return
}
chapter, err := strconv.Atoi(path)
if err != nil {
http.NotFound(w, r)
return
}
const limit = 18
offset := chapter * limit
type Cover struct {
Comic int
Url string
}
end := offset + limit*2
if end > len(randomCovers) {
end = len(randomCovers)
}
start := offset
if start > len(randomCovers) {
start = len(randomCovers)
}
all := make([]Cover, 0, limit*2)
for _, c := range randomCovers[start:end] {
var coverUrl string
if c.Prefix == "imaginary" {
coverUrl = fmt.Sprintf("%s/smartcrop?width=300&height=370&file=%s", imaginaryURL, url.QueryEscape(c.Path))
} else {
coverUrl = "/static/" + c.Path
}
all = append(all, Cover{Comic: c.Comic, Url: coverUrl})
}
covers := all
var preload []Cover
if len(all) > limit {
covers = all[:limit]
preload = all[limit:]
}
var nextChapter int
if len(preload) > 0 {
nextChapter = chapter + 1
} else {
nextChapter = chapter
}
data := struct {
Covers []Cover
Preload []Cover
Chapter int
Prefix string
}{covers, preload, nextChapter, "/random"}
tmpl.ExecuteTemplate(w, "index.html", data)
}
func handleGallery(w http.ResponseWriter, r *http.Request) {
comic := strings.TrimPrefix(r.URL.Path, "/gallery/")
if comic == "" {
http.NotFound(w, r)
return
}
query := `
select comic.title, comic.directory || '/' || page.filename, page.prefix
from page
join comic on page.id_comic = comic.id_comic
where comic.id_comic = ?
order by page.number
`
rows, err := db.Query(query, comic)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
defer rows.Close()
type Page struct {
Path string
Prefix string
}
var title string
var pages []Page
for rows.Next() {
var path, prefix string
if err := rows.Scan(&title, &path, &prefix); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
pages = append(pages, Page{Path: path, Prefix: prefix})
}
if len(pages) == 0 {
http.NotFound(w, r)
return
}
data := struct {
Title string
Pages []Page
ImaginaryURL string
}{title, pages, imaginaryURL}
tmpl.ExecuteTemplate(w, "gallery.html", data)
}
func handleIndex(w http.ResponseWriter, r *http.Request) {
chapter := 0
path := strings.TrimPrefix(r.URL.Path, "/")
if path != "" {
var err error
chapter, err = strconv.Atoi(path)
if err != nil {
http.NotFound(w, r)
return
}
}
const limit = 18
offset := chapter * limit
query := `
select page.id_comic, comic.directory || '/' || page.filename path, page.prefix
from page
join comic on comic.id_comic = page.id_comic
where page.number = 0
order by comic.id_comic desc
limit ? offset ?
`
rows, err := db.Query(query, limit*2, offset)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
defer rows.Close()
type Cover struct {
Comic int
Url string
}
all := make([]Cover, 0, limit*2)
for rows.Next() {
var comic int
var path, prefix string
if err := rows.Scan(&comic, &path, &prefix); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
var coverUrl string
if prefix == "imaginary" {
coverUrl = fmt.Sprintf("%s/smartcrop?width=300&height=370&file=%s", imaginaryURL, url.QueryEscape(path))
} else {
coverUrl = "/static/" + path
}
all = append(all, Cover{Comic: comic, Url: coverUrl})
}
covers := all
var preload []Cover
if len(all) > limit {
covers = all[:limit]
preload = all[limit:]
}
var nextChapter int
if len(preload) > 0 {
nextChapter = chapter + 1
} else {
nextChapter = chapter
}
data := struct {
Covers []Cover
Preload []Cover
Chapter int
Prefix string
}{covers, preload, nextChapter, ""}
tmpl.ExecuteTemplate(w, "index.html", data)
}

79
templates/gallery.html Normal file
View file

@ -0,0 +1,79 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{.Title}}</title>
<script src="/assets/alpine.min.js" defer></script>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { background: black; height: 100vh; overflow: hidden; }
.page {
width: 100vw;
height: 100vh;
background-size: contain;
background-repeat: no-repeat;
background-position: center;
}
.page.landscape {
width: 100vh;
height: 100vw;
transform: rotate(270deg) translateY(-50%);
transform-origin: top right;
}
</style>
</head>
<body>
<div
x-data="{
current: 0,
landscape: false,
width: 1080,
height: 1920,
imaginary: '{{.ImaginaryURL}}',
pages: [{{range $i, $p := .Pages}}{{if $i}},{{end}}{path: '{{$p.Path}}', prefix: '{{$p.Prefix}}'}{{end}}],
urls: [],
prefetched: new Set(),
buildUrls() {
this.urls = this.pages.map(p => {
if (p.prefix === 'imaginary') {
return this.imaginary + '/fit?width=' + this.width + '&height=' + this.height + '&file=' + encodeURIComponent(p.path);
}
return '/static/' + p.path;
});
this.prefetched.clear();
},
prefetch() {
const img = new Image();
img.onload = () => {
for (let i = 1; i <= 10 && this.current + i < this.urls.length; i++) {
const url = this.urls[this.current + i];
if (!this.prefetched.has(url)) {
this.prefetched.add(url);
new Image().src = url;
}
}
};
img.src = this.urls[this.current];
},
swap() {
this.landscape = !this.landscape;
[this.width, this.height] = [this.height, this.width];
this.buildUrls();
},
init() {
this.buildUrls();
this.prefetch();
this.$watch('current', () => this.prefetch());
}
}"
@keydown.window.left.prevent="current = Math.max(0, current - 1)"
@keydown.window.right.prevent="current = Math.min(urls.length - 1, current + 1)"
@keydown.window.k.prevent="swap()"
@keydown.window.backspace.prevent="history.back()"
autofocus
>
<div class="page" :class="landscape ? 'landscape' : ''" :style="'background-image: url(\'' + urls[current] + '\');'"></div>
</div>
</body>
</html>

41
templates/index.html Normal file
View file

@ -0,0 +1,41 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Henna</title>
<script src="/assets/htmx.min.js" defer></script>
<script src="/assets/alpine.min.js" defer></script>
</head>
<body style="margin: 0; background-color: black; overflow: hidden;">
<div id="app"
x-data="{active: 0, total: {{len .Covers}}, row: 6, chapter: {{.Chapter}}, prefix: '{{.Prefix}}', comics: [{{range $i, $c := .Covers}}{{if $i}},{{end}}{{$c.Comic}}{{end}}]}"
@keydown.window.left.prevent="active = Math.max(0, active - 1)"
@keydown.window.right.prevent="active = Math.min(total - 1, active + 1)"
@keydown.window.up.prevent="active = Math.max(0, active - row)"
@keydown.window.down.prevent="active = Math.min(total - 1, active + row)"
@keydown.window.i.prevent="htmx.ajax('GET', prefix + '/' + chapter, {target: '#app', swap: 'outerHTML'})"
@keydown.window.n.prevent="htmx.ajax('GET', prefix + '/' + Math.max(0, chapter - 2), {target: '#app', swap: 'outerHTML'})"
@keydown.window.enter.prevent="window.location.href = '/gallery/' + comics[active]"
@wheel.window.prevent="$event.deltaY > 0 ? htmx.ajax('GET', prefix + '/' + chapter, {target: '#app', swap: 'outerHTML'}) : htmx.ajax('GET', prefix + '/' + Math.max(0, chapter - 2), {target: '#app', swap: 'outerHTML'})"
autofocus
>
<div>
{{range $i, $c := .Covers}}
<div class="cover" :class="{'active': active === {{$i}}}">
<img src="{{$c.Url}}" width="300" height="370">
</div>
{{end}}
</div>
<style>
.cover { display: inline-block; margin: 2px; outline: 2px solid transparent; }
.cover.active { outline: 2px solid #0af; }
</style>
<script>
setTimeout(() => {
[{{range $i, $c := .Preload}}{{if $i}},{{end}}'{{$c.Url}}'{{end}}].forEach(url => new Image().src = url);
}, 200);
</script>
</div>
</body>
</html>