commit inicial
This commit is contained in:
commit
92b0a902ca
20 changed files with 2464 additions and 0 deletions
51
.air.toml
Normal file
51
.air.toml
Normal 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
3
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
foo.html
|
||||
tmp/*
|
||||
indexStyle.css
|
||||
4
Dockerfile
Normal file
4
Dockerfile
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
FROM golang:1.23 as first
|
||||
|
||||
COPY ./* .
|
||||
RUN go build -o main -v .
|
||||
9
Makefile
Normal file
9
Makefile
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
|
||||
|
||||
|
||||
run:
|
||||
go mod tidy
|
||||
go run -v .
|
||||
|
||||
dev:
|
||||
air
|
||||
258
gallery/gallery.go
Normal file
258
gallery/gallery.go
Normal 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
11
go.mod
Normal 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
39
go.sum
Normal 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
32
logging.go
Normal 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
369
main.go
Normal 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
76
state/state.go
Normal 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
256
views/index.go
Normal 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
72
views/quotes.go
Normal 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
60
views/reader.go
Normal 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
683
views/static/hotkeys.js
Normal 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
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
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
46
views/static/index.js
Normal 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
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
104
views/static/reader.js
Normal 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
382
views/static/styles.css
Normal 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%;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue