version 1.3.0

Reviewed-on: #7
This commit is contained in:
riomoo 2026-01-28 09:51:23 -05:00 committed by moobot
commit b3aa1d2646
Signed by: moobot
GPG key ID: 1F58B1369E1C199C
12 changed files with 116 additions and 31 deletions

View file

@ -35,12 +35,15 @@ RUN echo "Building Linux binary..." && \
# Build Windows binary # Build Windows binary
RUN echo "Building Windows binary..." && \ RUN echo "Building Windows binary..." && \
echo 'IDI_ICON1 ICON "./app/gopherbook/static/images/favicon/favicon.ico"' > gopherbook.rc && \
x86_64-w64-mingw32-windres gopherbook.rc -o ./app/gopherbook/gopherbook.syso && \
CGO_ENABLED=1 GOOS=windows GOARCH=amd64 CC=x86_64-w64-mingw32-gcc go build \ CGO_ENABLED=1 GOOS=windows GOARCH=amd64 CC=x86_64-w64-mingw32-gcc go build \
-a \ -a \
-ldflags="-s -w" \ -ldflags="-s -w" \
-trimpath \ -trimpath \
-o bin/gopherbook-windows.exe ./app/gopherbook && \ -o bin/gopherbook-windows.exe ./app/gopherbook && \
upx --best --ultra-brute bin/gopherbook-windows.exe upx --best --ultra-brute bin/gopherbook-windows.exe && \
rm ./app/gopherbook/gopherbook.syso gopherbook.rc
# Verify binaries were created # Verify binaries were created
RUN ls -lh bin/ && \ RUN ls -lh bin/ && \

View file

@ -2,4 +2,4 @@ build:
go build -o bin/main ./app/gopherbook go build -o bin/main ./app/gopherbook
clean: clean:
rm -rf watch etc library cache rm -rf watch etc library cache binaries releases

View file

