taggart

Simple golang tagging filesystem webapp
Log | Files | Refs

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:
Ainclude-cbz.go | 356+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Astatic/cbz-viewer.js | 49+++++++++++++++++++++++++++++++++++++++++++++++++
Mstatic/style.css | 33++++++++++++++++++++++++++++++---
Mtemplates/_gallery.html | 5+++++
Atemplates/cbz_viewer.html | 52++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtemplates/file.html | 9+++++++++
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", &currentIndex) + } + + 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}}">