feat: serve width-bounded thumbnail variants for image grids
Add /files/{key}?w=<px>: the endpoint decodes JPEG/PNG sources, downscales to
a width snapped to a small allow-list (aspect-preserving, never upscaling),
caches the result under a thumbs/ prefix, and serves it — so cover grids
download ~KB instead of the 1–5 MB original. Cover handlers purge cached
variants when a cover (whose key is reused) is replaced or deleted. Frontend
grids use new thumbUrl/thumbSrcset helpers (MangaCard with responsive srcset).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2
backend/Cargo.lock
generated
2
backend/Cargo.lock
generated
@@ -1558,7 +1558,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
|
||||
|
||||
[[package]]
|
||||
name = "mangalord"
|
||||
version = "0.124.24"
|
||||
version = "0.125.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"argon2",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "mangalord"
|
||||
version = "0.124.24"
|
||||
version = "0.125.0"
|
||||
edition = "2021"
|
||||
default-run = "mangalord"
|
||||
|
||||
@@ -62,6 +62,7 @@ http-body-util = "0.1"
|
||||
mime = "0.3"
|
||||
futures-util = "0.3"
|
||||
tokio = { version = "1", features = ["test-util"] }
|
||||
image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp"] }
|
||||
|
||||
# Trim debug builds: keep line numbers in panics / backtraces but drop the
|
||||
# full DWARF info (variable-level inspection in gdb/lldb). With a sqlx +
|
||||
|
||||
@@ -5,6 +5,13 @@
|
||||
//! The handler uses `Storage::get_stream` so a multi-MB page is piped to
|
||||
//! the client a chunk at a time instead of buffered server-side.
|
||||
//!
|
||||
//! **Thumbnails.** `?w=<px>` serves a width-bounded variant (grids ask for a
|
||||
//! small width so they download ~KB instead of the 1–5 MB original). The width
|
||||
//! snaps to a small allow-list so the number of cached derivatives stays
|
||||
//! bounded; the resized image is cached in storage under a `thumbs/w{W}/` prefix
|
||||
//! and regenerated on demand. Only JPEG/PNG sources are thumbnailed (encoders we
|
||||
//! ship); other formats fall back to the original.
|
||||
//!
|
||||
//! **Auth model — capability URLs by design.** This endpoint is
|
||||
//! deliberately unauthenticated: reads stay public per the project
|
||||
//! brief, and per-page authorisation would require either a per-request
|
||||
@@ -16,35 +23,130 @@
|
||||
//! would gate this endpoint behind a `Storage::owner_of(key)` check;
|
||||
//! the seam is intentional.
|
||||
|
||||
use std::io::Cursor;
|
||||
|
||||
use axum::body::Body;
|
||||
use axum::extract::{Path, State};
|
||||
use axum::extract::{Path, Query, State};
|
||||
use axum::http::{header, HeaderName};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::routing::get;
|
||||
use axum::Router;
|
||||
use image::imageops::FilterType;
|
||||
use image::{ImageFormat, ImageReader};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::app::AppState;
|
||||
use crate::error::AppResult;
|
||||
use crate::storage::StorageError;
|
||||
use crate::error::{AppError, AppResult};
|
||||
use crate::storage::{Storage, StorageError};
|
||||
|
||||
/// Widths a thumbnail may be rendered at. A requested width snaps up to the
|
||||
/// smallest of these so the set of cached derivatives stays tiny.
|
||||
const ALLOWED_THUMB_WIDTHS: &[u32] = &[160, 320, 480, 640, 960];
|
||||
|
||||
/// Storage key prefix for cached thumbnails.
|
||||
const THUMB_PREFIX: &str = "thumbs";
|
||||
|
||||
/// Decode allocation cap (mirrors `analysis::ocr`): a tiny file declaring huge
|
||||
/// dimensions is rejected before the decoder allocates, not after (OOM guard).
|
||||
const MAX_THUMB_DECODE_PIXELS: u64 = 40_000_000;
|
||||
|
||||
pub fn routes() -> Router<AppState> {
|
||||
Router::new().route("/files/*key", get(serve))
|
||||
}
|
||||
|
||||
async fn serve(State(state): State<AppState>, Path(key): Path<String>) -> AppResult<Response> {
|
||||
let file = match state.storage.get_stream(&key).await {
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ServeQuery {
|
||||
/// Requested thumbnail width in pixels; absent = serve the original.
|
||||
w: Option<String>,
|
||||
}
|
||||
|
||||
async fn serve(
|
||||
State(state): State<AppState>,
|
||||
Path(key): Path<String>,
|
||||
Query(q): Query<ServeQuery>,
|
||||
) -> AppResult<Response> {
|
||||
// Thumbnail request: only for source formats we can re-encode; anything
|
||||
// else falls through to serving the original.
|
||||
if let Some(width) = resolve_thumb_width(q.w.as_deref()) {
|
||||
if let Some(fmt) = thumb_format_for(&key) {
|
||||
return serve_thumbnail(&state, &key, width, fmt).await;
|
||||
}
|
||||
}
|
||||
serve_original(&state, &key).await
|
||||
}
|
||||
|
||||
async fn serve_original(state: &AppState, key: &str) -> AppResult<Response> {
|
||||
let file = match state.storage.get_stream(key).await {
|
||||
Ok(f) => f,
|
||||
Err(StorageError::NotFound) => return Err(crate::error::AppError::NotFound),
|
||||
Err(StorageError::NotFound) => return Err(AppError::NotFound),
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
let ct = content_type_for(&key);
|
||||
Ok(image_response(
|
||||
state,
|
||||
content_type_for(key),
|
||||
file.size_bytes.to_string(),
|
||||
Body::from_stream(file.stream),
|
||||
))
|
||||
}
|
||||
|
||||
async fn serve_thumbnail(
|
||||
state: &AppState,
|
||||
key: &str,
|
||||
width: u32,
|
||||
fmt: ImageFormat,
|
||||
) -> AppResult<Response> {
|
||||
let derived = thumb_key(key, width);
|
||||
|
||||
// Serve the cached variant if it exists.
|
||||
match state.storage.get_stream(&derived).await {
|
||||
Ok(f) => {
|
||||
return Ok(image_response(
|
||||
state,
|
||||
content_type_for(key),
|
||||
f.size_bytes.to_string(),
|
||||
Body::from_stream(f.stream),
|
||||
));
|
||||
}
|
||||
Err(StorageError::NotFound) => {}
|
||||
Err(e) => return Err(e.into()),
|
||||
}
|
||||
|
||||
// Generate from the original.
|
||||
let original = match state.storage.get(key).await {
|
||||
Ok(b) => b,
|
||||
Err(StorageError::NotFound) => return Err(AppError::NotFound),
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
// Resizing is CPU-bound; keep it off the async worker threads.
|
||||
let thumb = tokio::task::spawn_blocking(move || make_thumbnail(&original, width, fmt))
|
||||
.await
|
||||
.map_err(|e| AppError::Other(anyhow::anyhow!("thumbnail task join: {e}")))??;
|
||||
|
||||
// Best-effort cache; a write failure just means we regenerate next time.
|
||||
let _ = state.storage.put(&derived, &thumb).await;
|
||||
|
||||
let len = thumb.len().to_string();
|
||||
Ok(image_response(
|
||||
state,
|
||||
content_type_for(key),
|
||||
len,
|
||||
Body::from(thumb),
|
||||
))
|
||||
}
|
||||
|
||||
/// Shared response builder for both the original and thumbnail paths.
|
||||
fn image_response(
|
||||
state: &AppState,
|
||||
content_type: &str,
|
||||
content_length: String,
|
||||
body: Body,
|
||||
) -> Response {
|
||||
let headers = [
|
||||
(header::CONTENT_TYPE, content_type.to_string()),
|
||||
(header::CONTENT_LENGTH, content_length),
|
||||
// `nosniff` makes the contract explicit: the browser must trust the
|
||||
// Content-Type we declared (and that the magic-byte sniff at upload
|
||||
// time produced) instead of trying to detect HTML/JS in the body.
|
||||
// Belt-and-braces vs. polyglot files that survive the upload sniff.
|
||||
let headers = [
|
||||
(header::CONTENT_TYPE, ct.to_string()),
|
||||
(header::CONTENT_LENGTH, file.size_bytes.to_string()),
|
||||
(
|
||||
HeaderName::from_static("x-content-type-options"),
|
||||
"nosniff".to_string(),
|
||||
@@ -52,15 +154,12 @@ async fn serve(State(state): State<AppState>, Path(key): Path<String>) -> AppRes
|
||||
// Blobs are content-addressed by unguessable, immutable keys (a
|
||||
// re-upload mints new UUIDs), so a fetched page/cover never changes.
|
||||
// Cache it for a year and mark it `immutable` so browsers skip
|
||||
// revalidation entirely — this is what lets the reader's page and
|
||||
// next-chapter preloading hit cache instead of re-downloading (and
|
||||
// re-proxying every byte through the SvelteKit node server in prod).
|
||||
// revalidation entirely.
|
||||
//
|
||||
// BUT under PRIVATE_MODE these blobs are auth-gated (see
|
||||
// `private_mode_guard`), so they must NOT be marked `public`: a shared
|
||||
// cache / CDN in front of the app would store the object and then serve
|
||||
// it to unauthenticated clients, defeating the gate. Use `private` so
|
||||
// only the requesting user's browser caches it.
|
||||
// BUT under PRIVATE_MODE these blobs are auth-gated, so they must NOT be
|
||||
// marked `public`: a shared cache / CDN would store the object and serve
|
||||
// it to unauthenticated clients. Use `private` so only the requesting
|
||||
// user's browser caches it.
|
||||
(
|
||||
header::CACHE_CONTROL,
|
||||
if state.auth.private_mode {
|
||||
@@ -70,7 +169,83 @@ async fn serve(State(state): State<AppState>, Path(key): Path<String>) -> AppRes
|
||||
},
|
||||
),
|
||||
];
|
||||
Ok((headers, Body::from_stream(file.stream)).into_response())
|
||||
(headers, body).into_response()
|
||||
}
|
||||
|
||||
/// Parse and clamp a requested thumbnail width. Returns `None` for absent /
|
||||
/// unparseable / zero widths (serve the original); otherwise snaps the request
|
||||
/// up to the smallest allowed width (capped at the largest) so cached variants
|
||||
/// stay bounded.
|
||||
fn resolve_thumb_width(raw: Option<&str>) -> Option<u32> {
|
||||
let requested: u32 = raw?.trim().parse().ok()?;
|
||||
if requested == 0 {
|
||||
return None;
|
||||
}
|
||||
Some(
|
||||
ALLOWED_THUMB_WIDTHS
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|&w| w >= requested)
|
||||
.unwrap_or_else(|| *ALLOWED_THUMB_WIDTHS.last().expect("non-empty")),
|
||||
)
|
||||
}
|
||||
|
||||
/// The re-encode format for a source key, or `None` when it isn't one we ship an
|
||||
/// encoder for (gif/avif → serve the original instead of a broken thumbnail).
|
||||
fn thumb_format_for(key: &str) -> Option<ImageFormat> {
|
||||
match content_type_for(key) {
|
||||
"image/jpeg" => Some(ImageFormat::Jpeg),
|
||||
"image/png" => Some(ImageFormat::Png),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The storage key a width-`w` thumbnail of `key` is cached under.
|
||||
fn thumb_key(key: &str, width: u32) -> String {
|
||||
format!("{THUMB_PREFIX}/w{width}/{key}")
|
||||
}
|
||||
|
||||
/// Every cached thumbnail key for an original `key`, across all allowed widths.
|
||||
/// Used by the cover handlers to purge stale variants when a cover (whose key is
|
||||
/// reused, unlike content-addressed pages) is replaced or deleted.
|
||||
pub(crate) fn thumbnail_keys(key: &str) -> Vec<String> {
|
||||
ALLOWED_THUMB_WIDTHS
|
||||
.iter()
|
||||
.map(|&w| thumb_key(key, w))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Best-effort deletion of every cached thumbnail for `key`. Call after the
|
||||
/// underlying blob at `key` changes or is removed.
|
||||
pub(crate) async fn purge_thumbnails(storage: &dyn Storage, key: &str) {
|
||||
for derived in thumbnail_keys(key) {
|
||||
let _ = storage.delete(&derived).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Decode, downscale to `width` (aspect-preserving, never upscaling), and
|
||||
/// re-encode in `fmt`. Pure + synchronous so it runs under `spawn_blocking` and
|
||||
/// is unit-testable without a server.
|
||||
fn make_thumbnail(bytes: &[u8], width: u32, fmt: ImageFormat) -> anyhow::Result<Vec<u8>> {
|
||||
use anyhow::Context;
|
||||
let mut reader = ImageReader::new(Cursor::new(bytes))
|
||||
.with_guessed_format()
|
||||
.context("guess image format for thumbnail")?;
|
||||
let mut limits = image::Limits::default();
|
||||
limits.max_alloc = Some(MAX_THUMB_DECODE_PIXELS.saturating_mul(4));
|
||||
reader.limits(limits);
|
||||
let img = reader.decode().context("decode image for thumbnail")?;
|
||||
|
||||
// Only downscale; a source narrower than the target is served as-is.
|
||||
let out = if img.width() > width {
|
||||
img.resize(width, u32::MAX, FilterType::Lanczos3)
|
||||
} else {
|
||||
img
|
||||
};
|
||||
|
||||
let mut buf = Cursor::new(Vec::new());
|
||||
out.write_to(&mut buf, fmt).context("encode thumbnail")?;
|
||||
Ok(buf.into_inner())
|
||||
}
|
||||
|
||||
fn content_type_for(key: &str) -> &'static str {
|
||||
@@ -84,3 +259,68 @@ fn content_type_for(key: &str) -> &'static str {
|
||||
_ => "application/octet-stream",
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn resolve_thumb_width_snaps_and_validates() {
|
||||
// Absent / unparseable / zero → serve the original.
|
||||
assert_eq!(resolve_thumb_width(None), None);
|
||||
assert_eq!(resolve_thumb_width(Some("")), None);
|
||||
assert_eq!(resolve_thumb_width(Some("abc")), None);
|
||||
assert_eq!(resolve_thumb_width(Some("0")), None);
|
||||
// Snap up to the smallest allowed width.
|
||||
assert_eq!(resolve_thumb_width(Some("1")), Some(160));
|
||||
assert_eq!(resolve_thumb_width(Some("160")), Some(160));
|
||||
assert_eq!(resolve_thumb_width(Some("161")), Some(320));
|
||||
assert_eq!(resolve_thumb_width(Some("640")), Some(640));
|
||||
// Above the max → capped at the largest allowed width.
|
||||
assert_eq!(resolve_thumb_width(Some("5000")), Some(960));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thumb_format_only_for_encodable_sources() {
|
||||
assert_eq!(thumb_format_for("a/b/cover.jpg"), Some(ImageFormat::Jpeg));
|
||||
assert_eq!(thumb_format_for("a/b/cover.png"), Some(ImageFormat::Png));
|
||||
// Formats we can't re-encode fall back to the original.
|
||||
assert_eq!(thumb_format_for("a/b/cover.webp"), None);
|
||||
assert_eq!(thumb_format_for("a/b/cover.gif"), None);
|
||||
assert_eq!(thumb_format_for("a/b/cover.avif"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thumb_key_is_prefixed_by_width() {
|
||||
assert_eq!(
|
||||
thumb_key("mangas/x/cover.png", 320),
|
||||
"thumbs/w320/mangas/x/cover.png"
|
||||
);
|
||||
assert_eq!(thumbnail_keys("k.png").len(), ALLOWED_THUMB_WIDTHS.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn make_thumbnail_downscales_and_preserves_aspect() {
|
||||
// 100x50 red PNG → thumbnail width 40 → 40x20, still decodable PNG.
|
||||
let mut src = Cursor::new(Vec::new());
|
||||
image::RgbImage::from_pixel(100, 50, image::Rgb([255, 0, 0]))
|
||||
.write_to(&mut src, ImageFormat::Png)
|
||||
.unwrap();
|
||||
let out = make_thumbnail(src.get_ref(), 40, ImageFormat::Png).unwrap();
|
||||
let decoded = image::load_from_memory(&out).unwrap();
|
||||
assert_eq!(decoded.width(), 40);
|
||||
assert_eq!(decoded.height(), 20, "aspect ratio preserved");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn make_thumbnail_does_not_upscale() {
|
||||
// A 30px-wide source requested at 40 stays 30 wide (no upscaling).
|
||||
let mut src = Cursor::new(Vec::new());
|
||||
image::RgbImage::from_pixel(30, 30, image::Rgb([0, 255, 0]))
|
||||
.write_to(&mut src, ImageFormat::Png)
|
||||
.unwrap();
|
||||
let out = make_thumbnail(src.get_ref(), 40, ImageFormat::Png).unwrap();
|
||||
let decoded = image::load_from_memory(&out).unwrap();
|
||||
assert_eq!(decoded.width(), 30);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -414,8 +414,14 @@ async fn put_cover(
|
||||
Ok(()) | Err(StorageError::NotFound) => {}
|
||||
Err(e) => return Err(e.into()),
|
||||
}
|
||||
// Old key's cached thumbnails are now orphaned.
|
||||
crate::api::files::purge_thumbnails(state.storage.as_ref(), prev).await;
|
||||
}
|
||||
}
|
||||
// Cover keys are reused (mangas/{id}/cover.{ext}), so a same-extension
|
||||
// replacement overwrites the blob at an existing key — drop any thumbnails
|
||||
// cached for it so we don't serve a stale variant of the old cover.
|
||||
crate::api::files::purge_thumbnails(state.storage.as_ref(), &new_key).await;
|
||||
|
||||
repo::manga::set_cover_image_path(&state.db, id, &new_key, img.bytes.len() as i64).await?;
|
||||
Ok(Json(repo::manga::get_detail(&state.db, id).await?))
|
||||
@@ -439,6 +445,7 @@ async fn delete_cover(
|
||||
Ok(()) | Err(StorageError::NotFound) => {}
|
||||
Err(e) => return Err(e.into()),
|
||||
}
|
||||
crate::api::files::purge_thumbnails(state.storage.as_ref(), &key).await;
|
||||
repo::manga::clear_cover_image_path(&state.db, id).await?;
|
||||
}
|
||||
Ok(Json(repo::manga::get_detail(&state.db, id).await?))
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
mod common;
|
||||
|
||||
use axum::http::StatusCode;
|
||||
use http_body_util::BodyExt;
|
||||
use serde_json::{json, Value};
|
||||
use sqlx::PgPool;
|
||||
use tower::ServiceExt;
|
||||
@@ -45,6 +46,65 @@ fn cover_form(bytes: &[u8]) -> MultipartBuilder {
|
||||
MultipartBuilder::new().add_file("cover", "cover.bin", "application/octet-stream", bytes)
|
||||
}
|
||||
|
||||
/// A real, decodable PNG (unlike `fake_png_bytes`, which is only magic bytes)
|
||||
/// so the thumbnail endpoint has something to resize.
|
||||
fn real_png(width: u32, height: u32) -> Vec<u8> {
|
||||
let mut buf = std::io::Cursor::new(Vec::new());
|
||||
image::RgbImage::from_pixel(width, height, image::Rgb([10, 120, 200]))
|
||||
.write_to(&mut buf, image::ImageFormat::Png)
|
||||
.unwrap();
|
||||
buf.into_inner()
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn files_serves_downscaled_thumbnail_variant(pool: PgPool) {
|
||||
let h = harness(pool);
|
||||
let (_, cookie) = register_user(&h.app).await;
|
||||
let manga = create_manga_with_cover(&h.app, &cookie, "Thumb", None).await;
|
||||
let id = id_of(&manga);
|
||||
|
||||
// Upload a real 800x400 PNG cover.
|
||||
let resp = h
|
||||
.app
|
||||
.clone()
|
||||
.oneshot(put_multipart_with_cookie(
|
||||
&format!("/api/v1/mangas/{id}/cover"),
|
||||
cover_form(&real_png(800, 400)),
|
||||
&cookie,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let key = format!("mangas/{id}/cover.png");
|
||||
|
||||
// ?w=320 serves a 320px-wide variant with the source content-type.
|
||||
let resp = h
|
||||
.app
|
||||
.clone()
|
||||
.oneshot(get(&format!("/api/v1/files/{key}?w=320")))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
assert_eq!(
|
||||
resp.headers().get("content-type").unwrap().to_str().unwrap(),
|
||||
"image/png"
|
||||
);
|
||||
let body = resp.into_body().collect().await.unwrap().to_bytes();
|
||||
let thumb = image::load_from_memory(&body).unwrap();
|
||||
assert_eq!(thumb.width(), 320, "served a 320px-wide variant");
|
||||
assert_eq!(thumb.height(), 160, "aspect ratio preserved");
|
||||
|
||||
// The un-parametrised request still serves the full-resolution original.
|
||||
let resp = h
|
||||
.app
|
||||
.clone()
|
||||
.oneshot(get(&format!("/api/v1/files/{key}")))
|
||||
.await
|
||||
.unwrap();
|
||||
let full = resp.into_body().collect().await.unwrap().to_bytes();
|
||||
assert_eq!(image::load_from_memory(&full).unwrap().width(), 800);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn put_cover_sets_path_when_none_existed(pool: PgPool) {
|
||||
let h = harness(pool);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mangalord-frontend",
|
||||
"version": "0.124.24",
|
||||
"version": "0.125.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach, type MockInstance } from 'vitest';
|
||||
import { ApiError, request, setOn401Hook, fileUrl } from './client';
|
||||
import { ApiError, request, setOn401Hook, fileUrl, thumbUrl, thumbSrcset } from './client';
|
||||
import { getManga } from './mangas';
|
||||
|
||||
describe('fileUrl', () => {
|
||||
@@ -19,6 +19,21 @@ describe('fileUrl', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('thumbUrl / thumbSrcset', () => {
|
||||
it('appends the width query to the file URL', () => {
|
||||
expect(thumbUrl('mangas/abc/cover.png', 320)).toBe(
|
||||
'/api/v1/files/mangas/abc/cover.png?w=320'
|
||||
);
|
||||
});
|
||||
|
||||
it('builds a srcset over the given candidate widths', () => {
|
||||
expect(thumbSrcset('mangas/abc/cover.png', [160, 320])).toBe(
|
||||
'/api/v1/files/mangas/abc/cover.png?w=160 160w, ' +
|
||||
'/api/v1/files/mangas/abc/cover.png?w=320 320w'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('request error envelope parsing', () => {
|
||||
let fetchSpy: MockInstance<typeof globalThis.fetch>;
|
||||
|
||||
|
||||
@@ -19,6 +19,25 @@ export function fileUrl(key: string): string {
|
||||
return `${BASE}/v1/files/${encoded}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* URL to a width-bounded thumbnail variant of a stored image (the backend
|
||||
* `/files/{key}?w=` endpoint). Grids use this so a cover downloads ~KB instead
|
||||
* of the 1–5 MB original; the backend snaps `width` to a small allow-list and
|
||||
* caches the result.
|
||||
*/
|
||||
export function thumbUrl(key: string, width: number): string {
|
||||
return `${fileUrl(key)}?w=${width}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* A `srcset` string over the candidate `widths` for `key`, e.g.
|
||||
* `.../files/k?w=320 320w, .../files/k?w=480 480w`. Pair with a `sizes`
|
||||
* attribute so the browser picks the right variant for the rendered size.
|
||||
*/
|
||||
export function thumbSrcset(key: string, widths: number[]): string {
|
||||
return widths.map((w) => `${thumbUrl(key, w)} ${w}w`).join(', ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds an API URL for non-`fetch` consumers (e.g. `EventSource` for SSE),
|
||||
* applying the same `VITE_API_BASE` prefix as `request()`. `path` is the
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { fileUrl } from '$lib/api/client';
|
||||
import { thumbUrl } from '$lib/api/client';
|
||||
import type { Bookmark } from '$lib/api/bookmarks';
|
||||
import { overflowTooltip } from '$lib/actions/overflowTooltip';
|
||||
import BookImage from '@lucide/svelte/icons/book-image';
|
||||
@@ -19,7 +19,7 @@
|
||||
<a href="/manga/{b.manga_id}" class="cover-link" aria-hidden="true" tabindex="-1">
|
||||
{#if b.manga_cover_image_path}
|
||||
<img
|
||||
src={fileUrl(b.manga_cover_image_path)}
|
||||
src={thumbUrl(b.manga_cover_image_path, 320)}
|
||||
alt=""
|
||||
class="cover"
|
||||
loading="lazy"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { fileUrl } from '$lib/api/client';
|
||||
import { thumbUrl } from '$lib/api/client';
|
||||
import type { CollectionSummary } from '$lib/api/collections';
|
||||
import FolderOpen from '@lucide/svelte/icons/folder-open';
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
{:else}
|
||||
{#each c.sample_covers as cover (cover)}
|
||||
<img
|
||||
src={fileUrl(cover)}
|
||||
src={thumbUrl(cover, 320)}
|
||||
alt=""
|
||||
class="collage-cover"
|
||||
loading="lazy"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { fileUrl } from '$lib/api/client';
|
||||
import { thumbUrl } from '$lib/api/client';
|
||||
import { chapterLabel } from '$lib/api/chapters';
|
||||
import { overflowTooltip } from '$lib/actions/overflowTooltip';
|
||||
import type { ReadProgressSummary } from '$lib/api/read_progress';
|
||||
@@ -47,7 +47,7 @@
|
||||
<span class="cover-wrap">
|
||||
{#if p.manga_cover_image_path}
|
||||
<img
|
||||
src={fileUrl(p.manga_cover_image_path)}
|
||||
src={thumbUrl(p.manga_cover_image_path, 320)}
|
||||
alt=""
|
||||
class="cover"
|
||||
loading="lazy"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { fileUrl } from '$lib/api/client';
|
||||
import { thumbUrl, thumbSrcset } from '$lib/api/client';
|
||||
import type { Manga } from '$lib/api/client';
|
||||
import type { AuthorRef, GenreRef } from '$lib/api/mangas';
|
||||
import { overflowTooltip } from '$lib/actions/overflowTooltip';
|
||||
@@ -48,7 +48,9 @@
|
||||
<a href="/manga/{manga.id}" class="cover-link" aria-hidden="true" tabindex="-1">
|
||||
{#if manga.cover_image_path}
|
||||
<img
|
||||
src={fileUrl(manga.cover_image_path)}
|
||||
src={thumbUrl(manga.cover_image_path, 320)}
|
||||
srcset={thumbSrcset(manga.cover_image_path, [160, 320, 480, 640])}
|
||||
sizes="(max-width: 600px) 45vw, 200px"
|
||||
alt=""
|
||||
class="cover"
|
||||
loading="lazy"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { fileUrl } from '$lib/api/client';
|
||||
import { fileUrl, thumbUrl } from '$lib/api/client';
|
||||
import type { TaggedMangaAggregate } from '$lib/api/page_tags';
|
||||
|
||||
/**
|
||||
@@ -36,7 +36,7 @@
|
||||
<li class="row" data-testid={testid}>
|
||||
<a {href} class="cover-link" aria-hidden="true" tabindex="-1">
|
||||
{#if primaryCover}
|
||||
<img src={fileUrl(primaryCover)} alt="" class="cover" loading="lazy" decoding="async" />
|
||||
<img src={thumbUrl(primaryCover, 320)} alt="" class="cover" loading="lazy" decoding="async" />
|
||||
{:else}
|
||||
<div class="cover cover-placeholder"></div>
|
||||
{/if}
|
||||
|
||||
Reference in New Issue
Block a user