diff --git a/src/app/add/page.tsx b/src/app/add/page.tsx index f47fb02..d155d5f 100644 --- a/src/app/add/page.tsx +++ b/src/app/add/page.tsx @@ -81,10 +81,29 @@ export default function AddMoviePage() {

{error}

)} -

+

Supports imdb.com/title/... and criterion.com/films/... URLs

+
+ + 🎞️ Browse Criterion + + + 🎬 Browse IMDB + +
+ {preview && (
diff --git a/src/components/mobile-bottom-nav.tsx b/src/components/mobile-bottom-nav.tsx index 1e9f8c5..47119da 100644 --- a/src/components/mobile-bottom-nav.tsx +++ b/src/components/mobile-bottom-nav.tsx @@ -28,14 +28,17 @@ export function MobileBottomNav() { )} > - {label} + + {label} + ))} diff --git a/src/components/movie-row.tsx b/src/components/movie-row.tsx index 9d86399..8c95374 100644 --- a/src/components/movie-row.tsx +++ b/src/components/movie-row.tsx @@ -9,7 +9,7 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; -import type { Movie } from "@/types"; +import type { Movie, StreamingProvider } from "@/types"; import { formatRuntime } from "@/lib/utils"; const SEERR_LABEL: Record = { @@ -32,7 +32,7 @@ interface MovieRowProps { movie: Movie; position: number; seerrUrl?: string | null; - streamingProviders: { providerId: number; providerName: string }[]; + streamingProviders: StreamingProvider[]; streamingLink: string | null; onMarkWatched: (movie: Movie) => void; onForceDownload: (movieId: number) => void; @@ -136,7 +136,7 @@ export function MovieRow({ href={streamingLink} target="_blank" rel="noopener noreferrer" - className="rounded border border-stone-600 bg-stone-800 text-white px-2 py-0.5 text-xs font-medium hover:bg-stone-700 transition-colors" + className="rounded border border-amber-400 bg-white text-amber-700 px-2 py-0.5 text-xs font-medium hover:bg-amber-50 transition-colors" > Watch ↗ diff --git a/src/components/sidebar-utils.tsx b/src/components/sidebar-utils.tsx index e7fde0e..c2e3ddd 100644 --- a/src/components/sidebar-utils.tsx +++ b/src/components/sidebar-utils.tsx @@ -17,11 +17,11 @@ export function PlexSyncButton() { } } - const label = - state === 'loading' ? '⏳ Syncing…' - : state === 'ok' ? '✅ Synced!' - : state === 'error' ? '❌ Failed' - : '🎭 Sync Plex' + const content: { icon: string; text: string } = + state === 'loading' ? { icon: '⏳', text: 'Syncing…' } + : state === 'ok' ? { icon: '✅', text: 'Synced!' } + : state === 'error' ? { icon: '❌', text: 'Failed' } + : { icon: '🎭', text: 'Sync Plex' } return ( ) } @@ -54,11 +55,11 @@ export function StreamingRefreshButton() { } } - const label = - state === 'loading' ? '⏳ Refreshing…' - : state === 'ok' ? '✅ Refreshed!' - : state === 'error' ? '❌ Failed' - : '📡 Refresh Streaming' + const content: { icon: string; text: string } = + state === 'loading' ? { icon: '⏳', text: 'Refreshing…' } + : state === 'ok' ? { icon: '✅', text: 'Refreshed!' } + : state === 'error' ? { icon: '❌', text: 'Failed' } + : { icon: '📡', text: 'Refresh Streaming' } return ( ) } @@ -93,7 +95,7 @@ export function AskClaudeLink() { rel="noopener noreferrer" className="flex items-center gap-2 px-3 py-2 text-xs text-amber-700 hover:bg-amber-100 rounded-lg transition-colors" > - ✨ Ask Claude + Ask Claude ) } diff --git a/src/components/sidebar.tsx b/src/components/sidebar.tsx index c3b3938..fbf6763 100644 --- a/src/components/sidebar.tsx +++ b/src/components/sidebar.tsx @@ -20,7 +20,7 @@ export function Sidebar() { {/* Logo */}
- 🎬 +
Date Night
@@ -38,7 +38,7 @@ export function Sidebar() { : 'text-amber-800 hover:bg-amber-100' )} > - {icon} + {label} ))} @@ -46,22 +46,6 @@ export function Sidebar() { {/* Utility links */}
- - 🎞️ Browse Criterion - - - 🎬 Browse IMDB - @@ -72,7 +56,7 @@ export function Sidebar() { pathname === '/settings' && 'bg-amber-100 font-semibold' )} > - ⚙️ Settings + Settings
diff --git a/tests/add-page.test.tsx b/tests/add-page.test.tsx new file mode 100644 index 0000000..e5706fd --- /dev/null +++ b/tests/add-page.test.tsx @@ -0,0 +1,25 @@ +// tests/add-page.test.tsx +import { render, screen } from '@testing-library/react' +import { describe, it, expect, vi } from 'vitest' + +vi.mock('next/navigation', () => ({ + useRouter: () => ({ push: vi.fn() }), +})) + +vi.stubGlobal('fetch', vi.fn()) + +import AddMoviePage from '@/app/add/page' + +describe('AddMoviePage', () => { + it('shows Browse Criterion and Browse IMDB helper links', () => { + render() + expect(screen.getByRole('link', { name: /browse criterion/i })).toHaveAttribute( + 'href', + 'https://www.criterion.com/shop/browse/list?q=&format=all' + ) + expect(screen.getByRole('link', { name: /browse imdb/i })).toHaveAttribute( + 'href', + 'https://www.imdb.com/search/title/?title_type=feature' + ) + }) +}) diff --git a/tests/mobile-bottom-nav.test.tsx b/tests/mobile-bottom-nav.test.tsx index 4075779..4bed0cd 100644 --- a/tests/mobile-bottom-nav.test.tsx +++ b/tests/mobile-bottom-nav.test.tsx @@ -32,16 +32,30 @@ describe('MobileBottomNav', () => { it('highlights the active tab', () => { render() - // usePathname returns '/watchlist' — the List link should have the active colour const listLink = screen.getByRole('link', { name: /list/i }) expect(listLink).toHaveClass('text-amber-600') - // Active icon span gets the background pill const iconSpan = listLink.querySelector('span') - expect(iconSpan).toHaveClass('bg-amber-100') - // Inactive tabs should not have the active colour or pill + expect(iconSpan).toHaveClass('bg-amber-600') const watchedLink = screen.getByRole('link', { name: /watched/i }) expect(watchedLink).not.toHaveClass('text-amber-600') const inactiveIconSpan = watchedLink.querySelector('span') - expect(inactiveIconSpan).not.toHaveClass('bg-amber-100') + expect(inactiveIconSpan).not.toHaveClass('bg-amber-600') + }) + + it('applies bold font weight to the active tab label', () => { + render() + const listLink = screen.getByRole('link', { name: /list/i }) + const spans = listLink.querySelectorAll('span') + const labelSpan = spans[spans.length - 1] + expect(labelSpan).toHaveClass('font-bold') + }) + + it('wraps tab emoji icons with aria-hidden', () => { + render() + const links = screen.getAllByRole('link') + links.forEach((link) => { + const iconSpan = link.querySelector('span[aria-hidden="true"]') + expect(iconSpan).not.toBeNull() + }) }) }) diff --git a/tests/mobile-header.test.tsx b/tests/mobile-header.test.tsx index a2744ed..acd39cd 100644 --- a/tests/mobile-header.test.tsx +++ b/tests/mobile-header.test.tsx @@ -27,8 +27,8 @@ describe('MobileHeader', () => { await waitFor(() => { expect(screen.getByText('Browse Criterion')).toBeInTheDocument() expect(screen.getByText('Browse IMDB')).toBeInTheDocument() - expect(screen.getByText('🎭 Sync Plex')).toBeInTheDocument() - expect(screen.getByText('✨ Ask Claude')).toBeInTheDocument() + expect(screen.getByRole('button', { name: /sync plex/i })).toBeInTheDocument() + expect(screen.getByText(/ask claude/i)).toBeInTheDocument() expect(screen.getByRole('link', { name: /settings/i })).toBeInTheDocument() }) }) diff --git a/tests/movie-row.test.tsx b/tests/movie-row.test.tsx index dc3ffae..91f245e 100644 --- a/tests/movie-row.test.tsx +++ b/tests/movie-row.test.tsx @@ -2,7 +2,7 @@ import { render, screen } from '@testing-library/react' import { describe, it, expect, vi, beforeEach } from 'vitest' import { MovieRow } from '@/components/movie-row' -import type { Movie, User } from '@/types' +import type { Movie } from '@/types' vi.mock('next/image', () => ({ default: ({ src, alt }: { src: string; alt: string }) => {alt}, @@ -101,4 +101,18 @@ describe('MovieRow layout', () => { const infoSection = screen.getByText('Jeanne Dielman').closest('div') expect(infoSection).toContainElement(screen.getByText('Queued')) }) + + it('renders Watch link with amber outline instead of dark stone', () => { + render( + + ) + const watchLink = screen.getByRole('link', { name: /watch/i }) + expect(watchLink).not.toHaveClass('bg-stone-800') + expect(watchLink).toHaveClass('border-amber-400') + }) }) diff --git a/tests/sidebar-utils.test.tsx b/tests/sidebar-utils.test.tsx index 82666fa..d268f66 100644 --- a/tests/sidebar-utils.test.tsx +++ b/tests/sidebar-utils.test.tsx @@ -14,7 +14,7 @@ describe('PlexSyncButton', () => { it('renders in idle state', () => { render() - expect(screen.getByText('🎭 Sync Plex')).toBeInTheDocument() + expect(screen.getByRole('button', { name: /sync plex/i })).toBeInTheDocument() }) }) @@ -23,21 +23,21 @@ describe('StreamingRefreshButton', () => { it('renders in idle state', () => { render() - expect(screen.getByText('📡 Refresh Streaming')).toBeInTheDocument() + expect(screen.getByRole('button', { name: /refresh streaming/i })).toBeInTheDocument() }) it('posts to /api/streaming-providers/refresh on click', async () => { vi.mocked(global.fetch).mockResolvedValueOnce({ ok: true, json: async () => ({}) } as any) render() - fireEvent.click(screen.getByText('📡 Refresh Streaming')) + fireEvent.click(screen.getByRole('button', { name: /refresh streaming/i })) expect(global.fetch).toHaveBeenCalledWith('/api/streaming-providers/refresh', { method: 'POST' }) }) it('shows success state after refresh completes', async () => { vi.mocked(global.fetch).mockResolvedValueOnce({ ok: true, json: async () => ({}) } as any) render() - fireEvent.click(screen.getByText('📡 Refresh Streaming')) - await waitFor(() => expect(screen.getByText('✅ Refreshed!')).toBeInTheDocument()) + fireEvent.click(screen.getByRole('button', { name: /refresh streaming/i })) + await waitFor(() => expect(screen.getByText('Refreshed!')).toBeInTheDocument()) }) it('dispatches streaming-refreshed event on success', async () => { @@ -45,7 +45,7 @@ describe('StreamingRefreshButton', () => { const listener = vi.fn() window.addEventListener('streaming-refreshed', listener) render() - fireEvent.click(screen.getByText('📡 Refresh Streaming')) + fireEvent.click(screen.getByRole('button', { name: /refresh streaming/i })) await waitFor(() => expect(listener).toHaveBeenCalled()) window.removeEventListener('streaming-refreshed', listener) }) @@ -53,7 +53,7 @@ describe('StreamingRefreshButton', () => { it('shows error state when refresh fails', async () => { vi.mocked(global.fetch).mockResolvedValueOnce({ ok: false } as any) render() - fireEvent.click(screen.getByText('📡 Refresh Streaming')) - await waitFor(() => expect(screen.getByText('❌ Failed')).toBeInTheDocument()) + fireEvent.click(screen.getByRole('button', { name: /refresh streaming/i })) + await waitFor(() => expect(screen.getByText('Failed')).toBeInTheDocument()) }) }) diff --git a/tests/sidebar.test.tsx b/tests/sidebar.test.tsx new file mode 100644 index 0000000..6165594 --- /dev/null +++ b/tests/sidebar.test.tsx @@ -0,0 +1,39 @@ +// tests/sidebar.test.tsx +import { render, screen } from '@testing-library/react' +import { describe, it, expect, vi } from 'vitest' + +vi.mock('next/navigation', () => ({ + usePathname: () => '/watchlist', +})) + +vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({}), +})) + +import { Sidebar } from '@/components/sidebar' + +describe('Sidebar', () => { + it('renders primary nav links', () => { + render() + expect(screen.getByRole('link', { name: /watch list/i })).toBeInTheDocument() + expect(screen.getByRole('link', { name: /watched/i })).toBeInTheDocument() + expect(screen.getByRole('link', { name: /add movie/i })).toBeInTheDocument() + expect(screen.getByRole('link', { name: /recommend/i })).toBeInTheDocument() + }) + + it('does not render Browse Criterion or Browse IMDB in the utility footer', () => { + render() + expect(screen.queryByText(/browse criterion/i)).not.toBeInTheDocument() + expect(screen.queryByText(/browse imdb/i)).not.toBeInTheDocument() + }) + + it('wraps nav emoji icons with aria-hidden', () => { + render() + const navLinks = screen.getAllByRole('link') + navLinks.forEach((link) => { + const iconSpan = link.querySelector('span[aria-hidden="true"]') + expect(iconSpan).not.toBeNull() + }) + }) +})