commit inicial

This commit is contained in:
silva guimaraes 2025-07-03 17:12:05 -03:00
commit 92b0a902ca
20 changed files with 2464 additions and 0 deletions

51
.air.toml Normal file
View file

@ -0,0 +1,51 @@
root = "."
testdata_dir = "testdata"
tmp_dir = "tmp"
[build]
args_bin = []
bin = "./tmp/main"
cmd = "go build -o ./tmp/main ."
delay = 1000
exclude_dir = ["assets", "tmp", "vendor", "testdata"]
exclude_file = []
exclude_regex = ["_test.go"]
exclude_unchanged = false
follow_symlink = false
full_bin = ""
include_dir = []
include_ext = ["go", "tpl", "tmpl", "css", "js"]
include_file = []
kill_delay = "0s"
log = "build-errors.log"
poll = false
poll_interval = 0
post_cmd = []
pre_cmd = []
rerun = false
rerun_delay = 500
send_interrupt = false
stop_on_error = false
[color]
app = ""
build = "yellow"
main = "magenta"
runner = "green"
watcher = "cyan"
[log]
main_only = false
time = false
[misc]
clean_on_exit = false
[proxy]
app_port = 0
enabled = false
proxy_port = 0
[screen]
clear_on_rebuild = false
keep_scroll = true