@ -1,11 +1,21 @@
# Gopherbook Self-Hosted Comic Library & CBZ/CBT Reader <div align="center">
<img src="docs/images/gopherbook-title.png" alt="Description" width="50%">
</div>
<div align="center">
## Self-Hosted Comic Library & CBZ/CBT Reader
Gopherbook is a lightweight, single-binary, self-hosted web comic reader and library manager written in Go. Gopherbook is a lightweight, single-binary, self-hosted web comic reader and library manager written in Go.
It is designed for people who want full control over their digital comic collection (CBZ/CBT files), including support for password-protected/encrypted archives, per-user libraries, tagging, automatic organization, and a clean modern reader. It is designed for people who want full control over their digital comic collection (CBZ/CBT files), including support for password-protected/encrypted archives, per-user libraries, tagging, automatic organization, and a clean modern reader.
</div>
## License ## License
[![Custom badge](https://img.shields.io/endpoint?style=for-the-badge&url=https%3A%2F%2Fshare.jester-designs.com%2Fview%2Fpil.json)](LICENSE) [![Custom badge](docs/images/svgs/PIL.svg)](LICENSE)
## Features ## Features
@ -186,6 +196,10 @@ Pull requests are welcome! Especially:
Please open an issue first for bigger changes. Please open an issue first for bigger changes.
## If you'd like to know about the new mascot Vinny
- Check the [Wiki](/riomoo/gopherbook/wiki/Meet-Vinny.md)!
## Thanks / Credits ## Thanks / Credits
- yeka/zip password-protected ZIP support in pure Go - yeka/zip password-protected ZIP support in pure Go

View file

@ -21,6 +21,7 @@ import (
"regexp" "regexp"
"sort" "sort"
"strings" "strings"
"io/fs"
"sync" "sync"
"time" "time"
"runtime" "runtime"
@ -39,6 +40,9 @@ import (
//go:embed templates/index.html //go:embed templates/index.html
var templateFS embed.FS var templateFS embed.FS
//go:embed all:static
var staticFS embed.FS
// ComicInfo represents the standard ComicInfo.xml metadata // ComicInfo represents the standard ComicInfo.xml metadata
type ComicInfo struct { type ComicInfo struct {
XMLName xml.Name `xml:"ComicInfo"` XMLName xml.Name `xml:"ComicInfo"`
@ -135,6 +139,14 @@ func main() {
loadUsers() loadUsers()
initWatchFolders() initWatchFolders()
// Create static sub-filesystem once
staticSubFS, err := fs.Sub(staticFS, "static")
if err != nil {
log.Fatal(fmt.Errorf("failed to create static sub-filesystem: %w", err))
}
// Create handlers once and reuse
staticHandler := http.FileServer(http.FS(staticSubFS))
http.HandleFunc("/api/register", handleRegister) http.HandleFunc("/api/register", handleRegister)
http.HandleFunc("/api/login", handleLogin) http.HandleFunc("/api/login", handleLogin)
@ -154,6 +166,7 @@ func main() {
http.HandleFunc("/api/admin/delete-comic/", authMiddleware(handleDeleteComic)) http.HandleFunc("/api/admin/delete-comic/", authMiddleware(handleDeleteComic))
http.HandleFunc("/api/watch-folder", authMiddleware(handleWatchFolder)) http.HandleFunc("/api/watch-folder", authMiddleware(handleWatchFolder))
http.HandleFunc("/", serveUI) http.HandleFunc("/", serveUI)
http.Handle("/static/", http.StripPrefix("/static/", staticHandler))
go func() { go func() {
for { for {
@ -1599,7 +1612,7 @@ func saveJPEG(img image.Image, path string) error {
defer out.Close() defer out.Close()
// Lower quality = smaller memory footprint during encoding // Lower quality = smaller memory footprint during encoding
err = jpeg.Encode(out, img, &jpeg.Options{Quality: 70}) err = jpeg.Encode(out, img, &jpeg.Options{Quality: 85})
img = nil img = nil
runtime.GC() runtime.GC()
@ -1643,7 +1656,7 @@ func handleTags(w http.ResponseWriter, r *http.Request) {
} }
if req.Color == "" { if req.Color == "" {
req.Color = "#1f6feb" req.Color = "#446B6E"
} }
tagsMutex.Lock() tagsMutex.Lock()
@ -1832,7 +1845,7 @@ func handleTryKnownPasswords(w http.ResponseWriter, r *http.Request) {
tagData.Count++ tagData.Count++
tags[tag] = tagData tags[tag] = tagData
} else { } else {
tags[tag] = Tag{Name: tag, Color: "#1f6feb", Count: 1} tags[tag] = Tag{Name: tag, Color: "#446B6E", Count: 1}
} }
} }
tagsMutex.Unlock() tagsMutex.Unlock()
@ -1947,7 +1960,7 @@ func handleSetPassword(w http.ResponseWriter, r *http.Request) {
tagData.Count++ tagData.Count++
tags[tag] = tagData tags[tag] = tagData
} else { } else {
tags[tag] = Tag{Name: tag, Color: "#1f6feb", Count: 1} tags[tag] = Tag{Name: tag, Color: "#446B6E", Count: 1}
} }
} }
tagsMutex.Unlock() tagsMutex.Unlock()
@ -2419,7 +2432,7 @@ func processComic(filePath, filename string, modTime time.Time) Comic {
tagData.Count++ tagData.Count++
tags[tag] = tagData tags[tag] = tagData
} else { } else {
tags[tag] = Tag{Name: tag, Color: "#1f6feb", Count: 1} tags[tag] = Tag{Name: tag, Color: "#446B6E", Count: 1}
} }
} }
tagsMutex.Unlock() tagsMutex.Unlock()
@ -2481,7 +2494,7 @@ func loadComicMetadataLazy(comicID string) error {
tagData.Count++ tagData.Count++
tags[tag] = tagData tags[tag] = tagData
} else { } else {
tags[tag] = Tag{Name: tag, Color: "#1f6feb", Count: 1} tags[tag] = Tag{Name: tag, Color: "#446B6E", Count: 1}
} }
} }
tagsMutex.Unlock() tagsMutex.Unlock()

BIN
app/gopherbook/static/images/favicon/favicon.ico (Stored with Git LFS) Normal file

Binary file not shown.

BIN
app/gopherbook/static/images/pngs/CutePose2.png (Stored with Git LFS) Normal file

Binary file not shown.

BIN
app/gopherbook/static/images/pngs/LogoPose2.png (Stored with Git LFS) Normal file

Binary file not shown.

View file

