diff --git a/Dockerfile b/Dockerfile index 0d3bd17..75ef5e3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -18,7 +18,7 @@ FROM gcr.io/distroless/base-debian12:nonroot LABEL org.opencontainers.image.title="yoink" \ org.opencontainers.image.description="Comic downloader web UI" \ - org.opencontainers.image.source="https://github.com/bryanlundberg/yoink-go" + org.opencontainers.image.source="https://git.brizzle.dev/bryan/yoink-go" WORKDIR /app diff --git a/Makefile b/Makefile index 53e5b73..90610ba 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,9 @@ BIN := yoink BUILD_DIR := build +REGISTRY := git.brizzle.dev/bryan/yoink-go +VERSION := $(shell git describe --tags --always --dirty) -.PHONY: all windows linux darwin clean +.PHONY: all windows linux darwin clean docker-build docker-push all: windows linux darwin @@ -16,5 +18,15 @@ darwin: GOOS=darwin GOARCH=amd64 go build -o $(BUILD_DIR)/$(BIN)-darwin-amd64 GOOS=darwin GOARCH=arm64 go build -o $(BUILD_DIR)/$(BIN)-darwin-arm64 +docker-build: + podman build --format docker \ + -t $(REGISTRY):$(VERSION) \ + -t $(REGISTRY):latest \ + . + +docker-push: docker-build + podman push $(REGISTRY):$(VERSION) + podman push $(REGISTRY):latest + clean: rm -rf $(BUILD_DIR) diff --git a/README.md b/README.md index 3bdebfe..7514bed 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # yoink -A CLI tool for downloading comics from readallcomics.com and packaging them as `.cbz` archives. +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. ## How it works @@ -9,17 +9,33 @@ A CLI tool for downloading comics from readallcomics.com and packaging them as ` 3. Packages the images into a `.cbz` (Comic Book Zip) archive 4. Cleans up downloaded images, keeping only the cover (`001`) +--- + ## Installation -Build from source (requires Go 1.22.3+): +### From source + +Requires Go 1.22.3+: ```shell go build -o yoink ``` +### Pre-built binaries + Pre-built binaries for Linux (arm64) and Windows are available on the [releases page](https://git.brizzle.dev/bryan/yoink-go/releases). -## Usage +### Docker + +```shell +docker pull git.brizzle.dev/bryan/yoink-go:latest +``` + +--- + +## CLI + +Download a single comic issue: ```shell yoink @@ -37,16 +53,79 @@ The comic title is extracted from the page and used to name the archive. Output //<Title>.cbz ``` +--- + +## Web UI + +Yoink includes a self-hosted web interface for browsing and downloading comics from your browser. + +### Running directly + +```shell +yoink serve +``` + +By default the server listens on port `8080`. Use the `-p` flag to change it: + +```shell +yoink serve -p 3000 +``` + +### Running with Docker + +A `docker-compose.yml` is included for quick deployment: + +```shell +docker compose up -d +``` + +Or with Podman: + +```shell +podman compose up -d +``` + +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 +- **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 + +### 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: + +```yaml +# docker-compose.yml +services: + yoink: + image: git.brizzle.dev/bryan/yoink-go:latest + ports: + - "8080:8080" + volumes: + - ./library:/library + environment: + - YOINK_LIBRARY=/library + restart: unless-stopped +``` + +--- + ## Configuration -| Variable | Default | Description | -|-----------------|--------------|--------------------------------------| -| `YOINK_LIBRARY` | `~/.yoink` | Directory where comics are stored | +| Variable | Default | Description | +|-----------------|------------|-----------------------------------| +| `YOINK_LIBRARY` | `~/.yoink` | Directory where comics are stored | ```shell YOINK_LIBRARY=/mnt/media/comics yoink https://readallcomics.com/some-comic-001/ ``` +--- + ## Dependencies - [goquery](https://github.com/PuerkitoBio/goquery) — HTML parsing diff --git a/web/static/index.html b/web/static/index.html index 9a9c5ff..137f5f6 100644 --- a/web/static/index.html +++ b/web/static/index.html @@ -4,106 +4,162 @@ <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>Yoink + + +
- + +
-
Library
- + Library + +
+ + + + + + +
@@ -412,15 +643,19 @@
+
+
- - - - -

No comics yet — paste a URL above to get started.

+
+ + + + +
+

No comics yet — paste a URL above to start building your library.

