feat(detail): like/dislike buttons on the manga page
Add a tri-state like/dislike toggle beside the bookmark action (signed-in only): click a reaction to set it, the other to switch, or the active one to clear — optimistic with rollback, mirroring the bookmark toggle. Seeds from the new GET /me/reactions/:id in the detail loader (non-critical: any failure degrades to an unset toggle). Unit tests cover the state machine; e2e drives set → switch → clear against the real endpoints. 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]]
|
[[package]]
|
||||||
name = "mangalord"
|
name = "mangalord"
|
||||||
version = "0.106.0"
|
version = "0.107.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"argon2",
|
"argon2",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "mangalord"
|
name = "mangalord"
|
||||||
version = "0.106.0"
|
version = "0.107.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
default-run = "mangalord"
|
default-run = "mangalord"
|
||||||
|
|
||||||
|
|||||||
85
frontend/e2e/reaction-buttons.spec.ts
Normal file
85
frontend/e2e/reaction-buttons.spec.ts
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import { test, expect, type Page } from './fixtures';
|
||||||
|
|
||||||
|
// Detail-page like/dislike toggle: reflects initial state, sets/switches via
|
||||||
|
// PUT, and clears the active reaction via DELETE.
|
||||||
|
|
||||||
|
const mangaId = 'a1111111-1111-1111-1111-111111111111';
|
||||||
|
|
||||||
|
type Captured = { method: string; reaction?: string };
|
||||||
|
|
||||||
|
async function mockDetail(page: Page, captured: Captured[]) {
|
||||||
|
await page.route('**/api/v1/auth/config', (r) =>
|
||||||
|
r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ self_register_enabled: true, private_mode: false }) })
|
||||||
|
);
|
||||||
|
await page.route('**/api/v1/auth/me', (r) =>
|
||||||
|
r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ user: { id: 'u1', username: 'reader', created_at: '2026-01-01T00:00:00Z', is_admin: false } }) })
|
||||||
|
);
|
||||||
|
await page.route('**/api/v1/auth/me/preferences', (r) =>
|
||||||
|
r.fulfill({ status: 200, contentType: 'application/json', body: '{}' })
|
||||||
|
);
|
||||||
|
await page.route('**/api/v1/me/bookmarks*', (r) =>
|
||||||
|
r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ items: [], page: { limit: 50, offset: 0, total: 0 } }) })
|
||||||
|
);
|
||||||
|
await page.route(`**/api/v1/mangas/${mangaId}/chapters*`, (r) =>
|
||||||
|
r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ items: [], page: { limit: 50, offset: 0, total: 0 } }) })
|
||||||
|
);
|
||||||
|
await page.route(`**/api/v1/mangas/${mangaId}/similar`, (r) =>
|
||||||
|
r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ items: [] }) })
|
||||||
|
);
|
||||||
|
await page.route(`**/api/v1/me/read-progress/${mangaId}`, (r) =>
|
||||||
|
r.fulfill({ status: 404, contentType: 'application/json', body: JSON.stringify({ error: { code: 'not_found', message: 'no' } }) })
|
||||||
|
);
|
||||||
|
// Reaction: initially unset; capture writes.
|
||||||
|
await page.route(`**/api/v1/me/reactions/${mangaId}`, (r) =>
|
||||||
|
r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ manga_id: mangaId, reaction: null }) })
|
||||||
|
);
|
||||||
|
await page.route(`**/api/v1/mangas/${mangaId}/reaction`, (route) => {
|
||||||
|
const method = route.request().method();
|
||||||
|
if (method === 'PUT') {
|
||||||
|
const reaction = route.request().postDataJSON()?.reaction as string;
|
||||||
|
captured.push({ method, reaction });
|
||||||
|
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ manga_id: mangaId, reaction }) });
|
||||||
|
}
|
||||||
|
captured.push({ method });
|
||||||
|
return route.fulfill({ status: 204, body: '' });
|
||||||
|
});
|
||||||
|
// getManga last so it wins over the chapters glob.
|
||||||
|
await page.route(`**/api/v1/mangas/${mangaId}`, (r) =>
|
||||||
|
r.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({
|
||||||
|
id: mangaId, title: 'Berserk', status: 'ongoing', alt_titles: [], description: null,
|
||||||
|
cover_image_path: null, created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z',
|
||||||
|
authors: [], genres: [], tags: [], content_warnings: [], chapter_storage_bytes: 0
|
||||||
|
})
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('like, switch to dislike, then clear', async ({ page }) => {
|
||||||
|
const captured: Captured[] = [];
|
||||||
|
await mockDetail(page, captured);
|
||||||
|
await page.goto(`/manga/${mangaId}`);
|
||||||
|
|
||||||
|
const like = page.getByTestId('reaction-like');
|
||||||
|
const dislike = page.getByTestId('reaction-dislike');
|
||||||
|
await expect(like).toHaveAttribute('aria-pressed', 'false');
|
||||||
|
await expect(dislike).toHaveAttribute('aria-pressed', 'false');
|
||||||
|
|
||||||
|
// Like.
|
||||||
|
await like.click();
|
||||||
|
await expect(like).toHaveAttribute('aria-pressed', 'true');
|
||||||
|
await expect.poll(() => captured.at(-1)).toEqual({ method: 'PUT', reaction: 'like' });
|
||||||
|
|
||||||
|
// Switch to dislike.
|
||||||
|
await dislike.click();
|
||||||
|
await expect(dislike).toHaveAttribute('aria-pressed', 'true');
|
||||||
|
await expect(like).toHaveAttribute('aria-pressed', 'false');
|
||||||
|
await expect.poll(() => captured.at(-1)).toEqual({ method: 'PUT', reaction: 'dislike' });
|
||||||
|
|
||||||
|
// Click the active dislike again → clear.
|
||||||
|
await dislike.click();
|
||||||
|
await expect(dislike).toHaveAttribute('aria-pressed', 'false');
|
||||||
|
await expect.poll(() => captured.at(-1)?.method).toBe('DELETE');
|
||||||
|
});
|
||||||
4
frontend/package-lock.json
generated
4
frontend/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "mangalord-frontend",
|
"name": "mangalord-frontend",
|
||||||
"version": "0.106.0",
|
"version": "0.107.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "mangalord-frontend",
|
"name": "mangalord-frontend",
|
||||||
"version": "0.106.0",
|
"version": "0.107.0",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@lucide/svelte": "^1.16.0",
|
"@lucide/svelte": "^1.16.0",
|
||||||
"@playwright/test": "^1.48.0",
|
"@playwright/test": "^1.48.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "mangalord-frontend",
|
"name": "mangalord-frontend",
|
||||||
"version": "0.106.0",
|
"version": "0.107.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
42
frontend/src/lib/api/reactions.ts
Normal file
42
frontend/src/lib/api/reactions.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { ApiError, request } from './client';
|
||||||
|
|
||||||
|
/** A private per-user taste signal on a manga. */
|
||||||
|
export type Reaction = 'like' | 'dislike';
|
||||||
|
|
||||||
|
export type MangaReaction = {
|
||||||
|
manga_id: string;
|
||||||
|
reaction: Reaction | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** PUT /v1/mangas/:id/reaction — set (or switch) the user's reaction. */
|
||||||
|
export async function setReaction(mangaId: string, reaction: Reaction): Promise<MangaReaction> {
|
||||||
|
return request<MangaReaction>(`/v1/mangas/${encodeURIComponent(mangaId)}/reaction`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ reaction })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** DELETE /v1/mangas/:id/reaction — clear the user's reaction (idempotent). */
|
||||||
|
export async function clearReaction(mangaId: string): Promise<void> {
|
||||||
|
await request<void>(`/v1/mangas/${encodeURIComponent(mangaId)}/reaction`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the user's reaction for a manga, or `null` when they haven't
|
||||||
|
* reacted (or aren't signed in). Used by the detail page to seed the
|
||||||
|
* like/dislike toggle.
|
||||||
|
*/
|
||||||
|
export async function getMyReactionForManga(mangaId: string): Promise<Reaction | null> {
|
||||||
|
try {
|
||||||
|
const r = await request<MangaReaction>(
|
||||||
|
`/v1/me/reactions/${encodeURIComponent(mangaId)}`
|
||||||
|
);
|
||||||
|
return r.reaction;
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof ApiError && (e.status === 401 || e.status === 404)) return null;
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
117
frontend/src/lib/components/ReactionButtons.svelte
Normal file
117
frontend/src/lib/components/ReactionButtons.svelte
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { setReaction, clearReaction, type Reaction } from '$lib/api/reactions';
|
||||||
|
import ThumbsUp from '@lucide/svelte/icons/thumbs-up';
|
||||||
|
import ThumbsDown from '@lucide/svelte/icons/thumbs-down';
|
||||||
|
|
||||||
|
// Tri-state like/dislike toggle for a manga. Optimistic with rollback,
|
||||||
|
// mirroring the bookmark toggle. Rendered inside the detail page's
|
||||||
|
// signed-in action row, so it isn't independently login-gated.
|
||||||
|
let {
|
||||||
|
mangaId,
|
||||||
|
initial = null
|
||||||
|
}: {
|
||||||
|
mangaId: string;
|
||||||
|
initial?: Reaction | null;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
// svelte-ignore state_referenced_locally
|
||||||
|
let current = $state<Reaction | null>(initial);
|
||||||
|
let busy = $state(false);
|
||||||
|
|
||||||
|
// Click the active reaction to clear it; click the other to switch.
|
||||||
|
async function apply(next: Reaction) {
|
||||||
|
if (busy) return;
|
||||||
|
const prev = current;
|
||||||
|
const target: Reaction | null = current === next ? null : next;
|
||||||
|
current = target; // optimistic
|
||||||
|
busy = true;
|
||||||
|
try {
|
||||||
|
if (target === null) await clearReaction(mangaId);
|
||||||
|
else await setReaction(mangaId, target);
|
||||||
|
} catch {
|
||||||
|
current = prev; // rollback on failure
|
||||||
|
} finally {
|
||||||
|
busy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="reactions" role="group" aria-label="Rate this manga">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="reaction"
|
||||||
|
class:active={current === 'like'}
|
||||||
|
aria-pressed={current === 'like'}
|
||||||
|
aria-label="Like"
|
||||||
|
title="Like"
|
||||||
|
disabled={busy}
|
||||||
|
onclick={() => apply('like')}
|
||||||
|
data-testid="reaction-like"
|
||||||
|
>
|
||||||
|
<ThumbsUp size={16} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="reaction dislike"
|
||||||
|
class:active={current === 'dislike'}
|
||||||
|
aria-pressed={current === 'dislike'}
|
||||||
|
aria-label="Dislike"
|
||||||
|
title="Dislike"
|
||||||
|
disabled={busy}
|
||||||
|
onclick={() => apply('dislike')}
|
||||||
|
data-testid="reaction-dislike"
|
||||||
|
>
|
||||||
|
<ThumbsDown size={16} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.reactions {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reaction {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
padding: 0;
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text);
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
background var(--transition),
|
||||||
|
border-color var(--transition),
|
||||||
|
color var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reaction:hover:not(:disabled) {
|
||||||
|
background: var(--surface-elevated);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reaction:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reaction.active {
|
||||||
|
color: var(--primary-contrast);
|
||||||
|
background: var(--primary);
|
||||||
|
border-color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reaction.dislike.active {
|
||||||
|
color: var(--primary-contrast);
|
||||||
|
background: var(--danger);
|
||||||
|
border-color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reaction:focus-visible {
|
||||||
|
outline: 2px solid var(--focus-ring);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
62
frontend/src/lib/components/ReactionButtons.svelte.test.ts
Normal file
62
frontend/src/lib/components/ReactionButtons.svelte.test.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||||
|
import { render, screen, cleanup, fireEvent } from '@testing-library/svelte';
|
||||||
|
|
||||||
|
const setReaction = vi.fn();
|
||||||
|
const clearReaction = vi.fn();
|
||||||
|
vi.mock('$lib/api/reactions', () => ({
|
||||||
|
setReaction: (...a: unknown[]) => setReaction(...a),
|
||||||
|
clearReaction: (...a: unknown[]) => clearReaction(...a)
|
||||||
|
}));
|
||||||
|
|
||||||
|
import ReactionButtons from './ReactionButtons.svelte';
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
setReaction.mockReset();
|
||||||
|
clearReaction.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
const pressed = (testid: string) =>
|
||||||
|
screen.getByTestId(testid).getAttribute('aria-pressed');
|
||||||
|
|
||||||
|
describe('ReactionButtons', () => {
|
||||||
|
it('reflects the initial reaction', () => {
|
||||||
|
render(ReactionButtons, { props: { mangaId: 'm1', initial: 'like' } });
|
||||||
|
expect(pressed('reaction-like')).toBe('true');
|
||||||
|
expect(pressed('reaction-dislike')).toBe('false');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('likes when nothing is set', async () => {
|
||||||
|
setReaction.mockResolvedValue({ manga_id: 'm1', reaction: 'like' });
|
||||||
|
render(ReactionButtons, { props: { mangaId: 'm1', initial: null } });
|
||||||
|
await fireEvent.click(screen.getByTestId('reaction-like'));
|
||||||
|
expect(setReaction).toHaveBeenCalledWith('m1', 'like');
|
||||||
|
expect(pressed('reaction-like')).toBe('true');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears when clicking the active reaction again', async () => {
|
||||||
|
clearReaction.mockResolvedValue(undefined);
|
||||||
|
render(ReactionButtons, { props: { mangaId: 'm1', initial: 'like' } });
|
||||||
|
await fireEvent.click(screen.getByTestId('reaction-like'));
|
||||||
|
expect(clearReaction).toHaveBeenCalledWith('m1');
|
||||||
|
expect(pressed('reaction-like')).toBe('false');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('switches from like to dislike', async () => {
|
||||||
|
setReaction.mockResolvedValue({ manga_id: 'm1', reaction: 'dislike' });
|
||||||
|
render(ReactionButtons, { props: { mangaId: 'm1', initial: 'like' } });
|
||||||
|
await fireEvent.click(screen.getByTestId('reaction-dislike'));
|
||||||
|
expect(setReaction).toHaveBeenCalledWith('m1', 'dislike');
|
||||||
|
expect(pressed('reaction-dislike')).toBe('true');
|
||||||
|
expect(pressed('reaction-like')).toBe('false');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rolls back on failure', async () => {
|
||||||
|
setReaction.mockRejectedValue(new Error('boom'));
|
||||||
|
render(ReactionButtons, { props: { mangaId: 'm1', initial: null } });
|
||||||
|
await fireEvent.click(screen.getByTestId('reaction-like'));
|
||||||
|
// Optimistic update reverted after the rejection.
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(pressed('reaction-like')).toBe('false');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -20,6 +20,7 @@
|
|||||||
import { session } from '$lib/session.svelte';
|
import { session } from '$lib/session.svelte';
|
||||||
import Chip from '$lib/components/Chip.svelte';
|
import Chip from '$lib/components/Chip.svelte';
|
||||||
import MangaCard from '$lib/components/MangaCard.svelte';
|
import MangaCard from '$lib/components/MangaCard.svelte';
|
||||||
|
import ReactionButtons from '$lib/components/ReactionButtons.svelte';
|
||||||
import Sheet from '$lib/components/Sheet.svelte';
|
import Sheet from '$lib/components/Sheet.svelte';
|
||||||
import AddToCollectionModal from '$lib/components/AddToCollectionModal.svelte';
|
import AddToCollectionModal from '$lib/components/AddToCollectionModal.svelte';
|
||||||
import Plus from '@lucide/svelte/icons/plus';
|
import Plus from '@lucide/svelte/icons/plus';
|
||||||
@@ -541,6 +542,7 @@
|
|||||||
>
|
>
|
||||||
{mangaBookmark ? '★ Bookmarked' : '☆ Bookmark'}
|
{mangaBookmark ? '★ Bookmarked' : '☆ Bookmark'}
|
||||||
</button>
|
</button>
|
||||||
|
<ReactionButtons mangaId={manga.id} initial={data.reaction} />
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="action"
|
class="action"
|
||||||
|
|||||||
@@ -2,17 +2,22 @@ import { getManga, getSimilarMangas, type MangaCard } from '$lib/api/mangas';
|
|||||||
import { listChapters } from '$lib/api/chapters';
|
import { listChapters } from '$lib/api/chapters';
|
||||||
import { listMyBookmarksOrEmpty } from '$lib/api/bookmarks';
|
import { listMyBookmarksOrEmpty } from '$lib/api/bookmarks';
|
||||||
import { getMyReadProgressForManga } from '$lib/api/read_progress';
|
import { getMyReadProgressForManga } from '$lib/api/read_progress';
|
||||||
|
import { getMyReactionForManga } from '$lib/api/reactions';
|
||||||
import type { PageLoad } from './$types';
|
import type { PageLoad } from './$types';
|
||||||
|
|
||||||
export const ssr = false;
|
export const ssr = false;
|
||||||
|
|
||||||
export const load: PageLoad = async ({ params }) => {
|
export const load: PageLoad = async ({ params }) => {
|
||||||
const [manga, chapters, bookmarks, readProgress, similar] = await Promise.all([
|
const [manga, chapters, bookmarks, readProgress, reaction, similar] = await Promise.all([
|
||||||
getManga(params.id),
|
getManga(params.id),
|
||||||
listChapters(params.id),
|
listChapters(params.id),
|
||||||
listMyBookmarksOrEmpty(),
|
listMyBookmarksOrEmpty(),
|
||||||
// Null when guest or never-read — page handles both cases.
|
// Null when guest or never-read — page handles both cases.
|
||||||
getMyReadProgressForManga(params.id),
|
getMyReadProgressForManga(params.id),
|
||||||
|
// Null when guest or not reacted — seeds the like/dislike toggle.
|
||||||
|
// Non-critical: any failure degrades to an unset toggle, never a
|
||||||
|
// broken page.
|
||||||
|
getMyReactionForManga(params.id).catch(() => null),
|
||||||
// Recommendations are non-critical: a failure here must not break
|
// Recommendations are non-critical: a failure here must not break
|
||||||
// the detail page, so fall back to an empty list.
|
// the detail page, so fall back to an empty list.
|
||||||
getSimilarMangas(params.id).catch(() => [] as MangaCard[])
|
getSimilarMangas(params.id).catch(() => [] as MangaCard[])
|
||||||
@@ -22,6 +27,7 @@ export const load: PageLoad = async ({ params }) => {
|
|||||||
chapters: chapters.items,
|
chapters: chapters.items,
|
||||||
bookmarks: bookmarks.items,
|
bookmarks: bookmarks.items,
|
||||||
readProgress,
|
readProgress,
|
||||||
|
reaction,
|
||||||
similar
|
similar
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user