diff --git a/backend/Cargo.lock b/backend/Cargo.lock index f6aaccd..a6fc1e9 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -1558,7 +1558,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" [[package]] name = "mangalord" -version = "0.106.0" +version = "0.107.0" dependencies = [ "anyhow", "argon2", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 593900b..6219956 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mangalord" -version = "0.106.0" +version = "0.107.0" edition = "2021" default-run = "mangalord" diff --git a/frontend/e2e/reaction-buttons.spec.ts b/frontend/e2e/reaction-buttons.spec.ts new file mode 100644 index 0000000..ee0b42d --- /dev/null +++ b/frontend/e2e/reaction-buttons.spec.ts @@ -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'); +}); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index e945a73..63104ef 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "mangalord-frontend", - "version": "0.106.0", + "version": "0.107.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "mangalord-frontend", - "version": "0.106.0", + "version": "0.107.0", "devDependencies": { "@lucide/svelte": "^1.16.0", "@playwright/test": "^1.48.0", diff --git a/frontend/package.json b/frontend/package.json index 929d8a0..3c27bce 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "mangalord-frontend", - "version": "0.106.0", + "version": "0.107.0", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/lib/api/reactions.ts b/frontend/src/lib/api/reactions.ts new file mode 100644 index 0000000..76d549f --- /dev/null +++ b/frontend/src/lib/api/reactions.ts @@ -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 { + return request(`/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 { + await request(`/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 { + try { + const r = await request( + `/v1/me/reactions/${encodeURIComponent(mangaId)}` + ); + return r.reaction; + } catch (e) { + if (e instanceof ApiError && (e.status === 401 || e.status === 404)) return null; + throw e; + } +} diff --git a/frontend/src/lib/components/ReactionButtons.svelte b/frontend/src/lib/components/ReactionButtons.svelte new file mode 100644 index 0000000..9da9173 --- /dev/null +++ b/frontend/src/lib/components/ReactionButtons.svelte @@ -0,0 +1,117 @@ + + +
+ + +
+ + diff --git a/frontend/src/lib/components/ReactionButtons.svelte.test.ts b/frontend/src/lib/components/ReactionButtons.svelte.test.ts new file mode 100644 index 0000000..ee49c38 --- /dev/null +++ b/frontend/src/lib/components/ReactionButtons.svelte.test.ts @@ -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'); + }); +}); diff --git a/frontend/src/routes/manga/[id]/+page.svelte b/frontend/src/routes/manga/[id]/+page.svelte index 06534f8..b9a5b35 100644 --- a/frontend/src/routes/manga/[id]/+page.svelte +++ b/frontend/src/routes/manga/[id]/+page.svelte @@ -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'} +