commit 6c9d74462a8778bd31c54a08f81c7ea4907fd802
parent c5e7929180b92337272612555a4cd4c3ae14c959
Author: breadcat <breadcat@users.noreply.github.com>
Date: Mon, 5 Jan 2026 16:20:31 +0000
Add CBZ upload and display functionality
Also beginning to split functions into separate files
Diffstat:
6 files changed, 501 insertions(+), 3 deletions(-)
diff --git a/include-cbz.go b/include-cbz.go
@@ -0,0 +1,355 @@
+package main
+
+import (
+ "archive/zip"
+ "fmt"
+ "image"
+ "image/jpeg"
+ "log"
+ "net/http"
+ "io"
+ "os"
+ "path/filepath"
+ "sort"
+ "strings"
+)
+
+// generateCBZThumbnail creates a 2x2 collage thumbnail from a CBZ file
+func generateCBZThumbnail(cbzPath, uploadDir, filename string) error {
+
+ thumbDir := filepath.Join(uploadDir, "thumbnails")
+
+ if err := os.MkdirAll(thumbDir, 0755); err != nil {
+ return fmt.Errorf("failed to create thumbnails directory: %v", err)
+ }
+
+ thumbPath := filepath.Join(thumbDir, filename+".jpg")
+
+ // Open the CBZ (ZIP) file
+ r, err := zip.OpenReader(cbzPath)
+ if err != nil {
+ return fmt.Errorf("failed to open CBZ: %v", err)
+ }
+ defer r.Close()
+
+ // Get list of image files from the archive
+ var imageFiles []*zip.File
+ for _, f := range r.File {
+ if !f.FileInfo().IsDir() {
+ ext := strings.ToLower(filepath.Ext(f.Name))
+ if ext == ".jpg" || ext == ".jpeg" || ext == ".png" || ext == ".webp" {
+ imageFiles = append(imageFiles, f)
+ }
+ }
+ }
+
+ if len(imageFiles) == 0 {
+ return fmt.Errorf("no images found in CBZ")
+ }
+
+ // Sort files by name to get consistent ordering
+ sort.Slice(imageFiles, func(i, j int) bool {
+ return imageFiles[i].Name < imageFiles[j].Name
+ })
+
+ // Select up to 4 images evenly distributed
+ var selectedFiles []*zip.File
+ if len(imageFiles) <= 4 {
+ selectedFiles = imageFiles
+ } else {
+ // Pick 4 images evenly distributed through the comic
+ step := len(imageFiles) / 4
+ for i := 0; i < 4; i++ {
+ selectedFiles = append(selectedFiles, imageFiles[i*step])
+ }
+ }
+
+
+ // Load the selected images
+ var images []image.Image
+ for _, f := range selectedFiles {
+ rc, err := f.Open()
+ if err != nil {
+ log.Printf("CBZ Thumbnail: Failed to open %s: %v", f.Name, err)
+ continue
+ }
+
+ img, _, err := image.Decode(rc)
+ rc.Close()
+ if err != nil {
+ log.Printf("CBZ Thumbnail: Failed to decode %s: %v", f.Name, err)
+ continue
+ }
+ images = append(images, img)
+ }
+
+
+ if len(images) == 0 {
+ return fmt.Errorf("failed to decode any images from CBZ")
+ }
+
+ // Create collage
+ collage := createCollage(images, 400) // 400px target width
+
+ // Save as JPEG
+ outFile, err := os.Create(thumbPath)
+ if err != nil {
+ return fmt.Errorf("failed to create thumbnail file: %v", err)
+ }
+ defer outFile.Close()
+
+ if err := jpeg.Encode(outFile, collage, &jpeg.Options{Quality: 85}); err != nil {
+ return fmt.Errorf("failed to encode JPEG: %v", err)
+ }
+
+ return nil
+}
+
+// createCollage creates a 2x2 grid from up to 4 images
+func createCollage(images []image.Image, targetWidth int) image.Image {
+ // Calculate cell size (half of target width)
+ cellSize := targetWidth / 2
+
+ // Create output image
+ collageImg := image.NewRGBA(image.Rect(0, 0, targetWidth, targetWidth))
+
+ // Fill with white background
+ for y := 0; y < targetWidth; y++ {
+ for x := 0; x < targetWidth; x++ {
+ collageImg.Set(x, y, image.White)
+ }
+ }
+
+ // Positions for the 2x2 grid
+ positions := []image.Point{
+ {0, 0}, // Top-left
+ {cellSize, 0}, // Top-right
+ {0, cellSize}, // Bottom-left
+ {cellSize, cellSize}, // Bottom-right
+ }
+
+ // Draw each image
+ for i, img := range images {
+ if i >= 4 {
+ break
+ }
+
+ // Resize image to fit cell
+ resized := resizeImage(img, cellSize, cellSize)
+
+ // Draw at position
+ pos := positions[i]
+ drawImage(collageImg, resized, pos.X, pos.Y)
+ }
+
+ return collageImg
+}
+
+// resizeImage resizes an image to fit within maxWidth x maxHeight while maintaining aspect ratio
+func resizeImage(img image.Image, maxWidth, maxHeight int) image.Image {
+ bounds := img.Bounds()
+ srcWidth := bounds.Dx()
+ srcHeight := bounds.Dy()
+
+ // Calculate scale to fit
+ scaleX := float64(maxWidth) / float64(srcWidth)
+ scaleY := float64(maxHeight) / float64(srcHeight)
+ scale := scaleX
+ if scaleY < scale {
+ scale = scaleY
+ }
+
+ newWidth := int(float64(srcWidth) * scale)
+ newHeight := int(float64(srcHeight) * scale)
+
+ // Create new image
+ dst := image.NewRGBA(image.Rect(0, 0, newWidth, newHeight))
+
+ // Simple nearest-neighbor scaling
+ for y := 0; y < newHeight; y++ {
+ for x := 0; x < newWidth; x++ {
+ srcX := int(float64(x) / scale)
+ srcY := int(float64(y) / scale)
+ dst.Set(x, y, img.At(srcX+bounds.Min.X, srcY+bounds.Min.Y))
+ }
+ }
+
+ return dst
+}
+
+// drawImage draws src onto dst at the given position
+func drawImage(dst *image.RGBA, src image.Image, x, y int) {
+ bounds := src.Bounds()
+ for dy := 0; dy < bounds.Dy(); dy++ {
+ for dx := 0; dx < bounds.Dx(); dx++ {
+ dst.Set(x+dx, y+dy, src.At(bounds.Min.X+dx, bounds.Min.Y+dy))
+ }
+ }
+}
+
+// CBZImage represents a single image within a CBZ file
+type CBZImage struct {
+ Filename string
+ Index int
+}
+
+// getCBZImages returns a list of images in a CBZ file
+func getCBZImages(cbzPath string) ([]CBZImage, error) {
+ r, err := zip.OpenReader(cbzPath)
+ if err != nil {
+ return nil, err
+ }
+ defer r.Close()
+
+ var images []CBZImage
+ for i, f := range r.File {
+ if !f.FileInfo().IsDir() {
+ ext := strings.ToLower(filepath.Ext(f.Name))
+ if ext == ".jpg" || ext == ".jpeg" || ext == ".png" || ext == ".webp" {
+ images = append(images, CBZImage{
+ Filename: f.Name,
+ Index: i,
+ })
+ }
+ }
+ }
+
+ // Sort by filename for consistent ordering
+ sort.Slice(images, func(i, j int) bool {
+ return images[i].Filename < images[j].Filename
+ })
+
+ // Re-index after sorting
+ for i := range images {
+ images[i].Index = i
+ }
+
+ return images, nil
+}
+
+// serveCBZImage extracts and serves a specific image from a CBZ file
+func serveCBZImage(w http.ResponseWriter, cbzPath string, imageIndex int) error {
+ r, err := zip.OpenReader(cbzPath)
+ if err != nil {
+ return err
+ }
+ defer r.Close()
+
+ // Get sorted list of images
+ var imageFiles []*zip.File
+ for _, f := range r.File {
+ if !f.FileInfo().IsDir() {
+ ext := strings.ToLower(filepath.Ext(f.Name))
+ if ext == ".jpg" || ext == ".jpeg" || ext == ".png" || ext == ".webp" {
+ imageFiles = append(imageFiles, f)
+ }
+ }
+ }
+
+ sort.Slice(imageFiles, func(i, j int) bool {
+ return imageFiles[i].Name < imageFiles[j].Name
+ })
+
+ if imageIndex < 0 || imageIndex >= len(imageFiles) {
+ return fmt.Errorf("image index out of range")
+ }
+
+ targetFile := imageFiles[imageIndex]
+
+ // Set content type based on extension
+ ext := strings.ToLower(filepath.Ext(targetFile.Name))
+ switch ext {
+ case ".jpg", ".jpeg":
+ w.Header().Set("Content-Type", "image/jpeg")
+ case ".png":
+ w.Header().Set("Content-Type", "image/png")
+ case ".webp":
+ w.Header().Set("Content-Type", "image/webp")
+ }
+
+ rc, err := targetFile.Open()
+ if err != nil {
+ return err
+ }
+ defer rc.Close()
+
+ _, err = io.Copy(w, rc)
+ return err
+}
+
+// cbzViewerHandler handles the CBZ gallery viewer
+func cbzViewerHandler(w http.ResponseWriter, r *http.Request) {
+ // Extract file ID from URL
+ parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/cbz/"), "/")
+ if len(parts) < 1 {
+ renderError(w, "Invalid CBZ viewer path", http.StatusBadRequest)
+ return
+ }
+
+ fileID := parts[0]
+
+ // Get the file from database
+ var f File
+ err := db.QueryRow("SELECT id, filename, path, COALESCE(description, '') FROM files WHERE id = ?", fileID).
+ Scan(&f.ID, &f.Filename, &f.Path, &f.Description)
+ if err != nil {
+ renderError(w, "File not found", http.StatusNotFound)
+ return
+ }
+
+ cbzPath := filepath.Join(config.UploadDir, f.Filename)
+
+ // Check if requesting a specific image
+ if len(parts) >= 3 && parts[1] == "image" {
+ imageIndex := 0
+ fmt.Sscanf(parts[2], "%d", &imageIndex)
+
+ if err := serveCBZImage(w, cbzPath, imageIndex); err != nil {
+ renderError(w, "Failed to serve image", http.StatusInternalServerError)
+ }
+ return
+ }
+
+ // Get list of images
+ images, err := getCBZImages(cbzPath)
+ if err != nil {
+ renderError(w, "Failed to read CBZ contents", http.StatusInternalServerError)
+ return
+ }
+
+ // Determine which image to display
+ currentIndex := 0
+ if len(parts) >= 2 {
+ fmt.Sscanf(parts[1], "%d", ¤tIndex)
+ }
+
+ if currentIndex < 0 {
+ currentIndex = 0
+ }
+ if currentIndex >= len(images) {
+ currentIndex = len(images) - 1
+ }
+
+ // Prepare data for template
+ type CBZViewData struct {
+ File File
+ Images []CBZImage
+ CurrentIndex int
+ TotalImages int
+ HasPrev bool
+ HasNext bool
+ }
+
+ viewData := CBZViewData{
+ File: f,
+ Images: images,
+ CurrentIndex: currentIndex,
+ TotalImages: len(images),
+ HasPrev: currentIndex > 0,
+ HasNext: currentIndex < len(images)-1,
+ }
+
+ pageData := buildPageData(f.Filename, viewData)
+
+ renderTemplate(w, "cbz_viewer.html", pageData)
+}
+\ No newline at end of file
diff --git a/static/cbz-viewer.js b/static/cbz-viewer.js
@@ -0,0 +1,48 @@
+// CBZ Keyboard Navigation
+
+// Scroll to viewer on page load
+window.addEventListener('DOMContentLoaded', function() {
+ const viewer = document.querySelector('.cbz-viewer');
+ if (viewer) {
+ viewer.scrollIntoView({ behavior: 'instant', block: 'start' });
+ }
+});
+
+document.addEventListener('keydown', function(e) {
+ // Don't intercept keys if user is typing in an input/textarea
+ const activeElement = document.activeElement;
+ if (activeElement.tagName === 'INPUT' ||
+ activeElement.tagName === 'TEXTAREA' ||
+ activeElement.isContentEditable) {
+ return;
+ }
+
+ // Get data from the .cbz-viewer element
+ const viewer = document.querySelector('.cbz-viewer');
+ if (!viewer) return;
+
+ const currentIndex = parseInt(viewer.dataset.currentIndex);
+ const totalImages = parseInt(viewer.dataset.totalImages);
+ const fileID = viewer.dataset.fileId;
+
+ switch(e.key) {
+ case 'ArrowLeft':
+ case 'a':
+ if (currentIndex > 0) {
+ window.location.href = `/cbz/${fileID}/${currentIndex - 1}`;
+ }
+ break;
+ case 'ArrowRight':
+ case 'd':
+ if (currentIndex < totalImages - 1) {
+ window.location.href = `/cbz/${fileID}/${currentIndex + 1}`;
+ }
+ break;
+ case 'Home':
+ window.location.href = `/cbz/${fileID}/0`;
+ break;
+ case 'End':
+ window.location.href = `/cbz/${fileID}/${totalImages - 1}`;
+ break;
+ }
+});
+\ No newline at end of file
diff --git a/static/style.css b/static/style.css
@@ -1,5 +1,5 @@
/* main body styling */
-body,button{background:#1a1a1a;color:#cfcfcf;font-family:sans-serif;margin:0}
+body,button{background:#1a1a1a;color:#cfcfcf;font-family:sans-serif;margin:0;position: relative;}
a{color:#add8e6;text-decoration:none}
input[type="url"], input[type="text"] {background:#1a1a1a;color:#cfcfcf;border:1px solid gray;margin: 8px;padding:8px;outline: none; box-sizing: border-box}
input[type="url"]:focus, input[type="text"]:focus{border:1px solid white;background-color:#3a3a3a}
@@ -92,4 +92,32 @@ img.file-content-image {max-width:400px}
.breadcrumb{padding:1rem;font-size:1.1rem}
.breadcrumb a:hover{text-decoration:underline}
.breadcrumb span{font-weight:500}
-.breadcrumb-separator{font-size:.8em}
-\ No newline at end of file
+.breadcrumb-separator{font-size:.8em}
+
+/* cbz viewer */
+.cbz-preview,.thumb-label{text-align:center}
+.cbz-icon,.cbz-icon::after{position:absolute;transform:translate(-50%,-50%)}
+.cbz-icon{top:50%;left:50%;width:40px;height:32px;border:3px solid #fff;box-shadow:0 0 10px rgba(0,0,0,.5)}
+.cbz-icon::before{content:'';position:absolute;top:6px;left:6px;right:6px;bottom:6px;border:2px solid #fff}
+.cbz-icon::after{content:'';top:50%;left:50%;width:8px;height:8px;background:#fff;border-radius:50%}
+.cbz-viewer{max-width:1200px;margin:0 auto;padding:20px}
+.cbz-gallery h3,.cbz-navigation{margin-bottom:20px}
+.cbz-navigation{display:flex;justify-content:space-between;align-items:center;padding:15px;background:#f5f5f5;border-radius:8px;flex-wrap:wrap;gap:10px}
+.nav-btn,.page-counter{padding:8px 16px;border-radius:4px}
+.nav-buttons{display:flex;gap:10px;align-items:center}
+.nav-btn{background:#007bff;color:#fff;text-decoration:none;font-size:14px;transition:background .2s}
+.nav-btn:hover:not(.disabled){background:#0056b3}
+.nav-btn.disabled{background:#ccc;cursor:not-allowed;color:#666}
+.nav-btn.back-btn{background:#6c757d}
+.nav-btn.back-btn:hover{background:#545b62}
+.page-counter{background:#fff;font-weight:700}
+.cbz-image-container{display:flex;justify-content:center;align-items:center;margin-bottom:30px;background:#000;border-radius:8px;min-height:600px}
+.cbz-image{max-width:100%;max-height:800px;object-fit:contain}
+.cbz-gallery{margin-top:40px;padding-top:20px;border-top:2px solid #ddd}
+.gallery-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(120px,1fr));gap:15px}
+.gallery-thumb{position:relative;display:block;border:3px solid transparent;border-radius:8px;overflow:hidden;transition:border-color .2s,transform .2s;background:#f5f5f5}
+.gallery-thumb:hover{border-color:#007bff;transform:scale(1.05)}
+.gallery-thumb.active{border-color:#28a745}
+.gallery-thumb img{width:100%;height:150px;object-fit:cover;display:block}
+.thumb-label{position:absolute;bottom:0;left:0;right:0;background:rgba(0,0,0,.7);color:#fff;padding:4px;font-size:12px}
+.cbz-open-button{margin-top:15px}
diff --git a/templates/_gallery.html b/templates/_gallery.html
@@ -3,6 +3,11 @@
<a href="/file/{{.File.ID}}" title="{{.File.Filename}}">
{{if hasAnySuffix .File.Filename ".jpg" ".jpeg" ".png" ".gif" ".webp"}}
<img src="/uploads/{{.File.EscapedFilename}}">
+ {{else if hasAnySuffix .File.Filename ".cbz"}}
+ <div class="gallery-video">
+ <img src="/uploads/thumbnails/{{.File.EscapedFilename}}.jpg">
+ <div class="cbz-icon"></div>
+ </div>
{{else if hasAnySuffix .File.Filename ".mp4" ".webm" ".mov" ".m4v"}}
<div class="gallery-video">
<img src="/uploads/thumbnails/{{.File.EscapedFilename}}.jpg">
diff --git a/templates/cbz_viewer.html b/templates/cbz_viewer.html
@@ -0,0 +1,51 @@
+{{template "_header" .}}
+<h2>CBZ: {{.Data.File.Filename}}</h2>
+
+<div class="cbz-viewer"
+ data-file-id="{{.Data.File.ID}}"
+ data-current-index="{{.Data.CurrentIndex}}"
+ data-total-images="{{.Data.TotalImages}}">
+ <div class="cbz-navigation">
+ <div class="nav-buttons">
+ {{if .Data.HasPrev}}
+ <a href="/cbz/{{.Data.File.ID}}/0" class="nav-btn">⏮ First</a>
+ <a href="/cbz/{{.Data.File.ID}}/{{sub .Data.CurrentIndex 1}}" class="nav-btn">◀ Previous</a>
+ {{else}}
+ <span class="nav-btn disabled">⏮ First</span>
+ <span class="nav-btn disabled">◀ Previous</span>
+ {{end}}
+
+ <span class="page-counter">Page {{add .Data.CurrentIndex 1}} / {{.Data.TotalImages}}</span>
+
+ {{if .Data.HasNext}}
+ <a href="/cbz/{{.Data.File.ID}}/{{add .Data.CurrentIndex 1}}" class="nav-btn">Next ▶</a>
+ <a href="/cbz/{{.Data.File.ID}}/{{sub .Data.TotalImages 1}}" class="nav-btn">Last ⏭</a>
+ {{else}}
+ <span class="nav-btn disabled">Next ▶</span>
+ <span class="nav-btn disabled">Last ⏭</span>
+ {{end}}
+ </div>
+
+ <a href="/file/{{.Data.File.ID}}" class="nav-btn back-btn">← Back to File Info</a>
+ </div>
+
+ <div class="cbz-image-container">
+ <img src="/cbz/{{.Data.File.ID}}/image/{{.Data.CurrentIndex}}" alt="Page {{add .Data.CurrentIndex 1}}" class="cbz-image">
+ </div>
+
+ <div class="cbz-gallery">
+ <h3>All Pages</h3>
+ <div class="gallery-grid">
+ {{range $i, $img := .Data.Images}}
+ <a href="/cbz/{{$.Data.File.ID}}/{{$i}}" class="gallery-thumb {{if eq $i $.Data.CurrentIndex}}active{{end}}">
+ <img src="/cbz/{{$.Data.File.ID}}/image/{{$i}}" alt="Page {{add $i 1}}" loading="lazy">
+ <span class="thumb-label">{{add $i 1}}</span>
+ </a>
+ {{end}}
+ </div>
+ </div>
+</div>
+
+<script src="/static/cbz-viewer.js" defer></script>
+
+{{template "_footer"}}
+\ No newline at end of file
diff --git a/templates/file.html b/templates/file.html
@@ -61,6 +61,15 @@
{{if hasAnySuffix .Data.File.Filename ".jpg" ".jpeg" ".png" ".gif" ".webp"}}
<a href="/uploads/{{.Data.EscapedFilename}}" target="_blank"><img src="/uploads/{{.Data.EscapedFilename}}" id="imageViewer" class="file-content-image"></a><br>
<script src="/static/timestamps.js" defer></script>
+ {{else if hasAnySuffix .Data.File.Filename ".cbz"}}
+ <div class="cbz-preview">
+ <a href="/cbz/{{.Data.File.ID}}">
+ <img src="/uploads/thumbnails/{{.Data.File.Filename}}.jpg" class="file-content-image" alt="CBZ Preview">
+ </a>
+ <div class="cbz-open-button">
+ <a href="/cbz/{{.Data.File.ID}}" class="text-button" style="display: inline-block; padding: 10px 20px; margin-top: 10px;">📖 Open CBZ Viewer</a>
+ </div>
+ </div>
{{else if hasAnySuffix .Data.File.Filename ".mp4" ".webm" ".mov" ".m4v"}}
<video id="videoPlayer" controls loop muted width="600">
<source src="/uploads/{{.Data.EscapedFilename}}">