3
.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
foo.html
tmp/*
indexStyle.css

4
Dockerfile Normal file
View file

@ -0,0 +1,4 @@
FROM golang:1.23 as first
COPY ./* .
RUN go build -o main -v .

9
Makefile Normal file
View file

@ -0,0 +1,9 @@
run:
go mod tidy
go run -v .
dev:
air

258
gallery/gallery.go Normal file
View file

@ -0,0 +1,258 @@
package gallery
import (
"crypto/md5"
"encoding/json"
"fmt"
"io/fs"
"os"
"path/filepath"
"strings"
"time"
)
type Gallery struct {
images []string
uuid string
title string
jpTitle string
tags []Tag
source Source
CTime time.Time
artists []string
groups []string
parodies []string
}
func (g *Gallery) Name() string { return g.title }
func (g *Gallery) JpName() string { return g.jpTitle }
func (g *Gallery) Uuid() string { return g.uuid }
func (g *Gallery) Images() []string { return g.images }
func (g *Gallery) Tags() []Tag { return g.tags }
func (g *Gallery) Source() Source { return g.source }
func (g *Gallery) Artists() []string { return g.artists }
func (g *Gallery) Groups() []string { return g.groups }
func (g *Gallery) Parodies() []string { return g.parodies }
func newGallery(
images []string, uuid string, title string, jpTitle string, tags []Tag,
source Source, CTime time.Time, artists []string, groups []string, parodies []string,
) Gallery {
return Gallery{
images: images,
uuid: uuid,
title: title,
jpTitle: jpTitle,
tags: tags,
source: source,
CTime: CTime,
artists: artists,
groups: groups,
parodies: parodies,
}
}
type Source string
const (
Nhentai Source = "nhentai"
Exhentai = "exhentai"
Hitomi = "hitomi"
)
type Gender string
const (
Male Gender = "male"
Female Gender = "female"
Any Gender = "any"
)
type Tag struct {
Name string
Sex Gender
}
func newTag(name string, sex Gender) Tag {
return Tag{
Name: strings.ToLower(name),
Sex: sex,
}
}
func genUuid(title string) string {
return fmt.Sprintf("%x", md5.Sum([]byte(title)))
}
func filterImages(root string, galleryDir []fs.DirEntry) []string {
var paths []string
for _, file := range galleryDir {
if file.IsDir() {
continue
}
name := file.Name()
// fixme
isJpeg := strings.HasSuffix(name, ".jpeg") || strings.HasSuffix(name, ".jpg")
isPng := strings.HasSuffix(name, ".png")
isWebp := strings.HasSuffix(name, ".webp")
isJson := strings.HasSuffix(name, ".json")
if isJson {
continue
}
if isJpeg || isPng || isWebp {
paths = append(paths, filepath.Join(root, name))
}
}
return paths
}
func NewNhentaiGallery(infoBinary []byte, root string, galleryDir []fs.DirEntry) (Gallery, error) {
var data struct {
Title string
Title_en string `json:"title_en"`
Title_ja string
// Gallery_id string `json:"gallery_id"` // troublesome
Media_id int
Date int64
Scanlator string
Artist, Group, Parody, Characters, Tags []string
Type, Lang, Language, Category, Subcategory string
Count int
}
err := json.Unmarshal(infoBinary, &data)
if err != nil {
return Gallery{}, err
}
var tags []Tag
for _, t := range data.Tags {
tags = append(tags, newTag(t, Any))
}
images := filterImages(root, galleryDir)
d, err := os.Stat(root)
if err != nil {
return Gallery{}, err
}
g := newGallery(
images, genUuid(data.Title), data.Title, data.Title_ja, tags,
Nhentai, d.ModTime(), data.Artist, data.Group, data.Parody,
)
return g, nil
}
func NewExhentaiGallery(infoBinary []byte, root string, galleryDir []fs.DirEntry) (Gallery, error) {
var data struct {
Gid int
Token string
Thumb string
Title string
Title_jpn string
Eh_category string
Uploader string
Date string
Parent string
Language string
Filecount string
Favorites string
Rating string
Torrentcount string
Lang string
Category string
Subcategory string
Expunged bool
Filesize int
Tags []string
}
err := json.Unmarshal(infoBinary, &data)
if err != nil {
return Gallery{}, err
}
var (
tags []Tag
artists []string
groups []string
parodies []string
)
for _, t := range data.Tags {
switch {
case strings.HasPrefix(t, "female:"):
tags = append(tags, newTag(t[7:], Female))
case strings.HasPrefix(t, "male:"):
tags = append(tags, newTag(t[5:], Male))
case strings.HasPrefix(t, "artist:"):
artists = append(artists, t[7:])
case strings.HasPrefix(t, "group:"):
groups = append(groups, t[6:])
case strings.HasPrefix(t, "parody:"):
parodies = append(parodies, t[7:])
}
}
d, err := os.Stat(root)
if err != nil {
return Gallery{}, err
}
g := newGallery(
filterImages(root, galleryDir), genUuid(data.Title), data.Title, data.Title_jpn,
tags, Exhentai, d.ModTime(), artists, groups, parodies,
)
return g, nil
}
func NewHitomiGallery(infoBinary []byte, root string, galleryDir []fs.DirEntry) (Gallery, error) {
var data struct {
Gallery_id int
Title string
Title_jpn string
Type string
Language string
Lang string
Date string
Tags []string
Artist []string
Group []string
Parody []string
Characters []string
Count int
Category string
Subcategory string
}
err := json.Unmarshal(infoBinary, &data)
if err != nil {
return Gallery{}, err
}
var tags []Tag
for _, t := range data.Tags {
sex := Any
name := t
if strings.HasSuffix(t, "♀") {
sex = Female
name = t[:len(t)-3]
} else if strings.HasSuffix(t, "♂") {
sex = Male
name = t[:len(t)-3]
}
tags = append(tags, newTag(name, sex))
}
d, err := os.Stat(root)
if err != nil {
return Gallery{}, err
}
g := newGallery(
filterImages(root, galleryDir), genUuid(data.Title), data.Title, data.Title_jpn,
tags, Hitomi, d.ModTime(), data.Artist, data.Group, data.Parody,
)
return g, nil
}
func NewGallery(source Source, infoBinary []byte, root string, galleryDir []fs.DirEntry) (Gallery, error) {
switch source {
case Nhentai:
return NewNhentaiGallery(infoBinary, root, galleryDir)
case Exhentai:
return NewExhentaiGallery(infoBinary, root, galleryDir)
case Hitomi:
return NewHitomiGallery(infoBinary, root, galleryDir)
}
panic(source)
}

11
go.mod Normal file
View file

@ -0,0 +1,11 @@
module goreader
go 1.23.0
require (
github.com/lithammer/fuzzysearch v1.1.8
github.com/silva-guimaraes/gtag v0.4.0
golang.org/x/image v0.20.0
)
require golang.org/x/text v0.18.0 // indirect

39
go.sum Normal file
View file

@ -0,0 +1,39 @@
github.com/lithammer/fuzzysearch v1.1.8 h1:/HIuJnjHuXS8bKaiTMeeDlW2/AyIWk2brx1V8LFgLN4=
github.com/lithammer/fuzzysearch v1.1.8/go.mod h1:IdqeyBClc3FFqSzYq/MXESsS4S0FsZ5ajtkr5xPLts4=
github.com/silva-guimaraes/gtag v0.4.0 h1:xFh95rhFU0y+b6EoW9OXs6JfaGogDpqiIY3gYiiulaA=
github.com/silva-guimaraes/gtag v0.4.0/go.mod h1:AqOpcUI+Lsu4mCKC8o0S4zr3wd6m8VHj6tKqmnyV/P0=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/image v0.20.0 h1:7cVCUjQwfL18gyBJOmYvptfSHS8Fb3YUDtfLIZ7Nbpw=
golang.org/x/image v0.20.0/go.mod h1:0a88To4CYVBAHp5FXJm8o7QbUl37Vd85ply1vyD8auM=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224=
golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=

32
logging.go Normal file
View file

@ -0,0 +1,32 @@
package main
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
}
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,
}
next.ServeHTTP(wrapped, r)
log.Printf("%v %5v %12v %v", wrapped.statusCode, r.Method, time.Since(start), r.URL.Path)
})
}

369
main.go Normal file
View file

@ -0,0 +1,369 @@
package main
import (
"crypto/rand"
_ "embed"
"errors"
"fmt"
"io"
"io/fs"
"log"
"math/big"
"net"
"net/http"
"os"
"path/filepath"
"slices"
"sort"
"strconv"
"goreader/gallery"
"goreader/state"
view "goreader/views"
"image"
_ "image/gif"
_ "image/jpeg"
"image/png"
"golang.org/x/image/draw"
"github.com/lithammer/fuzzysearch/fuzzy"
)
var searchGalleriesError = fmt.Errorf("failure reading archives")
func searchGalleryDl(state *state.State) error {
defer func() {
log.Println("done searching galleries")
state.Done = true
}()
sourceDirectories, err := os.ReadDir(state.Root)
if err != nil {
return errors.Join(searchGalleriesError, err)
}
for _, source := range sourceDirectories {
log.Println("attemping", source)
if !source.IsDir() {
continue
}
sourcePath := filepath.Join(state.Root, source.Name())
sourceDir, err := os.ReadDir(sourcePath)
if err != nil {
return errors.Join(searchGalleriesError, err)
}
for _, galleryDir := range sourceDir {
galleryPath := filepath.Join(sourcePath, galleryDir.Name())
galleryFiles, err := os.ReadDir(galleryPath)
if err != nil {
log.Println(err)
continue
}
infoIndex := slices.IndexFunc(galleryFiles, func(a fs.DirEntry) bool {
return a.Name() == "info.json"
})
if infoIndex == -1 {
log.Println(fmt.Errorf("no index file: %s", galleryPath))
continue
}
infoPath := filepath.Join(galleryPath, "info.json")
infoBinary, err := os.ReadFile(infoPath)
if err != nil {
log.Println(err)
continue
}
var from gallery.Source
switch source.Name() {
case "nhentai":
from = gallery.Nhentai
case "exhentai":
from = gallery.Exhentai
case "hitomi":
from = gallery.Hitomi
default:
log.Printf("unrecognized source: \"%s\"\n", source.Name())
continue
}
info, err := gallery.NewGallery(from, infoBinary, galleryPath, galleryFiles)
if err != nil {
log.Println(err)
continue
}
err = state.AddGallery(info)
if os.IsNotExist(err) {
log.Println(err)
} else if err != nil {
panic(err)
}
}
}
return nil
}
func main() {
state := &state.State{
Port: ":2323",
Root: "/home/xi/seed/gallery-dl/",
Galleries: make([]gallery.Gallery, 0, 50),
UniqueTags: make(map[gallery.Tag]int),
UniqueArtists: make(map[string]int),
UniqueGroups: make(map[string]int),
UniqueParodies: make(map[string]int),
Filtered: make([]gallery.Tag, 0),
}
fmt.Printf("live at http://localhost%s\n", state.Port)
server := &http.Server{
Addr: state.Port,
Handler: Logging(http.DefaultServeMux),
}
ln, err := net.Listen("tcp", server.Addr)
if err != nil {
panic(err)
}
go func() {
// defer func() {
// if err := recover(); err != nil {
// e, ok := err.(error)
// if !ok {
// log.Println(err)
// } else {
// log.Println(e.Error())
// }
// }
// }()
err := searchGalleryDl(state)
if err != nil {
log.Println(err)
log.Println()
}
}()
cacheDir, err := os.UserCacheDir()
if err != nil {
panic(err)
}
state.CacheDir = filepath.Join(cacheDir, "omakase")
err = os.MkdirAll(state.CacheDir, 0o755)
if err != nil {
panic(err)
}
log.Println("cache directory path:", state.CacheDir)
http.DefaultServeMux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" && r.URL.Path != "index.html" {
log.Println("not found:", r.URL.Path)
http.NotFound(w, r)
return
}
page, err := strconv.Atoi(r.URL.Query().Get("page"))
if err != nil {
page = 1
}
view.Index(state, page).Render(w)
})
// http.DefaultServeMux.HandleFunc("/static/styles.css", func(w http.ResponseWriter, r *http.Request) {
// file, err := os.Open("./views/static/styles.css")
// if err != nil {
// panic(err)
// }
// io.Copy(w, file)
// })
http.DefaultServeMux.HandleFunc("/read/{uuid}", func(w http.ResponseWriter, r *http.Request) {
uuid := r.PathValue("uuid")
if uuid == "" {
panic(fmt.Errorf("could not get gallery UUID. empty string."))
}
page := "1"
pageString := r.URL.Query().Get("page")
if pageString != "" {
page = pageString
}
found, err := state.FindTitle(uuid)
if err != nil {
panic(err)
}
_ = view.Reader(found, page).Render(w)
})
http.DefaultServeMux.HandleFunc("/random", func(w http.ResponseWriter, r *http.Request) {
f := state.FilterGalleries()
n, err := rand.Int(rand.Reader, big.NewInt(int64(len(f))))
if err != nil {
panic(err)
}
http.Redirect(w, r, fmt.Sprintf("/read/%s", f[n.Int64()].Uuid()), http.StatusFound)
})
http.DefaultServeMux.HandleFunc("/page/{uuid}/{page}", func(w http.ResponseWriter, r *http.Request) {
uuid := r.PathValue("uuid")
pageString := r.PathValue("page")
if uuid == "" || pageString == "" {
panic(fmt.Errorf("uuid or page could not be found: %s", r.URL.Path))
}
page, err := strconv.Atoi(pageString)
if err != nil {
panic(err)
}
found, err := state.FindTitle(uuid)
if err != nil {
panic(err)
}
image, err := os.Open(found.Images()[page-1])
if err != nil {
panic(err)
}
_, _ = io.Copy(w, image)
})
http.DefaultServeMux.HandleFunc("/thumb/{uuid}", func(w http.ResponseWriter, r *http.Request) {
uuid := r.PathValue("uuid")
if uuid == "" {
panic(fmt.Errorf("uuid could not be found: %s", r.URL.Path))
}
found, err := state.FindTitle(uuid)
if err != nil {
panic(err)
}
if len(found.Images()) == 0 {
panic(fmt.Errorf("why does gallery contain no images? \"%s\"", uuid))
}
imageCacheFilepath := filepath.Join(state.CacheDir, uuid)
imageCache, err := os.Open(imageCacheFilepath)
if err == nil {
_, _ = io.Copy(w, imageCache)
imageCache.Close()
return
}
if len(found.Images()) == 0 {
panic(fmt.Errorf("why does gallery contain no images? \"%s\"", uuid))
}
coverImagePath := found.Images()[0]
imageStream, err := os.Open(coverImagePath)
if err != nil {
panic(err)
}
defer imageStream.Close()
sourceImage, _, err := image.Decode(imageStream)
if err != nil {
panic(err)
}
sourceSize := sourceImage.Bounds().Size()
aspectRatio := float64(sourceSize.X) / float64(sourceSize.Y)
destWidth := 148
destHeight := int(float64(destWidth) / aspectRatio)
scaler := draw.BiLinear.NewScaler(destWidth, destHeight, sourceSize.X, sourceSize.Y)
destRect := image.Rect(0, 0, destWidth, destHeight)
destImage := image.NewRGBA(destRect)
scaler.Scale(destImage, destRect, sourceImage, sourceImage.Bounds(), draw.Src, nil)
imageCache, err = os.Create(imageCacheFilepath)
if err != nil {
panic(err)
}
defer imageCache.Close()
_ = png.Encode(io.MultiWriter(imageCache, w), destImage)
})
// http.DefaultServeMux.HandleFunc("POST /filter/{tag}/{sex}", func(w http.ResponseWriter, r *http.Request) {
// tagLookup := r.PathValue("tag")
// if tagLookup == "" {
// panic(fmt.Errorf("tag could not be found: %s", r.URL.Path))
// }
// if tagLookup == "clear" {
// state.Filtered = []gallery.Tag{}
// view.GalleriesListing(state.Galleries).Render(w)
// return
// }
// tagSexLookup := r.PathValue("sex")
// if tagSexLookup == "" {
// panic(fmt.Errorf("tag sex could not be found: %s", r.URL.Path))
// }
// sex := gallery.ToGender(tagSexLookup)
// i := slices.IndexFunc(state.TagKeys, func(a gallery.Tag) bool {
// return a.Name == tagLookup && a.Sex == sex
// })
// if i < 0 {
// panic(fmt.Errorf("tag could not be found: %s", r.URL.Path))
// }
// tag := state.TagKeys[i]
// i = slices.Index(state.Filtered, tag)
// if i < 0 {
// state.Filtered = append(state.Filtered, tag)
// } else {
// state.Filtered = slices.Delete(state.Filtered, i, i+1)
// }
// view.GalleriesListing(state.FilterGalleries()).Render(w)
// })
http.DefaultServeMux.HandleFunc("GET /stats", func(w http.ResponseWriter, r *http.Request) {
view.Stats(state).Render(w)
})
http.DefaultServeMux.HandleFunc("GET /details/{uuid}", func(w http.ResponseWriter, r *http.Request) {
uuid := r.PathValue("uuid")
if uuid == "" {
panic(fmt.Errorf("uuid could not be found: %s", r.URL.Path))
}
g, err := state.FindTitle(uuid)
if err != nil {
panic(err)
}
view.InspectorInfo(g, state).Render(w)
})
// FIXME
http.DefaultServeMux.HandleFunc("POST /search", func(w http.ResponseWriter, r *http.Request) {
query := r.FormValue("search")
log.Println("search form result:", query)
if query == "" {
view.SearchResults(state.Galleries, 0).Render(w)
return
}
// ranks := fuzzy.RankFindFold(search, state.GalleryNames)
var ranks fuzzy.Ranks
for i, g := range state.Galleries {
en := fuzzy.RankMatchFold(query, g.Name())
rank := fuzzy.Rank{
Source: query,
Target: g.JpName(),
Distance: en,
OriginalIndex: i,
}
if jp := fuzzy.RankMatchFold(query, g.JpName()); jp >= 0 && jp < en {
rank = fuzzy.Rank{
Source: query,
Target: g.JpName(),
Distance: jp,
OriginalIndex: i,
}
}
if rank.Distance < 0 {
continue
}
ranks = append(ranks, rank)
}
sort.Sort(ranks)
var galleries []gallery.Gallery
for i, r := range ranks {
if i == 30 {
break
}
galleries = append(galleries, state.Galleries[r.OriginalIndex])
}
view.SearchResults(galleries, 0).Render(w)
})
log.Fatal(server.Serve(ln))
}

76
state/state.go Normal file
View file

@ -0,0 +1,76 @@
package state
import (
"fmt"
"goreader/gallery"
"maps"
"slices"
_ "golang.org/x/image/webp"
)
type State struct {
Port string
Root string
CacheDir string
Galleries []gallery.Gallery
GalleryNames []string
UniqueTags map[gallery.Tag]int
UniqueArtists map[string]int
UniqueGroups map[string]int
UniqueParodies map[string]int
TagKeys []gallery.Tag
Filtered []gallery.Tag
Done bool
}
func (s *State) AddGallery(g gallery.Gallery) error {
s.Galleries = append(s.Galleries, g)
s.GalleryNames = append(s.GalleryNames, g.Name())
slices.SortFunc(s.Galleries, func(a, b gallery.Gallery) int {
return a.CTime.Compare(b.CTime)
})
for _, tag := range g.Tags() {
s.UniqueTags[tag]++
}
for _, artist := range g.Artists() {
s.UniqueArtists[artist]++
}
for _, group := range g.Groups() {
s.UniqueGroups[group]++
}
for _, parody := range g.Parodies() {
s.UniqueParodies[parody]++
}
s.TagKeys = slices.Collect(maps.Keys(s.UniqueTags))
return nil
}
func (s *State) FilterGalleries() []gallery.Gallery {
var ret []gallery.Gallery
for _, g := range s.Galleries {
galleryTags := g.Tags()
count := 0
for _, f := range s.Filtered {
if i := slices.Index(galleryTags, f); i < 0 {
break
}
count++
}
if len(s.Filtered) == count {
ret = append(ret, g)
}
}
return ret
}
func (s *State) FindTitle(uuid string) (gallery.Gallery, error) {
index := slices.IndexFunc(s.Galleries, func(g gallery.Gallery) bool {
return g.Uuid() == uuid
})
if index == -1 {
return gallery.Gallery{}, fmt.Errorf("gallery not found")
}
return s.Galleries[index], nil
}

256
views/index.go Normal file
View file

@ -0,0 +1,256 @@
package view
import (
_ "embed"
"fmt"
"goreader/gallery"
"goreader/state"
"strconv"
"github.com/silva-guimaraes/gtag"
)
//go:embed static/styles.css
var styles string
//go:embed static/reader.js
var readerJavascript string
//go:embed static/index.js
var indexJavascript string
//go:embed static/hotkeys.min.js
var hotkeys string
//go:embed static/htmx.min.js
var htmx string
func tag(t gallery.Tag, state *state.State) *gtag.Tag {
gender := "X"
backgroundClass := "any"
if t.Sex == gallery.Male {
gender = "M"
backgroundClass = "male"
}
if t.Sex == gallery.Female {
gender = "F"
backgroundClass = "female"
}
d := gtag.New("a").
Href("javascript:void(0)").
Class("tag").
SetAttr("hx-post", fmt.Sprintf("/filter/%s/%s", t.Name, t.Sex)).
SetAttr("hx-target", "#listing").
SetAttr("hx-swap", "outerHTML")
{
d.Tag("span").Text(gender).Class("name", backgroundClass)
d.Tag("span").Text(t.Name).Class("name")
d.Tag("span").Text(strconv.Itoa(state.UniqueTags[t])).Class("name", backgroundClass)
}
return d
}
func thumbnail(src string) *gtag.Tag {
return gtag.NewVoid("img").SetAttr("src", src).SetAttr("loading", "lazy")
}
func fullThumbnail(g gallery.Gallery) *gtag.Tag {
return thumbnail(fmt.Sprintf("/page/%s/1", g.Uuid()))
}
func smallThumbnail(g gallery.Gallery) *gtag.Tag {
return thumbnail(fmt.Sprintf("/thumb/%s", g.Uuid())).Class("thumbnail")
}
func InspectorInfo(g gallery.Gallery, state *state.State) *gtag.Tag {
d := gtag.Div().Class("float")
{
// d.Append(smallThumbnail(g))
d.Append(fullThumbnail(g))
// d.Tag("div").Id("inspector-image-placeholder")
d.Tag("a").
SetAttr("href", fmt.Sprintf("/read/%s", g.Uuid())).
SetAttr("hx-boost", "false").
SetAttr("style", "color: white").
Tag("h1").Text(g.Name())
d.Tag("h2").Text(g.JpName())
tags := d.Div()
{
for _, t := range g.Tags() {
tags.Append(tag(t, state))
}
}
}
return d
}
func cover(g gallery.Gallery) *gtag.Tag {
a := gtag.New("a").
Class("cover").
Id(fmt.Sprintf("cover-%s", g.Uuid())).
SetAttr("href", fmt.Sprintf("/read/%s", g.Uuid())).
SetAttr("hx-get", fmt.Sprintf("/details/%s", g.Uuid())).
SetAttr("onmouseenter", "inspectSetTimeout(event)").
SetAttr("onmouseleave", "inspectClearTimeout(event)").
SetAttr("hx-trigger", "inspect").
SetAttr("hx-target", "#inspector").
SetAttr("hx-boost", "false")
a.Append(smallThumbnail(g))
return a
}
const galleriesPerPage = 50
func SearchResults(galleries []gallery.Gallery, page int) *gtag.Tag {
glm := max(len(galleries)-1, 0)
recent := galleries[min(page*galleriesPerPage, glm):min((page+1)*galleriesPerPage, glm)]
m := gtag.New("main")
// HTMX faz com que isso receba [InspectorInfo] quando usuário paira o
// mouse sobre algum cover
m.Tag("section").Id("inspector")
results := m.Tag("section").Id("results")
{
for _, g := range recent {
results.Append(cover(g))
}
}
return m
}
func Stats(state *state.State) *gtag.Tag {
s := gtag.New("div").Id("stats")
{
s.Tag("p").Text(fmt.Sprintf("Galleries loaded: %d", len(state.Galleries)))
s.Tag("p").Text(fmt.Sprintf("Unique tags: %d", len(state.UniqueTags)))
s.Tag("p").Text(fmt.Sprintf("Unique artists: %d", len(state.UniqueArtists)))
s.Tag("p").Text(fmt.Sprintf("Unique groups: %d", len(state.UniqueGroups)))
s.Tag("p").Text(fmt.Sprintf("Unique parodies: %d", len(state.UniqueParodies)))
}
if !state.Done {
s.SetAttr("hx-get", "/stats").SetAttr("hx-trigger", "every 0.01s").SetAttr("hx-swap", "outerHTML")
}
return s
}
func Index(state *state.State, page int) *gtag.Tag {
html := gtag.Doc()
{
head := html.Head()
{
head.Tag("title").Text("index")
head.Tag("style").Asis(styles)
head.Asis(
`<link href='https://fonts.googleapis.com/css?family=Noto Sans' rel='stylesheet'>`,
)
head.Asis("<!-- hotkeys -->")
head.Tag("script").Asis(hotkeys)
head.Asis("<!-- htmx -->")
head.Tag("script").Asis(htmx)
}
body := html.Body().Id("index").SetAttr("hx-boost", "true")
{
nav := body.Tag("nav").Class("container")
{
nav.Div().Text("omakase v1")
}
center := body.Div().Id("center")
{
top := center.Tag("div").Class("top")
{
top.Div().Id("search-bar").VoidTag("input").
Class("ask-input").
SetAttr("type", "search").
SetAttr("name", "search").
SetAttr("autocomplete", "false").
SetAttr("placeholder", "Slash to search...").
SetAttr("hx-post", "/search").
SetAttr("hx-trigger", "input changed delay:50ms, search").
SetAttr("hx-target", "#search-results")
header := top.Tag("header").Class("container")
{
header.Div().Id("omakase").
Tag("a").
SetAttr("href", "/random").
Text("おまかせ").
SetAttr("target", "_blank")
header.Tag("hr").Style("opacity: 0.2;")
header.Append(Stats(state))
}
}
ret := center.Tag("main").Id("search-results")
{
controls := ret.Tag("section").AddClass("container").Id("controls")
{
paging := controls.Div()
{
maxPages := len(state.Galleries) / galleriesPerPage
previousPage := gtag.New("a").
AddClass("page-control").
Text("<")
if page > 1 {
previousPage.SetAttr("href",
fmt.Sprintf("?page=%d", page-1),
)
}
paging.Append(previousPage)
p := page
for ; p < min(page+3, maxPages); p++ {
paging.Tag("a").
AddClass("page-control").
SetAttr("href",
fmt.Sprintf("?page=%d", p),
).Text(fmt.Sprint(p))
}
if len(state.Galleries) > 0 && p != maxPages {
paging.Tag("span").Text("...")
}
paging.Tag("a").
AddClass("page-control").
SetAttr("href",
fmt.Sprintf("?page=%d", maxPages),
).Text(fmt.Sprint(maxPages))
nextPage := gtag.New("a").
AddClass("page-control").
SetAttr("href", "?page=1").
Text(">")
if page < maxPages {
nextPage.SetAttr("href",
fmt.Sprintf("?page=%d", page+1),
)
} else if page == maxPages {
nextPage.SetAttr("disabled", "true")
}
paging.Append(nextPage)
}
}
ret.Append(SearchResults(state.Galleries, page))
}
}
body.Tag("footer").P().Text(randomQuote())
body.Tag("script").Asis(indexJavascript)
}
}
return html
}
// content := body.Div().Id("content").Class("container"); {
// details := content.Tag("details"); {
// details.Tag("summary").Text("filter tags...")
// tags := details.Div().Id("tags"); {
// tags.Tag("a").Href("javascript:void(0)").Text("clear tags").
// Class("name", "tag").Style("display: block;").
// SetAttr("hx-post", "/filter/clear/all").
// SetAttr("hx-target", "#listing").
// SetAttr("hx-swap", "outerHTML")
// tags.Text("Tags:")
// tagsSpan := tags.Tag("span").SetAttr("hx-boost", "true"); {
// for k, v := range uniqueTags {
// tagsSpan.Append(tag(k, v))
// }
// }
// }
// }
// content.Append(galleriesListing(galleries))
// }

72
views/quotes.go Normal file
View file

@ -0,0 +1,72 @@
package view
import "math/rand"
var quotes = []string{
"The object of the superior man is truth.",
"Real knowledge is to know the extent of one's ignorance.",
"The will to win, the desire to succeed, the urge to reach your full potential... these are the keys that will unlock the door to personal excellence.",
"Better a diamond with a flaw than a pebble without.",
"It is easy to hate and it is difficult to love. This is how the whole scheme of things works. All good things are difficult to achieve; and bad things are very easy to get.",
"Humility is the solid foundation of all virtues.",
"What you do not want done to yourself, do not do to others.",
"Study the past, if you would divine the future.",
"Do not impose on others what you yourself do not desire.",
"The strength of a nation derives from the integrity of the home.",
"The more man meditates upon good thoughts, the better will be his world and the world at large.",
"A gentleman would be ashamed should his deeds not match his words.",
"We should feel sorrow, but not sink under its oppression.",
"The expectations of life depend upon diligence; the mechanic that would perfect his work must first sharpen his tools.",
"If we don't know life, how can we know death?",
"If you look into your own heart, and you find nothing wrong there, what is there to worry about? What is there to fear?",
"To see and listen to the wicked is already the beginning of wickedness.",
"Ability will never catch up with the demand for it.",
"Death and life have their determined appointments",
"riches and honors depend upon heaven.",
"There are three methods to gaining wisdom. The first is reflection, which is the highest. The second is limitation, which is the easiest. The third is experience, which is the bitterest.",
"The superior man thinks always of virtue; the common man thinks of comfort.",
"Life is really simple, but we insist on making it complicated.",
"Our greatest glory is not in never falling, but in rising every time we fall.",
"Wherever you go, go with all your heart.",
"When anger rises, think of the consequences.",
"Only the wisest and stupidest of men never change.",
"When it is obvious that the goals cannot be reached, don't adjust the goals, adjust the action steps.",
"Never give a sword to a man who can't dance.",
"If you think in terms of a year, plant a seed; if in terms of ten years, plant trees; if in terms of 100 years, teach the people.",
"Heaven means to be one with God.",
"Old age, believe me, is a good and pleasant thing. It is true you are gently shouldered off the stage, but then you are given such a comfortable front stall as spectator.",
"An oppressive government is more to be feared than a tiger.",
"The cautious seldom err.",
"To be wronged is nothing unless you continue to remember it.",
"Learning without thought is labor lost; thought without learning is perilous.",
"It is more shameful to distrust our friends than to be deceived by them.",
"To see what is right and not to do it is want of courage, or of principle.",
"If I am walking with two other men, each of them will serve as my teacher. I will pick out the good points of the one and imitate them, and the bad points of the other and correct them in myself.",
"The superior man acts before he speaks, and afterwards speaks according to his action.",
"Wisdom, compassion, and courage are the three universally recognized moral qualities of men.",
"I hear and I forget. I see and I remember. I do and I understand.",
"Everything has beauty, but not everyone sees it.",
"By three methods we may learn wisdom: First, by reflection, which is noblest; Second, by imitation, which is easiest; and third by experience, which is the bitterest.",
"To know what you know and what you do not know, that is true knowledge.",
"Success depends upon previous preparation, and without such preparation there is sure to be failure.",
"Silence is a true friend who never betrays.",
"He who learns but does not think, is lost! He who thinks but does not learn is in great danger.",
"Without feelings of respect, what is there to distinguish men from beasts?",
"I hear, I know. I see, I remember. I do, I understand.",
"The superior man understands what is right; the inferior man understands what will sell.",
"To practice five things under all circumstances constitutes perfect virtue; these five are gravity, generosity of soul, sincerity, earnestness, and kindness.",
"I want you to be everything that's you, deep at the center of your being.",
"You cannot open a book without learning something.",
"They must often change, who would be constant in happiness or wisdom.",
"Never contract friendship with a man that is not better than thyself.",
"The superior man is modest in his speech, but exceeds in his actions.",
"In a country well governed, poverty is something to be ashamed of. In a country badly governed, wealth is something to be ashamed of.",
"A superior man is modest in his speech, but exceeds in his actions.",
"To see the right and not to do it is cowardice.",
}
var quotesLen = len(quotes)
func randomQuote() string {
return quotes[rand.Intn(quotesLen)]
}

60
views/reader.go Normal file
View file

@ -0,0 +1,60 @@
package view
import (
_ "embed"
"fmt"
"goreader/gallery"
"strconv"
"github.com/silva-guimaraes/gtag"
)
//go:embed static/panzoom.min.js
var panzoom string
func Reader(g gallery.Gallery, page string) *gtag.Tag {
images := g.Images()
html := gtag.Doc()
head := html.Head()
{
head.Tag("title").Text("leitor")
head.Tag("style").Asis(styles)
head.Asis("<!-- panzoom -->")
head.Tag("script").Asis(panzoom)
}
body := html.Body().Id("reader")
{
nav := body.Tag("nav")
{
controls := nav.Div().Id("controls")
{
controls.Tag("button").Id("start-button").Text("start")
controls.Tag("button").Id("previous-button").Text("previous")
controls.Tag("button").Id("next-button").Text("next")
controls.Tag("button").Id("end-button").Text("end")
}
nav.VoidTag("hr")
pageCounter := nav.P().Style("block: inline;")
{
pageCounter.Tag("span").Id("page-counter").Text(page)
pageCounter.Text("/")
pageCounter.Text(strconv.Itoa(len(images)))
}
}
cont := body.Div().Id("pages-container").SetAttr("data-page", page)
{
for i := range images {
img := gtag.NewVoid("img").
Class("page hidden").
SetAttr("loading", "lazy").
SetAttr("src", fmt.Sprintf("/page/%s/%d", g.Uuid(), i+1)).
SetAttr("onclick", "pageNav(event);")
cont.Append(img)
}
}
body.Tag("a").Class("navigate").Id("left").SetAttr("href", "javascript:void(0)")
body.Tag("a").Class("navigate").Id("right").SetAttr("href", "javascript:void(0)")
body.Tag("script").Asis(readerJavascript)
}
return html
}

683
views/static/hotkeys.js Normal file
View file

@ -0,0 +1,683 @@
/**!
* hotkeys-js v3.13.7
* A simple micro-library for defining and dispatching keyboard shortcuts. It has no dependencies.
*
* Copyright (c) 2024 kenny wong <wowohoo@qq.com>
* https://github.com/jaywcjlove/hotkeys-js.git
*
* @website: https://jaywcjlove.github.io/hotkeys-js
* Licensed under the MIT license
*/
(function (global, factory) {
typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() :
typeof define === 'function' && define.amd ? define(factory) :
(global = typeof globalThis !== 'undefined' ? globalThis : global || self, global.hotkeys = factory());
})(this, (function () { 'use strict';
const isff = typeof navigator !== 'undefined' ? navigator.userAgent.toLowerCase().indexOf('firefox') > 0 : false;
// 绑定事件
function addEvent(object, event, method, useCapture) {
if (object.addEventListener) {
object.addEventListener(event, method, useCapture);
} else if (object.attachEvent) {
object.attachEvent("on".concat(event), method);
}
}
function removeEvent(object, event, method, useCapture) {
if (object.removeEventListener) {
object.removeEventListener(event, method, useCapture);
} else if (object.detachEvent) {
object.detachEvent("on".concat(event), method);
}
}
// 修饰键转换成对应的键码
function getMods(modifier, key) {
const mods = key.slice(0, key.length - 1);
for (let i = 0; i < mods.length; i++) mods[i] = modifier[mods[i].toLowerCase()];
return mods;
}
// 处理传的key字符串转换成数组
function getKeys(key) {
if (typeof key !== 'string') key = '';
key = key.replace(/\s/g, ''); // 匹配任何空白字符,包括空格、制表符、换页符等等
const keys = key.split(','); // 同时设置多个快捷键,以','分割
let index = keys.lastIndexOf('');
// 快捷键可能包含',',需特殊处理
for (; index >= 0;) {
keys[index - 1] += ',';
keys.splice(index, 1);
index = keys.lastIndexOf('');
}
return keys;
}
// 比较修饰键的数组
function compareArray(a1, a2) {
const arr1 = a1.length >= a2.length ? a1 : a2;
const arr2 = a1.length >= a2.length ? a2 : a1;
let isIndex = true;
for (let i = 0; i < arr1.length; i++) {
if (arr2.indexOf(arr1[i]) === -1) isIndex = false;
}
return isIndex;
}
// Special Keys
const _keyMap = {
backspace: 8,
'⌫': 8,
tab: 9,
clear: 12,
enter: 13,
'↩': 13,
return: 13,
esc: 27,
escape: 27,
space: 32,
left: 37,
up: 38,
right: 39,
down: 40,
del: 46,
delete: 46,
ins: 45,
insert: 45,
home: 36,
end: 35,
pageup: 33,
pagedown: 34,
capslock: 20,
num_0: 96,
num_1: 97,
num_2: 98,
num_3: 99,
num_4: 100,
num_5: 101,
num_6: 102,
num_7: 103,
num_8: 104,
num_9: 105,
num_multiply: 106,
num_add: 107,
num_enter: 108,
num_subtract: 109,
num_decimal: 110,
num_divide: 111,
'⇪': 20,
',': 188,
'.': 190,
'/': 191,
'`': 192,
'-': isff ? 173 : 189,
'=': isff ? 61 : 187,
';': isff ? 59 : 186,
'\'': 222,
'[': 219,
']': 221,
'\\': 220
};
// Modifier Keys
const _modifier = {
// shiftKey
'⇧': 16,
shift: 16,
// altKey
'⌥': 18,
alt: 18,
option: 18,
// ctrlKey
'⌃': 17,
ctrl: 17,
control: 17,
// metaKey
'⌘': 91,
cmd: 91,
command: 91
};
const modifierMap = {
16: 'shiftKey',
18: 'altKey',
17: 'ctrlKey',
91: 'metaKey',
shiftKey: 16,
ctrlKey: 17,
altKey: 18,
metaKey: 91
};
const _mods = {
16: false,
18: false,
17: false,
91: false
};
const _handlers = {};
// F1~F12 special key
for (let k = 1; k < 20; k++) {
_keyMap["f".concat(k)] = 111 + k;
}
let _downKeys = []; // 记录摁下的绑定键
let winListendFocus = null; // window是否已经监听了focus事件
let _scope = 'all'; // 默认热键范围
const elementEventMap = new Map(); // 已绑定事件的节点记录
// 返回键码
const code = x => _keyMap[x.toLowerCase()] || _modifier[x.toLowerCase()] || x.toUpperCase().charCodeAt(0);
const getKey = x => Object.keys(_keyMap).find(k => _keyMap[k] === x);
const getModifier = x => Object.keys(_modifier).find(k => _modifier[k] === x);
// 设置获取当前范围(默认为'所有'
function setScope(scope) {
_scope = scope || 'all';
}
// 获取当前范围
function getScope() {
return _scope || 'all';
}
// 获取摁下绑定键的键值
function getPressedKeyCodes() {
return _downKeys.slice(0);
}
function getPressedKeyString() {
return _downKeys.map(c => getKey(c) || getModifier(c) || String.fromCharCode(c));
}
function getAllKeyCodes() {
const result = [];
Object.keys(_handlers).forEach(k => {
_handlers[k].forEach(_ref => {
let {
key,
scope,
mods,
shortcut
} = _ref;
result.push({
scope,
shortcut,
mods,
keys: key.split('+').map(v => code(v))
});
});
});
return result;
}
// 表单控件控件判断 返回 Boolean
// hotkey is effective only when filter return true
function filter(event) {
const target = event.target || event.srcElement;
const {
tagName
} = target;
let flag = true;
const isInput = tagName === 'INPUT' && !['checkbox', 'radio', 'range', 'button', 'file', 'reset', 'submit', 'color'].includes(target.type);
// ignore: isContentEditable === 'true', <input> and <textarea> when readOnly state is false, <select>
if (target.isContentEditable || (isInput || tagName === 'TEXTAREA' || tagName === 'SELECT') && !target.readOnly) {
flag = false;
}
return flag;
}
// 判断摁下的键是否为某个键返回true或者false
function isPressed(keyCode) {
if (typeof keyCode === 'string') {
keyCode = code(keyCode); // 转换成键码
}
return _downKeys.indexOf(keyCode) !== -1;
}
// 循环删除handlers中的所有 scope(范围)
function deleteScope(scope, newScope) {
let handlers;
let i;
// 没有指定scope获取scope
if (!scope) scope = getScope();
for (const key in _handlers) {
if (Object.prototype.hasOwnProperty.call(_handlers, key)) {
handlers = _handlers[key];
for (i = 0; i < handlers.length;) {
if (handlers[i].scope === scope) {
const deleteItems = handlers.splice(i, 1);
deleteItems.forEach(_ref2 => {
let {
element
} = _ref2;
return removeKeyEvent(element);
});
} else {
i++;
}
}
}
}
// 如果scope被删除将scope重置为all
if (getScope() === scope) setScope(newScope || 'all');
}
// 清除修饰键
function clearModifier(event) {
let key = event.keyCode || event.which || event.charCode;
const i = _downKeys.indexOf(key);
// 从列表中清除按压过的键
if (i >= 0) {
_downKeys.splice(i, 1);
}
// 特殊处理 cmmand 键,在 cmmand 组合快捷键 keyup 只执行一次的问题
if (event.key && event.key.toLowerCase() === 'meta') {
_downKeys.splice(0, _downKeys.length);
}
// 修饰键 shiftKey altKey ctrlKey (command||metaKey) 清除
if (key === 93 || key === 224) key = 91;
if (key in _mods) {
_mods[key] = false;
// 将修饰键重置为false
for (const k in _modifier) if (_modifier[k] === key) hotkeys[k] = false;
}
}
function unbind(keysInfo) {
// unbind(), unbind all keys
if (typeof keysInfo === 'undefined') {
Object.keys(_handlers).forEach(key => {
Array.isArray(_handlers[key]) && _handlers[key].forEach(info => eachUnbind(info));
delete _handlers[key];
});
removeKeyEvent(null);
} else if (Array.isArray(keysInfo)) {
// support like : unbind([{key: 'ctrl+a', scope: 's1'}, {key: 'ctrl-a', scope: 's2', splitKey: '-'}])
keysInfo.forEach(info => {
if (info.key) eachUnbind(info);
});
} else if (typeof keysInfo === 'object') {
// support like unbind({key: 'ctrl+a, ctrl+b', scope:'abc'})
if (keysInfo.key) eachUnbind(keysInfo);
} else if (typeof keysInfo === 'string') {
for (var _len = arguments.length, args = new Array(_len > 1 ? _len - 1 : 0), _key = 1; _key < _len; _key++) {
args[_key - 1] = arguments[_key];
}
// support old method
// eslint-disable-line
let [scope, method] = args;
if (typeof scope === 'function') {
method = scope;
scope = '';
}
eachUnbind({
key: keysInfo,
scope,
method,
splitKey: '+'
});
}
}
// 解除绑定某个范围的快捷键
const eachUnbind = _ref3 => {
let {
key,
scope,
method,
splitKey = '+'
} = _ref3;
const multipleKeys = getKeys(key);
multipleKeys.forEach(originKey => {
const unbindKeys = originKey.split(splitKey);
const len = unbindKeys.length;
const lastKey = unbindKeys[len - 1];
const keyCode = lastKey === '*' ? '*' : code(lastKey);
if (!_handlers[keyCode]) return;
// 判断是否传入范围,没有就获取范围
if (!scope) scope = getScope();
const mods = len > 1 ? getMods(_modifier, unbindKeys) : [];
const unbindElements = [];
_handlers[keyCode] = _handlers[keyCode].filter(record => {
// 通过函数判断,是否解除绑定,函数相等直接返回
const isMatchingMethod = method ? record.method === method : true;
const isUnbind = isMatchingMethod && record.scope === scope && compareArray(record.mods, mods);
if (isUnbind) unbindElements.push(record.element);
return !isUnbind;
});
unbindElements.forEach(element => removeKeyEvent(element));
});
};
// 对监听对应快捷键的回调函数进行处理
function eventHandler(event, handler, scope, element) {
if (handler.element !== element) {
return;
}
let modifiersMatch;
// 看它是否在当前范围
if (handler.scope === scope || handler.scope === 'all') {
// 检查是否匹配修饰符如果有返回true
modifiersMatch = handler.mods.length > 0;
for (const y in _mods) {
if (Object.prototype.hasOwnProperty.call(_mods, y)) {
if (!_mods[y] && handler.mods.indexOf(+y) > -1 || _mods[y] && handler.mods.indexOf(+y) === -1) {
modifiersMatch = false;
}
}
}
// 调用处理程序,如果是修饰键不做处理
if (handler.mods.length === 0 && !_mods[16] && !_mods[18] && !_mods[17] && !_mods[91] || modifiersMatch || handler.shortcut === '*') {
handler.keys = [];
handler.keys = handler.keys.concat(_downKeys);
if (handler.method(event, handler) === false) {
if (event.preventDefault) event.preventDefault();else event.returnValue = false;
if (event.stopPropagation) event.stopPropagation();
if (event.cancelBubble) event.cancelBubble = true;
}
}
}
}
// 处理keydown事件
function dispatch(event, element) {
const asterisk = _handlers['*'];
let key = event.keyCode || event.which || event.charCode;
// 表单控件过滤 默认表单控件不触发快捷键
if (!hotkeys.filter.call(this, event)) return;
// Gecko(Firefox)的command键值224在Webkit(Chrome)中保持一致
// Webkit左右 command 键值不一样
if (key === 93 || key === 224) key = 91;
/**
* Collect bound keys
* If an Input Method Editor is processing key input and the event is keydown, return 229.
* https://stackoverflow.com/questions/25043934/is-it-ok-to-ignore-keydown-events-with-keycode-229
* http://lists.w3.org/Archives/Public/www-dom/2010JulSep/att-0182/keyCode-spec.html
*/
if (_downKeys.indexOf(key) === -1 && key !== 229) _downKeys.push(key);
/**
* Jest test cases are required.
* ===============================
*/
['ctrlKey', 'altKey', 'shiftKey', 'metaKey'].forEach(keyName => {
const keyNum = modifierMap[keyName];
if (event[keyName] && _downKeys.indexOf(keyNum) === -1) {
_downKeys.push(keyNum);
} else if (!event[keyName] && _downKeys.indexOf(keyNum) > -1) {
_downKeys.splice(_downKeys.indexOf(keyNum), 1);
} else if (keyName === 'metaKey' && event[keyName] && _downKeys.length === 3) {
/**
* Fix if Command is pressed:
* ===============================
*/
if (!(event.ctrlKey || event.shiftKey || event.altKey)) {
_downKeys = _downKeys.slice(_downKeys.indexOf(keyNum));
}
}
});
/**
* -------------------------------
*/
if (key in _mods) {
_mods[key] = true;
// 将特殊字符的key注册到 hotkeys 上
for (const k in _modifier) {
if (_modifier[k] === key) hotkeys[k] = true;
}
if (!asterisk) return;
}
// 将 modifierMap 里面的修饰键绑定到 event 中
for (const e in _mods) {
if (Object.prototype.hasOwnProperty.call(_mods, e)) {
_mods[e] = event[modifierMap[e]];
}
}
/**
* https://github.com/jaywcjlove/hotkeys/pull/129
* This solves the issue in Firefox on Windows where hotkeys corresponding to special characters would not trigger.
* An example of this is ctrl+alt+m on a Swedish keyboard which is used to type μ.
* Browser support: https://caniuse.com/#feat=keyboardevent-getmodifierstate
*/
if (event.getModifierState && !(event.altKey && !event.ctrlKey) && event.getModifierState('AltGraph')) {
if (_downKeys.indexOf(17) === -1) {
_downKeys.push(17);
}
if (_downKeys.indexOf(18) === -1) {
_downKeys.push(18);
}
_mods[17] = true;
_mods[18] = true;
}
// 获取范围 默认为 `all`
const scope = getScope();
// 对任何快捷键都需要做的处理
if (asterisk) {
for (let i = 0; i < asterisk.length; i++) {
if (asterisk[i].scope === scope && (event.type === 'keydown' && asterisk[i].keydown || event.type === 'keyup' && asterisk[i].keyup)) {
eventHandler(event, asterisk[i], scope, element);
}
}
}
// key 不在 _handlers 中返回
if (!(key in _handlers)) return;
const handlerKey = _handlers[key];
const keyLen = handlerKey.length;
for (let i = 0; i < keyLen; i++) {
if (event.type === 'keydown' && handlerKey[i].keydown || event.type === 'keyup' && handlerKey[i].keyup) {
if (handlerKey[i].key) {
const record = handlerKey[i];
const {
splitKey
} = record;
const keyShortcut = record.key.split(splitKey);
const _downKeysCurrent = []; // 记录当前按键键值
for (let a = 0; a < keyShortcut.length; a++) {
_downKeysCurrent.push(code(keyShortcut[a]));
}
if (_downKeysCurrent.sort().join('') === _downKeys.sort().join('')) {
// 找到处理内容
eventHandler(event, record, scope, element);
}
}
}
}
}
function hotkeys(key, option, method) {
_downKeys = [];
const keys = getKeys(key); // 需要处理的快捷键列表
let mods = [];
let scope = 'all'; // scope默认为all所有范围都有效
let element = document; // 快捷键事件绑定节点
let i = 0;
let keyup = false;
let keydown = true;
let splitKey = '+';
let capture = false;
let single = false; // 单个callback
// 对为设定范围的判断
if (method === undefined && typeof option === 'function') {
method = option;
}
if (Object.prototype.toString.call(option) === '[object Object]') {
if (option.scope) scope = option.scope; // eslint-disable-line
if (option.element) element = option.element; // eslint-disable-line
if (option.keyup) keyup = option.keyup; // eslint-disable-line
if (option.keydown !== undefined) keydown = option.keydown; // eslint-disable-line
if (option.capture !== undefined) capture = option.capture; // eslint-disable-line
if (typeof option.splitKey === 'string') splitKey = option.splitKey; // eslint-disable-line
if (option.single === true) single = true; // eslint-disable-line
}
if (typeof option === 'string') scope = option;
// 如果只允许单个callback先unbind
if (single) unbind(key, scope);
// 对于每个快捷键进行处理
for (; i < keys.length; i++) {
key = keys[i].split(splitKey); // 按键列表
mods = [];
// 如果是组合快捷键取得组合快捷键
if (key.length > 1) mods = getMods(_modifier, key);
// 将非修饰键转化为键码
key = key[key.length - 1];
key = key === '*' ? '*' : code(key); // *表示匹配所有快捷键
// 判断key是否在_handlers中不在就赋一个空数组
if (!(key in _handlers)) _handlers[key] = [];
_handlers[key].push({
keyup,
keydown,
scope,
mods,
shortcut: keys[i],
method,
key: keys[i],
splitKey,
element
});
}
// 在全局document上设置快捷键
if (typeof element !== 'undefined' && window) {
if (!elementEventMap.has(element)) {
const keydownListener = function () {
let event = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : window.event;
return dispatch(event, element);
};
const keyupListenr = function () {
let event = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : window.event;
dispatch(event, element);
clearModifier(event);
};
elementEventMap.set(element, {
keydownListener,
keyupListenr,
capture
});
addEvent(element, 'keydown', keydownListener, capture);
addEvent(element, 'keyup', keyupListenr, capture);
}
if (!winListendFocus) {
const listener = () => {
_downKeys = [];
};
winListendFocus = {
listener,
capture
};
addEvent(window, 'focus', listener, capture);
}
}
}
function trigger(shortcut) {
let scope = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : 'all';
Object.keys(_handlers).forEach(key => {
const dataList = _handlers[key].filter(item => item.scope === scope && item.shortcut === shortcut);
dataList.forEach(data => {
if (data && data.method) {
data.method();
}
});
});
}
// 销毁事件,unbind之后判断element上是否还有键盘快捷键如果没有移除监听
function removeKeyEvent(element) {
const values = Object.values(_handlers).flat();
const findindex = values.findIndex(_ref4 => {
let {
element: el
} = _ref4;
return el === element;
});
if (findindex < 0) {
const {
keydownListener,
keyupListenr,
capture
} = elementEventMap.get(element) || {};
if (keydownListener && keyupListenr) {
removeEvent(element, 'keyup', keyupListenr, capture);
removeEvent(element, 'keydown', keydownListener, capture);
elementEventMap.delete(element);
}
}
if (values.length <= 0 || elementEventMap.size <= 0) {
// 移除所有的元素上的监听
const eventKeys = Object.keys(elementEventMap);
eventKeys.forEach(el => {
const {
keydownListener,
keyupListenr,
capture
} = elementEventMap.get(el) || {};
if (keydownListener && keyupListenr) {
removeEvent(el, 'keyup', keyupListenr, capture);
removeEvent(el, 'keydown', keydownListener, capture);
elementEventMap.delete(el);
}
});
// 清空 elementEventMap
elementEventMap.clear();
// 清空 _handlers
Object.keys(_handlers).forEach(key => delete _handlers[key]);
// 移除window上的focus监听
if (winListendFocus) {
const {
listener,
capture
} = winListendFocus;
removeEvent(window, 'focus', listener, capture);
winListendFocus = null;
}
}
}
const _api = {
getPressedKeyString,
setScope,
getScope,
deleteScope,
getPressedKeyCodes,
getAllKeyCodes,
isPressed,
filter,
trigger,
unbind,
keyMap: _keyMap,
modifier: _modifier,
modifierMap
};
for (const a in _api) {
if (Object.prototype.hasOwnProperty.call(_api, a)) {
hotkeys[a] = _api[a];
}
}
if (typeof window !== 'undefined') {
const _hotkeys = window.hotkeys;
hotkeys.noConflict = deep => {
if (deep && window.hotkeys === hotkeys) {
window.hotkeys = _hotkeys;
}
return hotkeys;
};
window.hotkeys = hotkeys;
}
return hotkeys;
}));

