diff --git a/README.md b/README.md
index 8361e45..9f531af 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
# yoink
-A tool for downloading comics from readallcomics.com and packaging them as `.cbz` archives. Available as a CLI command or a self-hosted web application.
+A tool for downloading comics from readallcomics.com and packaging them as `.cbz` archives. Available as a CLI command or a self-hosted web application. The web UI also lets you package local image folders into `.cbz` archives directly from your browser.
## How it works
@@ -92,10 +92,21 @@ The web UI is then available at `http://localhost:8080`.
### Features
- **Download queue** — paste a comic URL into the input bar and track download progress in real time
-- **Library grid** — browse your downloaded comics as a 150×300 cover grid
+- **Local packaging** — drag and drop a folder of images (or use the file picker) to package them as a `.cbz` archive and add it to your library without downloading anything
+- **Library grid** — browse your comics as a 150×300 cover grid with title-initial placeholders for missing covers
- **Filter & sort** — filter by title and sort by newest, oldest, A–Z, or Z–A
- **One-click download** — click any cover to download the `.cbz` archive directly
+#### Packaging local images
+
+Click the upload icon (↑) in the header to open the packaging panel. Enter a title, then either:
+
+- **Drag and drop** a folder or image files onto the drop zone
+- **Select folder** to pick an entire directory at once
+- **Select files** to pick individual images
+
+Images are sorted by filename, the first image is used as the cover, and the result is saved to your library as `
/.cbz`.
+
### Library volume
Downloaded comics are stored at the path set by `YOINK_LIBRARY`. When using Docker, mount this as a volume to persist your library across container restarts:
diff --git a/web/server.go b/web/server.go
index 6415ebd..7ff7268 100644
--- a/web/server.go
+++ b/web/server.go
@@ -1,9 +1,11 @@
package web
import (
+ "archive/zip"
"embed"
"encoding/json"
"fmt"
+ "io"
"io/fs"
"net/http"
"net/url"
@@ -72,6 +74,7 @@ func (s *Server) Handler() http.Handler {
// API
mux.HandleFunc("/api/download", s.handleDownload)
+ mux.HandleFunc("/api/upload", s.handleUpload)
mux.HandleFunc("/api/comics", s.handleComics)
mux.HandleFunc("/api/jobs", s.handleJobs)
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
@@ -240,6 +243,114 @@ func (s *Server) handleJobs(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(jobs)
}
+func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost {
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+
+ // 500 MB limit
+ if err := r.ParseMultipartForm(500 << 20); err != nil {
+ http.Error(w, "request too large", http.StatusRequestEntityTooLarge)
+ return
+ }
+
+ title := strings.TrimSpace(r.FormValue("title"))
+ if title == "" {
+ http.Error(w, "title required", http.StatusBadRequest)
+ return
+ }
+ // Sanitize: no path separators or shell-special characters
+ title = filepath.Base(title)
+ title = strings.Map(func(r rune) rune {
+ if strings.ContainsRune(`/\:*?"<>|`, r) {
+ return '_'
+ }
+ return r
+ }, title)
+
+ fileHeaders := r.MultipartForm.File["images"]
+ if len(fileHeaders) == 0 {
+ http.Error(w, "no images provided", http.StatusBadRequest)
+ return
+ }
+
+ // Sort by original filename so page order is preserved
+ sort.Slice(fileHeaders, func(i, j int) bool {
+ return fileHeaders[i].Filename < fileHeaders[j].Filename
+ })
+
+ dir := filepath.Join(s.libraryPath, title)
+ if err := os.MkdirAll(dir, 0o755); err != nil {
+ http.Error(w, "failed to create directory", http.StatusInternalServerError)
+ return
+ }
+
+ cbzPath := filepath.Join(dir, title+".cbz")
+ cbzFile, err := os.Create(cbzPath)
+ if err != nil {
+ http.Error(w, "failed to create archive", http.StatusInternalServerError)
+ return
+ }
+ defer cbzFile.Close()
+
+ zw := zip.NewWriter(cbzFile)
+ defer zw.Close()
+
+ imageExts := map[string]bool{".jpg": true, ".jpeg": true, ".png": true, ".webp": true}
+ idx := 1
+
+ for _, fh := range fileHeaders {
+ ext := strings.ToLower(filepath.Ext(fh.Filename))
+ if !imageExts[ext] {
+ continue
+ }
+ if ext == ".jpeg" {
+ ext = ".jpg"
+ }
+
+ entryName := fmt.Sprintf("%03d%s", idx, ext)
+
+ src, err := fh.Open()
+ if err != nil {
+ continue
+ }
+
+ // Save first image as cover: " 001.jpg"
+ if idx == 1 {
+ coverPath := filepath.Join(dir, title+" "+entryName)
+ if cf, err := os.Create(coverPath); err == nil {
+ io.Copy(cf, src)
+ cf.Close()
+ src.Close()
+ src, err = fh.Open()
+ if err != nil {
+ continue
+ }
+ }
+ }
+
+ ze, err := zw.Create(entryName)
+ if err != nil {
+ src.Close()
+ continue
+ }
+ io.Copy(ze, src)
+ src.Close()
+ idx++
+ }
+
+ if idx == 1 {
+ // Nothing was written — no valid images
+ os.RemoveAll(dir)
+ http.Error(w, "no valid images in upload", http.StatusBadRequest)
+ return
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(map[string]string{"title": title, "status": "complete"})
+}
+
func Listen(addr string, libraryPath string) error {
srv := NewServer(libraryPath)
fmt.Printf("Yoink web server listening on %s\n", addr)
diff --git a/web/static/index.html b/web/static/index.html
index ee21afa..8244aeb 100644
--- a/web/static/index.html
+++ b/web/static/index.html
@@ -587,6 +587,206 @@
.toast-msg.is-error { border-left-color: var(--error); }
.toast-msg.fade { opacity: 0; }
+ /* ── Upload button ───────────────────────────────────────────────────── */
+ .upload-btn {
+ height: 36px;
+ width: 36px;
+ background: var(--surface2);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ color: var(--text2);
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+ transition: background 0.15s, border-color 0.15s, color 0.15s;
+ }
+
+ .upload-btn:hover { background: var(--border); border-color: var(--border2); color: var(--text); }
+
+ /* ── Upload modal ────────────────────────────────────────────────────── */
+ .modal-backdrop {
+ position: fixed;
+ inset: 0;
+ background: rgba(0,0,0,0.6);
+ backdrop-filter: blur(4px);
+ -webkit-backdrop-filter: blur(4px);
+ z-index: 200;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ opacity: 0;
+ pointer-events: none;
+ transition: opacity 0.2s;
+ }
+
+ .modal-backdrop.open { opacity: 1; pointer-events: all; }
+
+ .modal {
+ background: var(--surface);
+ border: 1px solid var(--border2);
+ border-radius: 12px;
+ padding: 28px;
+ width: 480px;
+ max-width: calc(100vw - 32px);
+ box-shadow: 0 24px 80px rgba(0,0,0,0.6);
+ transform: translateY(12px);
+ transition: transform 0.2s;
+ display: flex;
+ flex-direction: column;
+ gap: 18px;
+ }
+
+ .modal-backdrop.open .modal { transform: translateY(0); }
+
+ .modal-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ }
+
+ .modal-title {
+ font-size: 0.95rem;
+ font-weight: 700;
+ color: var(--text);
+ }
+
+ .modal-close {
+ background: none;
+ border: none;
+ color: var(--muted);
+ cursor: pointer;
+ font-size: 1.2rem;
+ line-height: 1;
+ width: 28px;
+ height: 28px;
+ border-radius: var(--radius-sm);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ transition: color 0.12s, background 0.12s;
+ }
+
+ .modal-close:hover { color: var(--text); background: var(--border); }
+
+ .upload-title-input {
+ width: 100%;
+ height: 38px;
+ padding: 0 14px;
+ background: var(--surface2);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ color: var(--text);
+ font-family: inherit;
+ font-size: 0.875rem;
+ outline: none;
+ transition: border-color 0.15s, box-shadow 0.15s;
+ }
+
+ .upload-title-input::placeholder { color: var(--muted); }
+
+ .upload-title-input:focus {
+ border-color: var(--accent);
+ box-shadow: 0 0 0 3px rgba(91,140,245,0.15);
+ }
+
+ .drop-zone {
+ border: 2px dashed var(--border2);
+ border-radius: var(--radius);
+ padding: 36px 20px;
+ text-align: center;
+ cursor: pointer;
+ transition: border-color 0.15s, background 0.15s;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 10px;
+ color: var(--muted);
+ }
+
+ .drop-zone:hover, .drop-zone.drag-over {
+ border-color: var(--accent);
+ background: var(--accent-dim);
+ color: var(--text2);
+ }
+
+ .drop-zone svg { opacity: 0.5; }
+ .drop-zone.drag-over svg { opacity: 0.8; }
+
+ .drop-zone-label {
+ font-size: 0.85rem;
+ font-weight: 500;
+ color: var(--text2);
+ }
+
+ .drop-zone-sub {
+ font-size: 0.75rem;
+ color: var(--muted);
+ }
+
+ .drop-zone-actions {
+ display: flex;
+ gap: 8px;
+ margin-top: 4px;
+ }
+
+ .pick-btn {
+ height: 30px;
+ padding: 0 14px;
+ background: var(--surface2);
+ border: 1px solid var(--border2);
+ border-radius: var(--radius-sm);
+ color: var(--text2);
+ font-family: inherit;
+ font-size: 0.75rem;
+ font-weight: 600;
+ cursor: pointer;
+ transition: border-color 0.12s, color 0.12s, background 0.12s;
+ }
+
+ .pick-btn:hover { border-color: var(--accent); color: var(--text); background: var(--accent-dim); }
+
+ .file-count {
+ font-size: 0.78rem;
+ color: var(--accent);
+ font-weight: 600;
+ min-height: 1.2em;
+ }
+
+ .upload-progress {
+ height: 3px;
+ background: var(--border);
+ border-radius: 999px;
+ overflow: hidden;
+ display: none;
+ }
+
+ .upload-progress-bar {
+ height: 100%;
+ background: var(--accent);
+ border-radius: 999px;
+ width: 0%;
+ transition: width 0.2s;
+ }
+
+ .modal-submit {
+ height: 40px;
+ background: var(--accent);
+ color: #fff;
+ border: none;
+ border-radius: var(--radius);
+ font-family: inherit;
+ font-size: 0.875rem;
+ font-weight: 700;
+ cursor: pointer;
+ transition: background 0.15s, transform 0.1s;
+ }
+
+ .modal-submit:hover { background: var(--accent-hv); }
+ .modal-submit:active { transform: scale(0.98); }
+ .modal-submit:disabled { opacity: 0.4; cursor: not-allowed; transform: none; }
+
.toast-icon {
display: inline-flex;
align-items: center;
@@ -709,8 +909,46 @@
/>
Download
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Drop images or a folder here
+
JPG, PNG, WebP — sorted by filename
+
+ Select folder
+ Select files
+
+
+
+
+
+
+
Package & add to library
+
+
+
@@ -760,6 +998,10 @@
+
+
+
+