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:
MechaCat02
2026-07-05 17:20:24 +02:00
parent 258a536254
commit bb833c7e71
10 changed files with 320 additions and 6 deletions

View 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;
}
}

View 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>

View 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');
});
});

View File

@@ -20,6 +20,7 @@
import { session } from '$lib/session.svelte';
import Chip from '$lib/components/Chip.svelte';
import MangaCard from '$lib/components/MangaCard.svelte';
import ReactionButtons from '$lib/components/ReactionButtons.svelte';
import Sheet from '$lib/components/Sheet.svelte';
import AddToCollectionModal from '$lib/components/AddToCollectionModal.svelte';
import Plus from '@lucide/svelte/icons/plus';
@@ -541,6 +542,7 @@
>
{mangaBookmark ? '★ Bookmarked' : '☆ Bookmark'}
</button>
<ReactionButtons mangaId={manga.id} initial={data.reaction} />
<button
type="button"
class="action"

View File

@@ -2,17 +2,22 @@ import { getManga, getSimilarMangas, type MangaCard } from '$lib/api/mangas';
import { listChapters } from '$lib/api/chapters';
import { listMyBookmarksOrEmpty } from '$lib/api/bookmarks';
import { getMyReadProgressForManga } from '$lib/api/read_progress';
import { getMyReactionForManga } from '$lib/api/reactions';
import type { PageLoad } from './$types';
export const ssr = false;
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),
listChapters(params.id),
listMyBookmarksOrEmpty(),
// Null when guest or never-read — page handles both cases.
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
// the detail page, so fall back to an empty list.
getSimilarMangas(params.id).catch(() => [] as MangaCard[])
@@ -22,6 +27,7 @@ export const load: PageLoad = async ({ params }) => {
chapters: chapters.items,
bookmarks: bookmarks.items,
readProgress,
reaction,
similar
};
};