2
views/static/hotkeys.min.js vendored Normal file

File diff suppressed because one or more lines are too long

1
views/static/htmx.min.js vendored Normal file

File diff suppressed because one or more lines are too long

46
views/static/index.js Normal file
View file

@ -0,0 +1,46 @@
let searchBar = document.querySelector('#search-bar input');
hotkeys('/', {keyup: true}, function (event, _){
if (event.type != 'keyup') {
return;
}
console.log(searchBar);
searchBar.focus();
});
/**
* @param {Event & {target: HTMLElement}} event
*/
function inspectSetTimeout(event) {
let target = event.target;
let id = setTimeout(
() => {
let targetId = '#' + target.id;
htmx.trigger(targetId, 'inspect', {});
// let hoveringCover = document.querySelector(`${targetId} img`);
// let inspectorImage = document.querySelector('.float img');
// if (inspectorImage == null) {
// return;
// }
// let cloned = hoveringCover.cloneNode();
// cloned.id = 'cloned';
// inspectorImage.before(cloned)
},
500
);
target.dataset['timeout'] = id
}
/**
* @param {Event & {target: HTMLElement}} event
*/
function inspectClearTimeout(event) {
let target = event.target;
clearTimeout(target.dataset['timeout'])
}

6
views/static/panzoom.min.js vendored Normal file

