feat: edit existing manga metadata (0.31.0)
Adds PUT /mangas/:id/cover (multipart) and DELETE /mangas/:id/cover so covers can be replaced or cleared after creation, and wires a dedicated /manga/[id]/edit SvelteKit route that combines the existing PATCH with the new cover endpoints. Cover PUT cleans up the old blob when the extension changes, swallowing StorageError::NotFound so a manually-gone file doesn't surface as a 404 to the client. Edit link on the manga detail page is gated on session.user, matching the auth posture of the underlying handlers. Also pins the local-dev port story via loadEnv() in vite.config.ts so VITE_PORT / BACKEND_URL from a (gitignored) .env keep the dev URL stable across runs. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2
backend/Cargo.lock
generated
2
backend/Cargo.lock
generated
@@ -1470,7 +1470,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
|
||||
|
||||
[[package]]
|
||||
name = "mangalord"
|
||||
version = "0.30.0"
|
||||
version = "0.31.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"argon2",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "mangalord"
|
||||
version = "0.30.0"
|
||||
version = "0.31.0"
|
||||
edition = "2021"
|
||||
default-run = "mangalord"
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use axum::extract::{Multipart, Path, Query, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::routing::{delete, get, post};
|
||||
use axum::routing::{delete, get, post, put};
|
||||
use axum::{Json, Router};
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
@@ -14,12 +14,14 @@ use crate::domain::patch::Patch;
|
||||
use crate::domain::tag::TagRef;
|
||||
use crate::error::{AppError, AppResult};
|
||||
use crate::repo;
|
||||
use crate::storage::StorageError;
|
||||
use crate::upload::{parse_image, UploadedImage};
|
||||
|
||||
pub fn routes() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/mangas", get(list).post(create))
|
||||
.route("/mangas/:id", get(get_one).patch(update))
|
||||
.route("/mangas/:id/cover", put(put_cover).delete(delete_cover))
|
||||
.route("/mangas/:id/tags", post(attach_tag))
|
||||
.route("/mangas/:id/tags/:tag_id", delete(detach_tag))
|
||||
}
|
||||
@@ -259,6 +261,82 @@ async fn update(
|
||||
Ok(Json(repo::manga::get_detail(&state.db, id).await?))
|
||||
}
|
||||
|
||||
/// `PUT /api/v1/mangas/:id/cover` is multipart/form-data with a single
|
||||
/// required `cover` part containing image bytes. MIME is sniffed by
|
||||
/// magic bytes (jpeg/png/webp/gif/avif); filename and Content-Type from
|
||||
/// the client are ignored. Replaces any existing cover, deleting the
|
||||
/// previous blob if its extension differs. Returns the refreshed
|
||||
/// `MangaDetail`.
|
||||
async fn put_cover(
|
||||
State(state): State<AppState>,
|
||||
CurrentUser(_user): CurrentUser,
|
||||
Path(id): Path<Uuid>,
|
||||
mut multipart: Multipart,
|
||||
) -> AppResult<Json<MangaDetail>> {
|
||||
// TODO(auth): until uploaders are tracked (Phase 5), any signed-in
|
||||
// user can edit any manga's cover. Restrict to uploader + admin
|
||||
// once that column lands.
|
||||
if !repo::manga::exists(&state.db, id).await? {
|
||||
return Err(AppError::NotFound);
|
||||
}
|
||||
|
||||
let mut cover: Option<UploadedImage> = None;
|
||||
while let Some(field) = next_field(&mut multipart).await? {
|
||||
if field.name() == Some("cover") {
|
||||
let bytes = read_field_bytes(field).await?.to_vec();
|
||||
cover = Some(parse_image(bytes, state.upload.max_file_bytes, "cover")?);
|
||||
}
|
||||
}
|
||||
let img = cover.ok_or_else(|| AppError::ValidationFailed {
|
||||
message: "cover part is required".into(),
|
||||
details: json!({ "cover": "required" }),
|
||||
})?;
|
||||
|
||||
// Read the old key BEFORE writing so we can clean up an orphan if
|
||||
// the extension changed (e.g., .png → .jpg). Same-extension is a
|
||||
// `put` overwrite — no delete needed.
|
||||
let old_key = repo::manga::get(&state.db, id).await?.cover_image_path;
|
||||
let new_key = format!("mangas/{}/cover.{}", id, img.ext);
|
||||
state.storage.put(&new_key, &img.bytes).await?;
|
||||
|
||||
if let Some(prev) = old_key.as_deref() {
|
||||
if prev != new_key {
|
||||
// Swallow NotFound — AppError maps it to a client 404,
|
||||
// which would be wrong here. The DB row can outlive a
|
||||
// manually-deleted blob.
|
||||
match state.storage.delete(prev).await {
|
||||
Ok(()) | Err(StorageError::NotFound) => {}
|
||||
Err(e) => return Err(e.into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
repo::manga::set_cover_image_path(&state.db, id, &new_key).await?;
|
||||
Ok(Json(repo::manga::get_detail(&state.db, id).await?))
|
||||
}
|
||||
|
||||
/// `DELETE /api/v1/mangas/:id/cover` clears `cover_image_path` and
|
||||
/// removes the blob. Idempotent: removing a non-existent cover succeeds
|
||||
/// with the unchanged detail.
|
||||
async fn delete_cover(
|
||||
State(state): State<AppState>,
|
||||
CurrentUser(_user): CurrentUser,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> AppResult<Json<MangaDetail>> {
|
||||
// TODO(auth): same caveat as put_cover.
|
||||
if !repo::manga::exists(&state.db, id).await? {
|
||||
return Err(AppError::NotFound);
|
||||
}
|
||||
if let Some(key) = repo::manga::get(&state.db, id).await?.cover_image_path {
|
||||
match state.storage.delete(&key).await {
|
||||
Ok(()) | Err(StorageError::NotFound) => {}
|
||||
Err(e) => return Err(e.into()),
|
||||
}
|
||||
repo::manga::clear_cover_image_path(&state.db, id).await?;
|
||||
}
|
||||
Ok(Json(repo::manga::get_detail(&state.db, id).await?))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AttachTagBody {
|
||||
pub name: String,
|
||||
|
||||
@@ -262,6 +262,17 @@ pub async fn set_cover_image_path<'e, E: PgExecutor<'e>>(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn clear_cover_image_path<'e, E: PgExecutor<'e>>(
|
||||
executor: E,
|
||||
id: Uuid,
|
||||
) -> AppResult<()> {
|
||||
sqlx::query("UPDATE mangas SET cover_image_path = NULL, updated_at = now() WHERE id = $1")
|
||||
.bind(id)
|
||||
.execute(executor)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn exists(pool: &PgPool, id: Uuid) -> AppResult<bool> {
|
||||
let (exists,): (bool,) =
|
||||
sqlx::query_as("SELECT EXISTS(SELECT 1 FROM mangas WHERE id = $1)")
|
||||
|
||||
412
backend/tests/api_mangas_cover.rs
Normal file
412
backend/tests/api_mangas_cover.rs
Normal file
@@ -0,0 +1,412 @@
|
||||
mod common;
|
||||
|
||||
use axum::http::StatusCode;
|
||||
use serde_json::{json, Value};
|
||||
use sqlx::PgPool;
|
||||
use tower::ServiceExt;
|
||||
use uuid::Uuid;
|
||||
|
||||
use common::{
|
||||
body_json, delete_with_cookie, fake_jpeg_bytes, fake_png_bytes, get, harness,
|
||||
post_multipart_with_cookie, put_multipart, put_multipart_with_cookie, register_user,
|
||||
MultipartBuilder,
|
||||
};
|
||||
|
||||
async fn create_manga_with_cover(
|
||||
app: &axum::Router,
|
||||
cookie: &str,
|
||||
title: &str,
|
||||
cover: Option<(&str, &[u8])>,
|
||||
) -> Value {
|
||||
let mut form =
|
||||
MultipartBuilder::new().add_json("metadata", json!({ "title": title }));
|
||||
if let Some((ct, bytes)) = cover {
|
||||
form = form.add_file("cover", "cover.bin", ct, bytes);
|
||||
}
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(post_multipart_with_cookie("/api/v1/mangas", form, cookie))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::CREATED,
|
||||
"seed create_manga failed: {:?}",
|
||||
resp.status()
|
||||
);
|
||||
body_json(resp).await
|
||||
}
|
||||
|
||||
fn id_of(body: &Value) -> Uuid {
|
||||
Uuid::parse_str(body["id"].as_str().unwrap()).unwrap()
|
||||
}
|
||||
|
||||
fn cover_form(bytes: &[u8]) -> MultipartBuilder {
|
||||
MultipartBuilder::new().add_file("cover", "cover.bin", "application/octet-stream", bytes)
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn put_cover_sets_path_when_none_existed(pool: PgPool) {
|
||||
let h = harness(pool);
|
||||
let (_, cookie) = register_user(&h.app).await;
|
||||
let manga = create_manga_with_cover(&h.app, &cookie, "Cover Me", None).await;
|
||||
let id = id_of(&manga);
|
||||
assert!(manga["cover_image_path"].is_null());
|
||||
|
||||
let bytes = fake_png_bytes();
|
||||
let resp = h
|
||||
.app
|
||||
.clone()
|
||||
.oneshot(put_multipart_with_cookie(
|
||||
&format!("/api/v1/mangas/{id}/cover"),
|
||||
cover_form(&bytes),
|
||||
&cookie,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let body = body_json(resp).await;
|
||||
let expected_key = format!("mangas/{id}/cover.png");
|
||||
assert_eq!(body["cover_image_path"], expected_key);
|
||||
assert_eq!(body["title"], "Cover Me");
|
||||
|
||||
let file_resp = h
|
||||
.app
|
||||
.clone()
|
||||
.oneshot(get(&format!("/api/v1/files/{expected_key}")))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(file_resp.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn put_cover_replaces_existing_same_extension(pool: PgPool) {
|
||||
let h = harness(pool);
|
||||
let (_, cookie) = register_user(&h.app).await;
|
||||
let original = fake_png_bytes();
|
||||
let manga = create_manga_with_cover(
|
||||
&h.app,
|
||||
&cookie,
|
||||
"Replace Me",
|
||||
Some(("image/png", &original)),
|
||||
)
|
||||
.await;
|
||||
let id = id_of(&manga);
|
||||
let original_key = format!("mangas/{id}/cover.png");
|
||||
assert_eq!(manga["cover_image_path"], original_key);
|
||||
|
||||
let mut replacement = fake_png_bytes();
|
||||
replacement.extend_from_slice(b"-replacement-marker");
|
||||
let resp = h
|
||||
.app
|
||||
.clone()
|
||||
.oneshot(put_multipart_with_cookie(
|
||||
&format!("/api/v1/mangas/{id}/cover"),
|
||||
cover_form(&replacement),
|
||||
&cookie,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let body = body_json(resp).await;
|
||||
assert_eq!(body["cover_image_path"], original_key);
|
||||
|
||||
let file_resp = h
|
||||
.app
|
||||
.clone()
|
||||
.oneshot(get(&format!("/api/v1/files/{original_key}")))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(file_resp.status(), StatusCode::OK);
|
||||
let body_bytes = http_body_util::BodyExt::collect(file_resp.into_body())
|
||||
.await
|
||||
.unwrap()
|
||||
.to_bytes();
|
||||
assert_eq!(body_bytes.as_ref(), replacement.as_slice());
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn put_cover_replaces_existing_different_extension_and_deletes_old_blob(pool: PgPool) {
|
||||
let h = harness(pool);
|
||||
let (_, cookie) = register_user(&h.app).await;
|
||||
let png = fake_png_bytes();
|
||||
let manga = create_manga_with_cover(
|
||||
&h.app,
|
||||
&cookie,
|
||||
"Switch Ext",
|
||||
Some(("image/png", &png)),
|
||||
)
|
||||
.await;
|
||||
let id = id_of(&manga);
|
||||
let old_key = format!("mangas/{id}/cover.png");
|
||||
assert_eq!(manga["cover_image_path"], old_key);
|
||||
|
||||
let jpeg = fake_jpeg_bytes();
|
||||
let resp = h
|
||||
.app
|
||||
.clone()
|
||||
.oneshot(put_multipart_with_cookie(
|
||||
&format!("/api/v1/mangas/{id}/cover"),
|
||||
cover_form(&jpeg),
|
||||
&cookie,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let body = body_json(resp).await;
|
||||
let new_key = format!("mangas/{id}/cover.jpg");
|
||||
assert_eq!(body["cover_image_path"], new_key);
|
||||
|
||||
let new_file = h
|
||||
.app
|
||||
.clone()
|
||||
.oneshot(get(&format!("/api/v1/files/{new_key}")))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(new_file.status(), StatusCode::OK);
|
||||
|
||||
let old_file = h
|
||||
.app
|
||||
.clone()
|
||||
.oneshot(get(&format!("/api/v1/files/{old_key}")))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(old_file.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn put_cover_rejects_unauthenticated(pool: PgPool) {
|
||||
let h = harness(pool);
|
||||
let (_, cookie) = register_user(&h.app).await;
|
||||
let manga = create_manga_with_cover(&h.app, &cookie, "Public Read", None).await;
|
||||
let id = id_of(&manga);
|
||||
|
||||
let resp = h
|
||||
.app
|
||||
.clone()
|
||||
.oneshot(put_multipart(
|
||||
&format!("/api/v1/mangas/{id}/cover"),
|
||||
cover_form(&fake_png_bytes()),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn put_cover_404_on_unknown_id(pool: PgPool) {
|
||||
let h = harness(pool);
|
||||
let (_, cookie) = register_user(&h.app).await;
|
||||
let id = Uuid::new_v4();
|
||||
let resp = h
|
||||
.app
|
||||
.clone()
|
||||
.oneshot(put_multipart_with_cookie(
|
||||
&format!("/api/v1/mangas/{id}/cover"),
|
||||
cover_form(&fake_png_bytes()),
|
||||
&cookie,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn put_cover_rejects_non_image_with_unsupported_media_type(pool: PgPool) {
|
||||
let h = harness(pool);
|
||||
let (_, cookie) = register_user(&h.app).await;
|
||||
let manga = create_manga_with_cover(&h.app, &cookie, "Not Image", None).await;
|
||||
let id = id_of(&manga);
|
||||
|
||||
let pdf = b"%PDF-1.4\n%\xc4\xe5".to_vec();
|
||||
let resp = h
|
||||
.app
|
||||
.clone()
|
||||
.oneshot(put_multipart_with_cookie(
|
||||
&format!("/api/v1/mangas/{id}/cover"),
|
||||
cover_form(&pdf),
|
||||
&cookie,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::UNSUPPORTED_MEDIA_TYPE);
|
||||
let body = body_json(resp).await;
|
||||
assert_eq!(body["error"]["code"], "unsupported_media_type");
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn put_cover_rejects_oversized(pool: PgPool) {
|
||||
let h = harness(pool);
|
||||
let (_, cookie) = register_user(&h.app).await;
|
||||
let manga = create_manga_with_cover(&h.app, &cookie, "Too Big", None).await;
|
||||
let id = id_of(&manga);
|
||||
|
||||
// Harness max_file_bytes is 256 KiB; 300 KiB trips the cap.
|
||||
let mut bytes = fake_png_bytes();
|
||||
bytes.resize(300 * 1024, 0);
|
||||
let resp = h
|
||||
.app
|
||||
.clone()
|
||||
.oneshot(put_multipart_with_cookie(
|
||||
&format!("/api/v1/mangas/{id}/cover"),
|
||||
cover_form(&bytes),
|
||||
&cookie,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::PAYLOAD_TOO_LARGE);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn put_cover_rejects_missing_cover_part(pool: PgPool) {
|
||||
let h = harness(pool);
|
||||
let (_, cookie) = register_user(&h.app).await;
|
||||
let manga = create_manga_with_cover(&h.app, &cookie, "Empty Form", None).await;
|
||||
let id = id_of(&manga);
|
||||
|
||||
let resp = h
|
||||
.app
|
||||
.clone()
|
||||
.oneshot(put_multipart_with_cookie(
|
||||
&format!("/api/v1/mangas/{id}/cover"),
|
||||
MultipartBuilder::new(),
|
||||
&cookie,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
||||
let body = body_json(resp).await;
|
||||
assert_eq!(body["error"]["code"], "validation_failed");
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn put_cover_preserves_other_metadata(pool: PgPool) {
|
||||
let h = harness(pool);
|
||||
let (_, cookie) = register_user(&h.app).await;
|
||||
let manga = create_manga_with_cover(
|
||||
&h.app,
|
||||
&cookie,
|
||||
"Keep My Fields",
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
let id = id_of(&manga);
|
||||
|
||||
let resp = h
|
||||
.app
|
||||
.clone()
|
||||
.oneshot(put_multipart_with_cookie(
|
||||
&format!("/api/v1/mangas/{id}/cover"),
|
||||
cover_form(&fake_png_bytes()),
|
||||
&cookie,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let body = body_json(resp).await;
|
||||
assert_eq!(body["title"], "Keep My Fields");
|
||||
assert_eq!(body["status"], "ongoing");
|
||||
assert_eq!(body["authors"], json!([]));
|
||||
assert_eq!(body["genres"], json!([]));
|
||||
assert_eq!(body["tags"], json!([]));
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn delete_cover_clears_path_and_removes_blob(pool: PgPool) {
|
||||
let h = harness(pool);
|
||||
let (_, cookie) = register_user(&h.app).await;
|
||||
let png = fake_png_bytes();
|
||||
let manga = create_manga_with_cover(
|
||||
&h.app,
|
||||
&cookie,
|
||||
"Bye Cover",
|
||||
Some(("image/png", &png)),
|
||||
)
|
||||
.await;
|
||||
let id = id_of(&manga);
|
||||
let key = format!("mangas/{id}/cover.png");
|
||||
|
||||
let resp = h
|
||||
.app
|
||||
.clone()
|
||||
.oneshot(delete_with_cookie(
|
||||
&format!("/api/v1/mangas/{id}/cover"),
|
||||
&cookie,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let body = body_json(resp).await;
|
||||
assert!(body["cover_image_path"].is_null());
|
||||
assert_eq!(body["title"], "Bye Cover");
|
||||
|
||||
let file_resp = h
|
||||
.app
|
||||
.clone()
|
||||
.oneshot(get(&format!("/api/v1/files/{key}")))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(file_resp.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn delete_cover_is_idempotent_when_no_cover_present(pool: PgPool) {
|
||||
let h = harness(pool);
|
||||
let (_, cookie) = register_user(&h.app).await;
|
||||
let manga = create_manga_with_cover(&h.app, &cookie, "Never Had One", None).await;
|
||||
let id = id_of(&manga);
|
||||
|
||||
for _ in 0..2 {
|
||||
let resp = h
|
||||
.app
|
||||
.clone()
|
||||
.oneshot(delete_with_cookie(
|
||||
&format!("/api/v1/mangas/{id}/cover"),
|
||||
&cookie,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let body = body_json(resp).await;
|
||||
assert!(body["cover_image_path"].is_null());
|
||||
}
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn delete_cover_rejects_unauthenticated(pool: PgPool) {
|
||||
let h = harness(pool);
|
||||
let (_, cookie) = register_user(&h.app).await;
|
||||
let manga = create_manga_with_cover(&h.app, &cookie, "Locked", None).await;
|
||||
let id = id_of(&manga);
|
||||
|
||||
let resp = h
|
||||
.app
|
||||
.clone()
|
||||
.oneshot(
|
||||
axum::http::Request::builder()
|
||||
.method("DELETE")
|
||||
.uri(format!("/api/v1/mangas/{id}/cover"))
|
||||
.body(axum::body::Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn delete_cover_404_on_unknown_id(pool: PgPool) {
|
||||
let h = harness(pool);
|
||||
let (_, cookie) = register_user(&h.app).await;
|
||||
let id = Uuid::new_v4();
|
||||
let resp = h
|
||||
.app
|
||||
.clone()
|
||||
.oneshot(delete_with_cookie(
|
||||
&format!("/api/v1/mangas/{id}/cover"),
|
||||
&cookie,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
@@ -336,6 +336,37 @@ pub fn post_multipart_with_cookie(
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
pub fn put_multipart_with_cookie(
|
||||
uri: &str,
|
||||
builder: MultipartBuilder,
|
||||
cookie: &str,
|
||||
) -> Request<Body> {
|
||||
let (boundary, body) = builder.finalize();
|
||||
Request::builder()
|
||||
.method("PUT")
|
||||
.uri(uri)
|
||||
.header(
|
||||
header::CONTENT_TYPE,
|
||||
format!("multipart/form-data; boundary={boundary}"),
|
||||
)
|
||||
.header(header::COOKIE, cookie)
|
||||
.body(Body::from(body))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
pub fn put_multipart(uri: &str, builder: MultipartBuilder) -> Request<Body> {
|
||||
let (boundary, body) = builder.finalize();
|
||||
Request::builder()
|
||||
.method("PUT")
|
||||
.uri(uri)
|
||||
.header(
|
||||
header::CONTENT_TYPE,
|
||||
format!("multipart/form-data; boundary={boundary}"),
|
||||
)
|
||||
.body(Body::from(body))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// Realistic PNG file header bytes — enough for `infer` to identify.
|
||||
pub fn fake_png_bytes() -> Vec<u8> {
|
||||
vec![0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 0]
|
||||
|
||||
147
frontend/e2e/manga-edit.spec.ts
Normal file
147
frontend/e2e/manga-edit.spec.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import { test, expect, type Page } from '@playwright/test';
|
||||
|
||||
const userFixture = {
|
||||
id: 'u1',
|
||||
username: 'alice',
|
||||
created_at: '2026-01-01T00:00:00Z'
|
||||
};
|
||||
|
||||
const baseManga = {
|
||||
id: 'm1',
|
||||
title: 'Berserk',
|
||||
status: 'ongoing',
|
||||
alt_titles: ['Old Alt'],
|
||||
description: 'Original description',
|
||||
cover_image_path: null,
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
updated_at: '2026-01-01T00:00:00Z',
|
||||
authors: [{ id: 'a1', name: 'Kentaro Miura' }],
|
||||
genres: [],
|
||||
tags: []
|
||||
};
|
||||
|
||||
async function stubAuthenticatedAndGenres(page: Page) {
|
||||
await page.route('**/api/v1/auth/me', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ user: userFixture })
|
||||
})
|
||||
);
|
||||
await page.route('**/api/v1/genres', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify([
|
||||
{ id: 'g-action', name: 'Action' },
|
||||
{ id: 'g-fantasy', name: 'Fantasy' }
|
||||
])
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
test('anonymous user sees sign-in prompt on /manga/[id]/edit', async ({ page }) => {
|
||||
await page.route('**/api/v1/auth/me', (route) =>
|
||||
route.fulfill({
|
||||
status: 401,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
error: { code: 'unauthenticated', message: 'unauthenticated' }
|
||||
})
|
||||
})
|
||||
);
|
||||
await page.route('**/api/v1/genres', (route) =>
|
||||
route.fulfill({ status: 200, contentType: 'application/json', body: '[]' })
|
||||
);
|
||||
await page.route('**/api/v1/mangas/m1', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(baseManga)
|
||||
})
|
||||
);
|
||||
|
||||
await page.goto('/manga/m1/edit');
|
||||
await expect(page.getByTestId('edit-signin')).toBeVisible();
|
||||
});
|
||||
|
||||
test('/manga/[id]/edit PATCHes the changed metadata and lands on the manga page', async ({
|
||||
page
|
||||
}) => {
|
||||
await stubAuthenticatedAndGenres(page);
|
||||
|
||||
let patchBody: Record<string, unknown> | null = null;
|
||||
let mangaAfter = { ...baseManga };
|
||||
await page.route('**/api/v1/mangas/m1', async (route) => {
|
||||
const method = route.request().method();
|
||||
if (method === 'GET') {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(mangaAfter)
|
||||
});
|
||||
} else if (method === 'PATCH') {
|
||||
patchBody = JSON.parse(route.request().postData() ?? '{}');
|
||||
mangaAfter = {
|
||||
...mangaAfter,
|
||||
title: (patchBody.title as string) ?? mangaAfter.title,
|
||||
description:
|
||||
'description' in (patchBody as Record<string, unknown>)
|
||||
? ((patchBody.description as string | null) ?? null)
|
||||
: mangaAfter.description
|
||||
};
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(mangaAfter)
|
||||
});
|
||||
} else {
|
||||
await route.fallback();
|
||||
}
|
||||
});
|
||||
await page.route('**/api/v1/mangas/m1/chapters*', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
items: [],
|
||||
page: { limit: 50, offset: 0, total: 0 }
|
||||
})
|
||||
})
|
||||
);
|
||||
await page.route('**/api/v1/me/bookmarks*', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
items: [],
|
||||
page: { limit: 50, offset: 0, total: 0 }
|
||||
})
|
||||
})
|
||||
);
|
||||
await page.route('**/api/v1/me/read-progress/m1', (route) =>
|
||||
route.fulfill({
|
||||
status: 404,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
error: { code: 'not_found', message: 'no progress' }
|
||||
})
|
||||
})
|
||||
);
|
||||
|
||||
await page.goto('/manga/m1');
|
||||
// Edit link is gated on session.user — it should be visible to the
|
||||
// stubbed authenticated user.
|
||||
await page.getByTestId('edit-manga-link').click();
|
||||
await expect(page).toHaveURL(/\/manga\/m1\/edit$/);
|
||||
|
||||
const titleInput = page.getByTestId('manga-title');
|
||||
await expect(titleInput).toHaveValue('Berserk');
|
||||
await titleInput.fill('Berserk (Deluxe)');
|
||||
await page.getByTestId('manga-edit-submit').click();
|
||||
|
||||
await expect(page).toHaveURL(/\/manga\/m1$/);
|
||||
await expect(page.getByTestId('manga-title')).toHaveText('Berserk (Deluxe)');
|
||||
expect(patchBody).not.toBeNull();
|
||||
expect((patchBody as Record<string, unknown>).title).toBe('Berserk (Deluxe)');
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mangalord-frontend",
|
||||
"version": "0.30.0",
|
||||
"version": "0.31.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -4,6 +4,8 @@ import {
|
||||
createManga,
|
||||
getManga,
|
||||
updateManga,
|
||||
updateMangaCover,
|
||||
deleteMangaCover,
|
||||
attachTag,
|
||||
detachTag
|
||||
} from './mangas';
|
||||
@@ -184,6 +186,49 @@ describe('mangas api client', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('updateMangaCover PUTs multipart with the cover blob', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
ok(detailFixture({ cover_image_path: 'mangas/b1/cover.png' }))
|
||||
);
|
||||
const cover = new Blob([new Uint8Array([0x89, 0x50, 0x4e, 0x47])], { type: 'image/png' });
|
||||
const updated = await updateMangaCover('b1', cover);
|
||||
expect(updated.cover_image_path).toBe('mangas/b1/cover.png');
|
||||
const url = fetchSpy.mock.calls[0][0] as string;
|
||||
expect(url).toMatch(/\/v1\/mangas\/b1\/cover$/);
|
||||
const init = fetchSpy.mock.calls[0][1] as RequestInit;
|
||||
expect(init.method).toBe('PUT');
|
||||
expect(init.body).toBeInstanceOf(FormData);
|
||||
const form = init.body as FormData;
|
||||
expect(form.get('cover')).toBeInstanceOf(Blob);
|
||||
// Boundary is filled in by the browser when body is FormData.
|
||||
expect(init.headers).toBeUndefined();
|
||||
});
|
||||
|
||||
it('updateMangaCover throws ApiError on payload_too_large', async () => {
|
||||
fetchSpy.mockResolvedValue(
|
||||
envelope(413, 'payload_too_large', 'cover exceeds size cap')
|
||||
);
|
||||
const cover = new Blob([new Uint8Array(1)]);
|
||||
await expect(updateMangaCover('b1', cover)).rejects.toMatchObject({
|
||||
name: 'ApiError',
|
||||
status: 413,
|
||||
code: 'payload_too_large'
|
||||
});
|
||||
});
|
||||
|
||||
it('deleteMangaCover DELETEs and returns the refreshed detail with null path', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
ok(detailFixture({ cover_image_path: null }))
|
||||
);
|
||||
const updated = await deleteMangaCover('b1');
|
||||
expect(updated.cover_image_path).toBeNull();
|
||||
const url = fetchSpy.mock.calls[0][0] as string;
|
||||
expect(url).toMatch(/\/v1\/mangas\/b1\/cover$/);
|
||||
const init = fetchSpy.mock.calls[0][1] as RequestInit;
|
||||
expect(init.method).toBe('DELETE');
|
||||
expect(init.body).toBeUndefined();
|
||||
});
|
||||
|
||||
it('attachTag POSTs the name and returns the TagRef', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
ok({ id: 't9', name: 'Dark Fantasy', added_by: 'u1' }, 201)
|
||||
|
||||
@@ -109,6 +109,31 @@ export async function updateManga(
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /api/v1/mangas/:id/cover (multipart). Replaces the cover image and
|
||||
* returns the refreshed detail. As with createManga the browser fills in
|
||||
* the multipart boundary automatically, so we must NOT set Content-Type.
|
||||
*/
|
||||
export async function updateMangaCover(
|
||||
id: string,
|
||||
cover: Blob
|
||||
): Promise<MangaDetail> {
|
||||
const form = new FormData();
|
||||
form.append('cover', cover);
|
||||
return request<MangaDetail>(
|
||||
`/v1/mangas/${encodeURIComponent(id)}/cover`,
|
||||
{ method: 'PUT', body: form }
|
||||
);
|
||||
}
|
||||
|
||||
/** DELETE /api/v1/mangas/:id/cover. Returns the refreshed detail. */
|
||||
export async function deleteMangaCover(id: string): Promise<MangaDetail> {
|
||||
return request<MangaDetail>(
|
||||
`/v1/mangas/${encodeURIComponent(id)}/cover`,
|
||||
{ method: 'DELETE' }
|
||||
);
|
||||
}
|
||||
|
||||
export async function attachTag(
|
||||
mangaId: string,
|
||||
name: string
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
import AddToCollectionModal from '$lib/components/AddToCollectionModal.svelte';
|
||||
import Plus from '@lucide/svelte/icons/plus';
|
||||
import FolderPlus from '@lucide/svelte/icons/folder-plus';
|
||||
import Pencil from '@lucide/svelte/icons/pencil';
|
||||
import UploadCloud from '@lucide/svelte/icons/upload-cloud';
|
||||
|
||||
let { data } = $props();
|
||||
@@ -327,6 +328,14 @@
|
||||
<FolderPlus size={16} aria-hidden="true" />
|
||||
<span>Add to collection</span>
|
||||
</button>
|
||||
<a
|
||||
class="action"
|
||||
href="/manga/{manga.id}/edit"
|
||||
data-testid="edit-manga-link"
|
||||
>
|
||||
<Pencil size={16} aria-hidden="true" />
|
||||
<span>Edit</span>
|
||||
</a>
|
||||
<a
|
||||
class="action"
|
||||
href="/manga/{manga.id}/upload-chapter"
|
||||
|
||||
481
frontend/src/routes/manga/[id]/edit/+page.svelte
Normal file
481
frontend/src/routes/manga/[id]/edit/+page.svelte
Normal file
@@ -0,0 +1,481 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { ApiError, fileUrl } from '$lib/api/client';
|
||||
import {
|
||||
deleteMangaCover,
|
||||
updateManga,
|
||||
updateMangaCover,
|
||||
type MangaStatus
|
||||
} from '$lib/api/mangas';
|
||||
import { session } from '$lib/session.svelte';
|
||||
import { formatBytes, validateImageFile } from '$lib/upload-validation';
|
||||
import Chip from '$lib/components/Chip.svelte';
|
||||
import Plus from '@lucide/svelte/icons/plus';
|
||||
import Trash2 from '@lucide/svelte/icons/trash-2';
|
||||
|
||||
let { data } = $props();
|
||||
const manga = $derived(data.manga);
|
||||
const genres = $derived(data.genres);
|
||||
|
||||
// Snapshot data.manga into local state once. The edit form is the
|
||||
// source of truth from here on — we deliberately don't re-derive
|
||||
// from `data` after the initial paint.
|
||||
/* svelte-ignore state_referenced_locally */
|
||||
let mangaTitle = $state(data.manga.title);
|
||||
/* svelte-ignore state_referenced_locally */
|
||||
let mangaStatus = $state<MangaStatus>(data.manga.status);
|
||||
/* svelte-ignore state_referenced_locally */
|
||||
let mangaDescription = $state(data.manga.description ?? '');
|
||||
/* svelte-ignore state_referenced_locally */
|
||||
let mangaAuthors = $state<string[]>(data.manga.authors.map((a) => a.name));
|
||||
let authorDraft = $state('');
|
||||
/* svelte-ignore state_referenced_locally */
|
||||
let mangaAltTitles = $state<string[]>([...data.manga.alt_titles]);
|
||||
let altTitleDraft = $state('');
|
||||
/* svelte-ignore state_referenced_locally */
|
||||
let mangaGenreIds = $state<string[]>(data.manga.genres.map((g) => g.id));
|
||||
|
||||
let coverFile = $state<File | null>(null);
|
||||
let coverError = $state<string | null>(null);
|
||||
let pendingCoverRemoval = $state(false);
|
||||
/* svelte-ignore state_referenced_locally */
|
||||
let currentCoverPath = $state<string | null>(data.manga.cover_image_path);
|
||||
|
||||
let submitting = $state(false);
|
||||
let mangaError = $state<string | null>(null);
|
||||
|
||||
const canSubmit = $derived(
|
||||
mangaTitle.trim().length > 0 && !coverError && !submitting
|
||||
);
|
||||
|
||||
function addAuthor() {
|
||||
const name = authorDraft.trim();
|
||||
if (!name) return;
|
||||
if (!mangaAuthors.some((a) => a.toLowerCase() === name.toLowerCase())) {
|
||||
mangaAuthors = [...mangaAuthors, name];
|
||||
}
|
||||
authorDraft = '';
|
||||
}
|
||||
function removeAuthor(name: string) {
|
||||
mangaAuthors = mangaAuthors.filter((a) => a !== name);
|
||||
}
|
||||
function addAltTitle() {
|
||||
const t = altTitleDraft.trim();
|
||||
if (!t) return;
|
||||
if (!mangaAltTitles.includes(t)) {
|
||||
mangaAltTitles = [...mangaAltTitles, t];
|
||||
}
|
||||
altTitleDraft = '';
|
||||
}
|
||||
function removeAltTitle(t: string) {
|
||||
mangaAltTitles = mangaAltTitles.filter((x) => x !== t);
|
||||
}
|
||||
function toggleGenre(id: string) {
|
||||
mangaGenreIds = mangaGenreIds.includes(id)
|
||||
? mangaGenreIds.filter((g) => g !== id)
|
||||
: [...mangaGenreIds, id];
|
||||
}
|
||||
function onCoverChange(e: Event) {
|
||||
const input = e.target as HTMLInputElement;
|
||||
const file = input.files?.[0] ?? null;
|
||||
coverFile = file;
|
||||
coverError = file ? validateImageFile(file) : null;
|
||||
// Picking a replacement supersedes a pending "remove" click.
|
||||
if (file) pendingCoverRemoval = false;
|
||||
}
|
||||
function markCoverForRemoval() {
|
||||
pendingCoverRemoval = true;
|
||||
coverFile = null;
|
||||
coverError = null;
|
||||
// Clear the file input so re-picking the same file still fires
|
||||
// `change` and undoes the removal.
|
||||
const input = document.getElementById('cover-input') as HTMLInputElement | null;
|
||||
if (input) input.value = '';
|
||||
}
|
||||
function undoCoverRemoval() {
|
||||
pendingCoverRemoval = false;
|
||||
}
|
||||
|
||||
async function submit(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
if (!canSubmit) return;
|
||||
if (authorDraft.trim()) addAuthor();
|
||||
if (altTitleDraft.trim()) addAltTitle();
|
||||
submitting = true;
|
||||
mangaError = null;
|
||||
try {
|
||||
// The textarea is the source of truth for description on
|
||||
// screen, so we always send it — trimmed-empty collapses to
|
||||
// null (explicit clear).
|
||||
await updateManga(manga.id, {
|
||||
title: mangaTitle.trim(),
|
||||
status: mangaStatus,
|
||||
authors: mangaAuthors,
|
||||
alt_titles: mangaAltTitles,
|
||||
genre_ids: mangaGenreIds,
|
||||
description: mangaDescription.trim() || null
|
||||
});
|
||||
if (pendingCoverRemoval) {
|
||||
const refreshed = await deleteMangaCover(manga.id);
|
||||
currentCoverPath = refreshed.cover_image_path;
|
||||
} else if (coverFile) {
|
||||
const refreshed = await updateMangaCover(manga.id, coverFile);
|
||||
currentCoverPath = refreshed.cover_image_path;
|
||||
}
|
||||
await goto(`/manga/${manga.id}`);
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError && e.status === 401) {
|
||||
await goto(`/login?next=/manga/${manga.id}/edit`);
|
||||
return;
|
||||
}
|
||||
mangaError = e instanceof Error ? e.message : String(e);
|
||||
submitting = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Edit {manga.title} — Mangalord</title>
|
||||
</svelte:head>
|
||||
|
||||
<h1>Edit manga</h1>
|
||||
|
||||
{#if !session.loaded}
|
||||
<p class="status" data-testid="edit-loading">Loading…</p>
|
||||
{:else if !session.user}
|
||||
<p class="status" data-testid="edit-signin">
|
||||
<a href="/login?next=/manga/{manga.id}/edit">Sign in</a> to edit this manga.
|
||||
</p>
|
||||
{:else}
|
||||
<form onsubmit={submit} action="javascript:void(0)" data-testid="manga-edit-form">
|
||||
<section class="card">
|
||||
<h2>Manga details</h2>
|
||||
<label class="form-field">
|
||||
<span>Title <span aria-hidden="true">*</span></span>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={mangaTitle}
|
||||
required
|
||||
maxlength="200"
|
||||
data-testid="manga-title"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="form-field">
|
||||
<span>Status</span>
|
||||
<select bind:value={mangaStatus} data-testid="manga-status">
|
||||
<option value="ongoing">Ongoing</option>
|
||||
<option value="completed">Completed</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<div class="form-field">
|
||||
<span>Authors</span>
|
||||
<div class="token-row">
|
||||
{#each mangaAuthors as a (a)}
|
||||
<Chip label={a} variant="primary" onRemove={() => removeAuthor(a)} />
|
||||
{/each}
|
||||
</div>
|
||||
<div class="token-input-row">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={authorDraft}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
addAuthor();
|
||||
}
|
||||
}}
|
||||
placeholder="Add author"
|
||||
maxlength="200"
|
||||
data-testid="manga-author-input"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="icon-btn primary"
|
||||
onclick={addAuthor}
|
||||
disabled={!authorDraft.trim()}
|
||||
aria-label="Add author"
|
||||
title="Add author"
|
||||
>
|
||||
<Plus size={16} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-field">
|
||||
<span>Genres</span>
|
||||
<div class="genre-grid" data-testid="manga-genres">
|
||||
{#each genres as g (g.id)}
|
||||
<label class="genre-toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={mangaGenreIds.includes(g.id)}
|
||||
onchange={() => toggleGenre(g.id)}
|
||||
/>
|
||||
<span>{g.name}</span>
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-field">
|
||||
<span>Alternative titles</span>
|
||||
<div class="token-row">
|
||||
{#each mangaAltTitles as t (t)}
|
||||
<Chip label={t} onRemove={() => removeAltTitle(t)} />
|
||||
{/each}
|
||||
</div>
|
||||
<div class="token-input-row">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={altTitleDraft}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
addAltTitle();
|
||||
}
|
||||
}}
|
||||
placeholder="Add alternative title"
|
||||
maxlength="200"
|
||||
data-testid="manga-alt-input"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="icon-btn primary"
|
||||
onclick={addAltTitle}
|
||||
disabled={!altTitleDraft.trim()}
|
||||
aria-label="Add alternative title"
|
||||
title="Add alternative title"
|
||||
>
|
||||
<Plus size={16} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label class="form-field">
|
||||
<span>Description</span>
|
||||
<textarea
|
||||
bind:value={mangaDescription}
|
||||
rows="4"
|
||||
data-testid="manga-description"
|
||||
></textarea>
|
||||
</label>
|
||||
|
||||
<div class="form-field">
|
||||
<span>Cover</span>
|
||||
{#if currentCoverPath && !pendingCoverRemoval}
|
||||
<div class="cover-preview" data-testid="cover-preview">
|
||||
<img
|
||||
src={fileUrl(currentCoverPath)}
|
||||
alt="Current cover"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="icon-btn danger"
|
||||
onclick={markCoverForRemoval}
|
||||
aria-label="Remove cover"
|
||||
title="Remove cover"
|
||||
data-testid="cover-remove"
|
||||
>
|
||||
<Trash2 size={16} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
{:else if pendingCoverRemoval}
|
||||
<p class="hint" data-testid="cover-pending-removal">
|
||||
Cover will be removed on save.
|
||||
<button
|
||||
type="button"
|
||||
class="text-link"
|
||||
onclick={undoCoverRemoval}
|
||||
data-testid="cover-undo-remove"
|
||||
>
|
||||
Undo
|
||||
</button>
|
||||
</p>
|
||||
{/if}
|
||||
<input
|
||||
id="cover-input"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onchange={onCoverChange}
|
||||
data-testid="manga-cover"
|
||||
/>
|
||||
{#if coverFile}
|
||||
<span class="hint">
|
||||
Will upload: {coverFile.name} ({formatBytes(coverFile.size)})
|
||||
</span>
|
||||
{/if}
|
||||
{#if coverError}
|
||||
<span class="field-error" role="alert">{coverError}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="actions">
|
||||
<button
|
||||
class="primary"
|
||||
type="submit"
|
||||
disabled={!canSubmit}
|
||||
data-testid="manga-edit-submit"
|
||||
>
|
||||
{submitting ? 'Saving…' : 'Save changes'}
|
||||
</button>
|
||||
<a class="cancel" href="/manga/{manga.id}" data-testid="manga-edit-cancel">
|
||||
Cancel
|
||||
</a>
|
||||
</div>
|
||||
{#if mangaError}
|
||||
<p role="alert" class="form-error" data-testid="manga-edit-error">{mangaError}</p>
|
||||
{/if}
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.status {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-4);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: var(--space-3);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.primary {
|
||||
background: var(--primary);
|
||||
color: var(--primary-contrast);
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.primary:hover:not(:disabled) {
|
||||
background: var(--primary-hover);
|
||||
border-color: var(--primary-hover);
|
||||
}
|
||||
|
||||
.cancel {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.hint {
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-sm);
|
||||
}
|
||||
|
||||
.field-error {
|
||||
color: var(--danger);
|
||||
font-size: var(--font-sm);
|
||||
}
|
||||
|
||||
.form-error {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.token-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
margin-bottom: var(--space-1);
|
||||
}
|
||||
|
||||
.token-input-row {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.token-input-row input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.genre-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.genre-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
color: var(--text);
|
||||
font-size: var(--font-sm);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.icon-btn:hover:not(:disabled) {
|
||||
background: var(--surface-elevated);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.icon-btn.primary {
|
||||
background: var(--primary);
|
||||
color: var(--primary-contrast);
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.icon-btn.primary:hover:not(:disabled) {
|
||||
background: var(--primary-hover);
|
||||
border-color: var(--primary-hover);
|
||||
}
|
||||
|
||||
.icon-btn.danger:hover:not(:disabled) {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.cover-preview {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.cover-preview img {
|
||||
max-width: 160px;
|
||||
height: auto;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
display: block;
|
||||
}
|
||||
|
||||
.text-link {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
color: var(--primary);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.text-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
</style>
|
||||
13
frontend/src/routes/manga/[id]/edit/+page.ts
Normal file
13
frontend/src/routes/manga/[id]/edit/+page.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { getManga } from '$lib/api/mangas';
|
||||
import { listGenres, type Genre } from '$lib/api/genres';
|
||||
import type { PageLoad } from './$types';
|
||||
|
||||
export const ssr = false;
|
||||
|
||||
export const load: PageLoad = async ({ params }) => {
|
||||
const [manga, genres] = await Promise.all([
|
||||
getManga(params.id),
|
||||
listGenres()
|
||||
]);
|
||||
return { manga, genres: genres as Genre[] };
|
||||
};
|
||||
@@ -1,20 +1,26 @@
|
||||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import { defineConfig } from 'vite';
|
||||
import { defineConfig, loadEnv } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [sveltekit()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: process.env.BACKEND_URL ?? 'http://localhost:8080',
|
||||
changeOrigin: true
|
||||
export default defineConfig(({ mode }) => {
|
||||
// Pull in .env so VITE_PORT / BACKEND_URL pin the dev URL across runs.
|
||||
// Empty prefix loads every key, not just VITE_*.
|
||||
const env = { ...process.env, ...loadEnv(mode, process.cwd(), '') };
|
||||
return {
|
||||
plugins: [sveltekit()],
|
||||
server: {
|
||||
port: Number(env.VITE_PORT ?? 5173),
|
||||
strictPort: env.VITE_PORT != null,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: env.BACKEND_URL ?? 'http://localhost:8080',
|
||||
changeOrigin: true
|
||||
}
|
||||
}
|
||||
},
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
include: ['src/**/*.test.ts'],
|
||||
globals: false
|
||||
}
|
||||
},
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
include: ['src/**/*.test.ts'],
|
||||
globals: false
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user