@ -3,6 +3,7 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<link href="/static/images/favicon/favicon.ico" rel="shortcut icon" type="image/x-icon">
<title>Gopherbook</title> <title>Gopherbook</title>
<style> <style>
* { * {
@ -27,7 +28,7 @@
header { header {
background: #395E62; background: #395E62;
border-bottom: 1px solid #314C52; border-bottom: 1px solid #314C52;
padding: 20px 0; padding: 10px 0;
margin-bottom: 30px; margin-bottom: 30px;
} }
@ -43,6 +44,8 @@
h1 { h1 {
color: #1b1e2c; color: #1b1e2c;
font-size: 24px; font-size: 24px;
padding: 20px 10px;
margin: auto;
} }
.auth-section { .auth-section {
@ -243,7 +246,7 @@
.comic-artist { .comic-artist {
display: inline-block; display: inline-block;
background: #1f6feb; background: #446B6E;
color: white; color: white;
padding: 2px 8px; padding: 2px 8px;
border-radius: 12px; border-radius: 12px;
@ -275,10 +278,18 @@
width: auto; width: auto;
} }
.tab:hover {
color: #1b1e2c;
}
.tab.active { .tab.active {
color: #395E62; color: #395E62;
border-bottom-color: #395E62; border-bottom-color: #395E62;
} }
.tab.active:hover {
color: #1b1e2c;
border-bottom-color: #395E62;
}
.message { .message {
padding: 12px; padding: 12px;
@ -334,7 +345,7 @@
} }
.bookmark-indicator { .bookmark-indicator {
color: #f0883e; color: #395E62;
font-size: 14px; font-size: 14px;
display: flex; display: flex;
align-items: center; align-items: center;
@ -345,7 +356,7 @@
position: absolute; position: absolute;
top: 8px; top: 8px;
right: 8px; right: 8px;
background: #f0883e; background: #395E62;
color: white; color: white;
padding: 4px 8px; padding: 4px 8px;
border-radius: 4px; border-radius: 4px;
@ -370,7 +381,7 @@
} }
.bookmark-list-title { .bookmark-list-title {
color: #f0883e; color: #395E62;
font-weight: 600; font-weight: 600;
margin-bottom: 8px; margin-bottom: 8px;
font-size: 14px; font-size: 14px;
@ -394,8 +405,7 @@
} }
.bookmark-list-item.current { .bookmark-list-item.current {
border-color: #f0883e; border-color: #395E62;
background: rgba(240, 136, 62, 0.1);
} }
.bookmark-delete { .bookmark-delete {
@ -539,7 +549,7 @@
} }
.modal-title { .modal-title {
color: #58a6ff; color: #446B6E;
font-size: 18px; font-size: 18px;
font-weight: 600; font-weight: 600;
} }
@ -667,7 +677,7 @@
} }
.password-modal-title { .password-modal-title {
color: #58a6ff; color: #446B6E;
font-size: 18px; font-size: 18px;
font-weight: 600; font-weight: 600;
margin-bottom: 8px; margin-bottom: 8px;
@ -701,7 +711,7 @@
.password-input-group input:focus { .password-input-group input:focus {
outline: none; outline: none;
border-color: #58a6ff; border-color: #446B6E;
} }
.password-modal-buttons { .password-modal-buttons {
@ -726,16 +736,22 @@
<body> <body>
<header> <header>
<div class="header-content"> <div class="header-content">
<h1>Gopherbook</h1> <div style="display: flex;">
<div id="userInfo" class="hidden"> <img src="/static/images/pngs/CutePose2.png" alt="on book" width="67px" height="75px">
<button onclick="logout()" class="secondary-btn" style="width: auto; padding: 8px 16px;">Logout</button> <h1>Gopherbook</h1>
</div> </div>
<div id="userInfo" class="hidden">
<button onclick="logout()" class="secondary-btn" style="width: auto; padding: 8px 16px;">Logout</button>
</div>
</div> </div>
</header> </header>
<div class="container"> <div class="container">
<div id="authSection" class="auth-section"> <div id="authSection" class="auth-section">
<div id="authMessage" class="message hidden"></div> <div id="authMessage" class="message hidden"></div>
<div style="display: flex; justify-content: center; margin: 0px 10px 20px 10px;">
<img src="/static/images/pngs/LogoPose2.png" alt="wink" style="width: 100px; height auto;">
</div>
<div class="tabs"> <div class="tabs">
<button class="tab active" onclick="showTab('login')">Login</button> <button class="tab active" onclick="showTab('login')">Login</button>
<button class="tab" onclick="showTab('register')">Register</button> <button class="tab" onclick="showTab('register')">Register</button>
@ -859,7 +875,7 @@
<h4 style="font-size: 14px; color: #8b949e; margin-bottom: 12px;">Create New Tag</h4> <h4 style="font-size: 14px; color: #8b949e; margin-bottom: 12px;">Create New Tag</h4>
<div class="add-tag-form"> <div class="add-tag-form">
<input type="text" id="newTagName" class="add-tag-input" placeholder="Tag name"> <input type="text" id="newTagName" class="add-tag-input" placeholder="Tag name">
<input type="color" id="newTagColor" class="color-picker" value="#1f6feb"> <input type="color" id="newTagColor" class="color-picker" value="#446B6E">
<button onclick="createTag()" style="width: auto; padding: 8px 16px;">Add</button> <button onclick="createTag()" style="width: auto; padding: 8px 16px;">Add</button>
</div> </div>
</div> </div>
@ -1052,7 +1068,7 @@
selectedTags.forEach(function(tagName) { selectedTags.forEach(function(tagName) {
var tag = allTags.find(function(t) { return t.name === tagName; }); var tag = allTags.find(function(t) { return t.name === tagName; });
var color = tag ? tag.color : '#1f6feb'; var color = tag ? tag.color : '#446B6E';
var filter = document.createElement('div'); var filter = document.createElement('div');
filter.className = 'tag-filter'; filter.className = 'tag-filter';
filter.style.background = color; filter.style.background = color;
@ -1200,7 +1216,7 @@
tagsHTML = '<div class="comic-tags">'; tagsHTML = '<div class="comic-tags">';
comic.tags.forEach(tagName => { comic.tags.forEach(tagName => {
const tag = allTags.find(t => t.name === tagName); const tag = allTags.find(t => t.name === tagName);
const color = tag ? tag.color : '#1f6feb'; const color = tag ? tag.color : '#446B6E';
tagsHTML += '<span class="comic-tag" style="background: ' + color + '">' + tagName + '</span>'; tagsHTML += '<span class="comic-tag" style="background: ' + color + '">' + tagName + '</span>';
}); });
tagsHTML += '</div>'; tagsHTML += '</div>';
@ -1279,7 +1295,7 @@
managingComic.tags.forEach(tagName => { managingComic.tags.forEach(tagName => {
const tag = allTags.find(t => t.name === tagName); const tag = allTags.find(t => t.name === tagName);
const color = tag ? tag.color : '#1f6feb'; const color = tag ? tag.color : '#446B6E';
const item = document.createElement('div'); const item = document.createElement('div');
item.className = 'tag-item'; item.className = 'tag-item';
@ -1367,7 +1383,7 @@
if (res.ok) { if (res.ok) {
document.getElementById('newTagName').value = ''; document.getElementById('newTagName').value = '';
document.getElementById('newTagColor').value = '#1f6feb'; document.getElementById('newTagColor').value = '#446B6E';
loadTags(); loadTags();
renderAvailableTags(); renderAvailableTags();
showMessage('Tag created!', 'success'); showMessage('Tag created!', 'success');
@ -1986,6 +2002,30 @@
.catch(function(err) { .catch(function(err) {
console.error('Initial comics fetch failed:', err); console.error('Initial comics fetch failed:', err);
}); });
// Add Enter key support for login form
document.getElementById('loginUsername').addEventListener('keypress', function(e) {
if (e.key === 'Enter') {
login();
}
});
document.getElementById('loginPassword').addEventListener('keypress', function(e) {
if (e.key === 'Enter') {
login();
}
});
// Add Enter key support for register form
document.getElementById('regUsername').addEventListener('keypress', function(e) {
if (e.key === 'Enter') {
register();
}
});
document.getElementById('regPassword').addEventListener('keypress', function(e) {
if (e.key === 'Enter') {
register();
}
});
</script> </script>
</body> </body>
</html> </html>

BIN
docs/images/gopherbook-title.png (Stored with Git LFS) Normal file

Binary file not shown.

BIN
docs/images/svgs/PIL.svg (Stored with Git LFS) Normal file

Binary file not shown.

View file

@ -2,7 +2,7 @@
set -e set -e
VERSION="${1:-v1.1.000}" VERSION="${1:-v1.3.000}"
RELEASE_DIR="./releases" RELEASE_DIR="./releases"
BINARIES_DIR="./binaries" BINARIES_DIR="./binaries"

View file

@ -41,6 +41,6 @@ echo "Cleaning up old images..."
podman image prune --force podman image prune --force
echo "Update and cleanup complete!" echo "Update and cleanup complete!"
echo "Container is running with memory limit: 512MB, swap: 512MB" echo "Container is running with memory limit: 512MB"
echo "Go memory limit (GOMEMLIMIT): 512MiB" echo "Go memory limit (GOMEMLIMIT): 512MiB"
echo "Aggressive GC enabled (GOGC=50)" echo "Aggressive GC enabled (GOGC=50)"