File diff suppressed because one or more lines are too long

104
views/static/reader.js Normal file
View file

@ -0,0 +1,104 @@
let nextButton = document.querySelector('#next-button');
let previousButton = document.querySelector('#previous-button');
let startButton = document.querySelector('#start-button');
let endButton = document.querySelector('#end-button');
let pageCounter = document.querySelector('#page-counter');
let leftNavigate = document.querySelector('#left');
let rightNavigate = document.querySelector('#right');
let space = document.querySelector('#pages-container');
let pages = document.querySelectorAll('.page');
let currentPage = space.dataset.page - 1;
function updateURL() {
const url = new URL(window.location.href);
// console.log(url.href);
// https://example.com/?a=hello&b=world
// console.log(url.origin);
// https://example.com
const new_params = new URLSearchParams([
['page', currentPage+1]
// ...Array.from(url.searchParams.entries()),
]).toString();
// console.log(new_params);
// a=hello&b=world&c=a&d=2&e=false
window.history.replaceState(null, "", `${url.pathname}?${new_params}`)
}
function updatePages() {
currentPage = Math.max(0, Math.min(currentPage, pages.length-1))
for (let i = 0; i < pages.length; i++) {
pages[i].classList.add("hidden")
if (i == currentPage) {
pages[i].classList.remove("hidden")
}
}
pageCounter.textContent = currentPage + 1;
updateURL();
}
function pageUpdater(x) {
return () => {
currentPage += x;
updatePages();
}
}
function pageSetter(x) {
return () => {
currentPage = x;
updatePages();
}
}
let nextPage = pageUpdater(1);
let previousPage = pageUpdater(-1);
nextButton.onclick = nextPage;
previousButton.onclick = previousPage;
leftNavigate.onclick = previousPage;
rightNavigate.onclick = nextPage;
startButton.onclick = pageSetter(0);
endButton.onclick = pageSetter(pages.length-1);
function pageNav(e) {
console.log(e.offsetX);
console.log(e.offsetY);
console.log(e);
nextPage();
}
const pz = Panzoom(space, { // nada aqui funciona?
// onClick: function (e) {
// console.log(e)
// return false;
// },
// maxScale: 5,
// maxZoom: 0.1,
// minZoom: 0.1
// animate: true,
// duration: 200,
// pinchSpeed: 0,
// zoomSpeed: 0.0001,
// easing: "ease-in-out",
});
// panzoom.zoom(1 / 1.1);
space.parentElement.addEventListener('wheel', pz.zoomWithWheel)
updatePages();