- +
@@ -428,7 +663,7 @@ // ── State ────────────────────────────────────────────────────────────── let knownJobs = {}; let dismissedJobs = JSON.parse(localStorage.getItem('dismissedJobs') || '{}'); - let allComics = []; // raw list from server (newest-first) + let allComics = []; let currentSort = localStorage.getItem('comicSort') || 'newest'; // ── DOM refs ─────────────────────────────────────────────────────────── @@ -441,6 +676,7 @@ const toastEl = document.getElementById('toast'); const filterInput = document.getElementById('filter-input'); const sortBtns = document.querySelectorAll('.sort-btn'); + const countEl = document.getElementById('comic-count'); // ── Submit handler ───────────────────────────────────────────────────── form.addEventListener('submit', async (e) => { @@ -484,18 +720,18 @@ } queue.classList.add('visible'); - active.sort((a, b) => new Date(a.created_at) - new Date(b.created_at)); active.forEach(job => { const row = document.createElement('div'); row.className = 'queue-item'; + row.dataset.status = job.status; - const statusPill = makeStatusPill(job); + const bar = document.createElement('div'); + bar.className = 'queue-status-bar'; const meta = document.createElement('div'); - meta.style.flex = '1'; - meta.style.minWidth = '0'; + meta.className = 'queue-meta'; const title = document.createElement('div'); title.className = 'queue-title'; @@ -507,17 +743,19 @@ meta.append(title, urlEl); + const pill = makeStatusPill(job); + const dismiss = document.createElement('button'); dismiss.className = 'dismiss-btn'; dismiss.title = 'Dismiss'; - dismiss.textContent = '×'; + dismiss.innerHTML = '×'; dismiss.addEventListener('click', () => { dismissedJobs[job.id] = true; localStorage.setItem('dismissedJobs', JSON.stringify(dismissedJobs)); renderQueue(); }); - row.append(statusPill, meta, dismiss); + row.append(bar, meta, pill, dismiss); queue.append(row); }); } @@ -540,15 +778,12 @@ }[job.status] || job.status; pill.append(document.createTextNode(label)); - - if (job.error) { - pill.title = job.error; - } + if (job.error) pill.title = job.error; return pill; } - // ── Render comics grid ───────────────────────────────────────────────── + // ── Filter & sort ────────────────────────────────────────────────────── function applyFilterAndSort() { const query = filterInput.value.trim().toLowerCase(); @@ -556,7 +791,6 @@ ? allComics.filter(c => c.title.toLowerCase().includes(query)) : allComics.slice(); - // Server already sends newest-first; re-sort only when needed if (currentSort === 'oldest') { comics = comics.slice().reverse(); } else if (currentSort === 'az') { @@ -568,15 +802,19 @@ renderComics(comics); } + // ── Render comics ────────────────────────────────────────────────────── function renderComics(comics) { grid.innerHTML = ''; if (!comics || comics.length === 0) { emptyEl.style.display = 'flex'; + countEl.style.display = 'none'; return; } emptyEl.style.display = 'none'; + countEl.textContent = allComics.length; + countEl.style.display = ''; comics.forEach(comic => { const a = document.createElement('a'); @@ -620,14 +858,22 @@ return div; } + function showSkeletons() { + grid.innerHTML = ''; + emptyEl.style.display = 'none'; + for (let i = 0; i < 10; i++) { + const sk = document.createElement('div'); + sk.className = 'skeleton-card'; + grid.append(sk); + } + } + // ── Toast ────────────────────────────────────────────────────────────── function toast(msg, isError = false) { const el = document.createElement('div'); - el.className = 'toast-msg'; + el.className = 'toast-msg' + (isError ? ' is-error' : ''); el.textContent = msg; - if (isError) el.style.borderColor = 'var(--error)'; toastEl.append(el); - setTimeout(() => { el.classList.add('fade'); setTimeout(() => el.remove(), 350); @@ -639,27 +885,23 @@ try { const res = await fetch('/api/jobs'); const jobs = await res.json(); - let needComicRefresh = false; jobs.forEach(job => { const prev = knownJobs[job.id]; if (prev && prev.status !== 'complete' && job.status === 'complete') { needComicRefresh = true; - toast(`"${job.title}" downloaded successfully`); + toast(`"${job.title}" downloaded`); } if (prev && prev.status !== 'error' && job.status === 'error') { - toast(`Error downloading "${job.title || job.url}": ${job.error}`, true); + toast(`Failed: ${job.title || job.url}${job.error ? ' — ' + job.error : ''}`, true); } knownJobs[job.id] = job; }); renderQueue(); - - if (needComicRefresh) { - await fetchComics(); - } - } catch (_) { /* network hiccup — ignore */ } + if (needComicRefresh) await fetchComics(); + } catch (_) {} } async function fetchComics() { @@ -671,22 +913,21 @@ } // ── Init ─────────────────────────────────────────────────────────────── + sortBtns.forEach(b => { + if (b.dataset.sort === currentSort) b.classList.add('active'); + else b.classList.remove('active'); - // Restore saved sort - sortBtns.forEach(btn => { - if (btn.dataset.sort === currentSort) btn.classList.add('active'); - else btn.classList.remove('active'); - - btn.addEventListener('click', () => { - currentSort = btn.dataset.sort; + b.addEventListener('click', () => { + currentSort = b.dataset.sort; localStorage.setItem('comicSort', currentSort); - sortBtns.forEach(b => b.classList.toggle('active', b === btn)); + sortBtns.forEach(x => x.classList.toggle('active', x === b)); applyFilterAndSort(); }); }); filterInput.addEventListener('input', applyFilterAndSort); + showSkeletons(); fetchComics(); setInterval(pollJobs, 2000); setInterval(fetchComics, 10000);