382
views/static/styles.css Normal file
View file

@ -0,0 +1,382 @@
:root {
--main-white: #f7f7f7;
--almost-white: #cfcfcf;
--main-shadow: #0000006b;
--sly-gray: #292929;
--haughty-gray: #464646;
--corny-red: #a70000;
--picky-magenta: #690060;
--deep-blue: #000077;
--darker-gray: #262626;
--silver-lining-white: #5f5f5f;
--main-margin: 62px;
}
.page {
box-sizing: border-box;
position: absolute;
height: 100%;
margin: 0 auto 0 auto;
}
.hidden {
display: none;
}
#pages-container {
flex-grow: 1;
display: flex;
justify-content: center;
}
html,
body {
margin: 0;
height: 100%;
}
body {
display: flex;
flex-direction: column;
color: white;
background-color: var(--sly-gray);
}
#reader nav {
margin: 10px;
position: absolute;
z-index: 1;
display: flex;
gap: 10px;
padding: 0 8px 0 8px;
background-color: var(--main-white);
box-shadow: 1px 1px 17px 0px var(--main-shadow);
border-radius: 3px;
color: black;
font-family: monospace;
}
#controls {
margin: auto 0 auto 0;
display: flex;
gap: 5px;
&>button {
border-radius: 2px;
border: 0;
background-color: var(--main-white);
box-shadow: 0px 0px 1px 1px rgb(from var(--sly-gray) r g b / 0.34);
}
}
.navigate {
position: absolute;
height: 100%;
width: 120px;
&#left {
left: 0;
}
&#right {
right: 0;
}
}
#index nav {
display: flex;
justify-content: flex-end;
/* margin-bottom: 11px; */
/* background-color: var(--haughty-gray); */
border-bottom: 1px solid var(--silver-lining-white);
padding: 5px;
/* box-shadow: black 1px 1px 1px 1px; */
}
#index #content {
width: 70vw;
margin: auto;
padding: auto;
}
.container {
background-color: var(--darker-gray);
border: 1px solid var(--silver-lining-white);
box-shadow: black 1px 1px 1px 1px;
border-radius: 2px;
padding: 20px;
}
#index #center {
margin: var(--main-margin) var(--main-margin) auto var(--main-margin);
}
.top {
/* width: 100%; */
/* display: flex; */
/* flex-direction: row; */
display: flex;
margin-bottom: 50px;
}
div#search-bar {
display: block;
width: auto;
flex: 1;
margin: var(--main-margin);
margin-left: 0;
margin-top: 31px;
& input {
width: 100%;
border-radius: 7px;
padding: 8px 10px;
outline: none;
display: flex;
align-items: center;
justify-content: center;
vertical-align: unset;
position: relative;
margin: 0;
cursor: pointer;
border: 0.1rem solid var(--haughty-gray);
background: var(--darker-gray);
color: white;
-webkit-transition: all 0.1s ease;
transition: all 0.1s ease;
&:hover {
border-color: #275efe;
}
&:focus {
box-shadow: 0 0 0 2px rgba(39, 94, 254, 0.3);
border-color: #275efe;
}
&:disabled {
background: #f6f8ff;
cursor: not-allowed;
opacity: 0.9;
border-color: #bbc1e1;
}
}
}
#index header {
width: min-content;
padding: 20px;
font-family: sans-serif;
/* margin: auto; */
/* margin-left: auto; */
/* margin-top: 50px; */
/* margin-right: 100px; */
& #omakase {
writing-mode: vertical-rl;
text-orientation: mixed;
text-align: right;
margin-left: auto;
& h2 {
margin: 0;
}
& a {
color: white;
font-size: x-large;
font-weight: 600;
}
}
& #stats {
text-align: right;
text-wrap: nowrap;
&>p {
margin: 0;
}
}
}
footer {
width: 100%;
/* position: absolute; */
/* bottom: 0; */
text-align: center;
font-size: small;
opacity: 0.4;
}
/* footer { */
/* font-size: ; */
/* } */
.name {
display: flex;
align-items: center;
padding-left: 7px;
padding-right: 7px;
line-height: 1.42857143;
font-size: 15px;
}
.tag,
.caption {
font-family: Noto sans, sans-serif;
font-weight: 600;
color: white;
text-decoration-line: none;
}
.tag {
display: inline-flex;
color: var(--almost-white);
text-decoration-line: none;
padding: 0.13em;
&>span:first-child {
font-family: monospace;
font-weight: 900;
border-radius: 3px 0 0 3px;
}
&>span:nth-child(2) {
background-color: var(--haughty-gray);
}
&>span:last-child {
border-radius: 0 3px 3px 0;
}
}
.tag:hover {
filter: brightness(1.3);
}
.tag:active {
filter: brightness(0.6);
}
.any {
background-color: var(--corny-red);
}
.male {
background-color: var(--deep-blue);
}
.female {
background-color: var(--picky-magenta);
}
.cover {
display: inline-block;
vertical-align: top;
}
.caption {
overflow: hidden;
background-color: var(--haughty-gray);
padding: 3px;
}
img {
filter: blur(15px);
}
.thumbnail,
.cover {
width: 148px;
height: auto;
opacity: 0.93;
}
#search-results {
& #controls {
width: calc(min-content / 2);
height: min-content;
margin-bottom: calc(var(--main-margin) / 2);
position: sticky;
top: 0;
z-index: 10;
& .page-control {
cursor: pointer;
background-color: unset;
margin: unset;
border-radius: 3px;
color: white;
border: 1px solid var(--silver-lining-white);
margin: 5px;
padding: 3px 10px 3px 10px;
font-weight: 700;
}
}
& #results {
display: grid;
width: 65%;
grid-template-columns: repeat(5, 1fr);
@media (min-width: 1200px) {
grid-template-columns: repeat(6, 1fr);
}
}
& #inspector {
position: sticky;
top: 0;
& .float {
float: right;
height: 100vh;
overflow-y: scroll;
scrollbar-width: none;
max-width: 35%;
font:
bold 18px/21px Helvetica,
Arial,
sans-serif;
& img {
max-height: 95vh;
max-width: 100%;
/* width: auto; */
/* height: auto; */
}
& #inspector-image-placeholder {
width: 100%;
height: 95vh;
background-color: black;
}
& h1 {
font-size: xx-large;
font-weight: 700;
margin-top: 10px;
font-size: x-large;
}
& h2 {
opacity: 0.37;
font-size: large;
}
& div {
margin-bottom: 100%;
}
}
}
}