Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 20 additions & 1 deletion src/app/add/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -81,10 +81,29 @@ export default function AddMoviePage() {
<p className="text-red-600 text-sm mb-4">{error}</p>
)}

<p className="text-xs text-amber-600 mb-6">
<p className="text-xs text-amber-600">
Supports imdb.com/title/... and criterion.com/films/... URLs
</p>

<div className="flex gap-4 text-xs text-amber-600 mb-6">
<a
href="https://www.criterion.com/shop/browse/list?q=&format=all"
target="_blank"
rel="noopener noreferrer"
className="hover:text-amber-800 underline transition-colors"
>
🎞️ Browse Criterion
</a>
<a
href="https://www.imdb.com/search/title/?title_type=feature"
target="_blank"
rel="noopener noreferrer"
className="hover:text-amber-800 underline transition-colors"
>
🎬 Browse IMDB
</a>
</div>

{preview && (
<div className="bg-white border border-amber-200 rounded-xl p-4 shadow-sm">
<div className="flex gap-4">
Expand Down
7 changes: 5 additions & 2 deletions src/components/mobile-bottom-nav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,14 +28,17 @@ export function MobileBottomNav() {
)}
>
<span
aria-hidden="true"
className={cn(
'text-xl mb-0.5 px-3 py-0.5 rounded-full',
pathname === href && 'bg-amber-100'
pathname === href ? 'bg-amber-600' : ''
)}
>
{icon}
</span>
<span>{label}</span>
<span className={cn(pathname === href ? 'font-bold' : 'font-medium')}>
{label}
</span>
</Link>
))}
</nav>
Expand Down
6 changes: 3 additions & 3 deletions src/components/movie-row.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> = {
Expand All @@ -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;
Expand Down Expand Up @@ -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 ↗
</a>
Expand Down
28 changes: 15 additions & 13 deletions src/components/sidebar-utils.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,19 +17,20 @@ 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 (
<button
onClick={handleClick}
disabled={state === 'loading'}
className="flex items-center gap-2 px-3 py-2 text-xs text-amber-700 hover:bg-amber-100 rounded-lg transition-colors w-full text-left disabled:opacity-60 disabled:cursor-not-allowed"
>
{label}
<span aria-hidden="true">{content.icon}</span>
{content.text}
</button>
)
}
Expand All @@ -54,19 +55,20 @@ 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 (
<button
onClick={handleClick}
disabled={state === 'loading'}
className="flex items-center gap-2 px-3 py-2 text-xs text-amber-700 hover:bg-amber-100 rounded-lg transition-colors w-full text-left disabled:opacity-60 disabled:cursor-not-allowed"
>
{label}
<span aria-hidden="true">{content.icon}</span>
{content.text}
</button>
)
}
Expand All @@ -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
<span aria-hidden="true">✨</span> Ask Claude
</a>
)
}
22 changes: 3 additions & 19 deletions src/components/sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export function Sidebar() {
{/* Logo */}
<div className="flex items-center gap-2 px-4 py-5 border-b border-amber-200">
<div className="w-8 h-8 bg-amber-600 rounded-lg flex items-center justify-center text-white text-sm">
🎬
<span aria-hidden="true">🎬</span>
</div>
<span className="font-extrabold text-amber-900 text-sm">Date Night</span>
</div>
Expand All @@ -38,30 +38,14 @@ export function Sidebar() {
: 'text-amber-800 hover:bg-amber-100'
)}
>
<span>{icon}</span>
<span aria-hidden="true">{icon}</span>
{label}
</Link>
))}
</nav>

{/* Utility links */}
<div className="px-2 py-4 border-t border-amber-200 flex flex-col gap-1">
<a
href="https://www.criterion.com/shop/browse/list?q=&format=all"
target="_blank"
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"
>
🎞️ Browse Criterion
</a>
<a
href="https://www.imdb.com/search/title/?title_type=feature"
target="_blank"
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"
>
🎬 Browse IMDB
</a>
<PlexSyncButton />
<StreamingRefreshButton />
<AskClaudeLink />
Expand All @@ -72,7 +56,7 @@ export function Sidebar() {
pathname === '/settings' && 'bg-amber-100 font-semibold'
)}
>
⚙️ Settings
<span aria-hidden="true">⚙️</span> Settings
</Link>
</div>
</aside>
Expand Down
25 changes: 25 additions & 0 deletions tests/add-page.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<AddMoviePage />)
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'
)
})
})
24 changes: 19 additions & 5 deletions tests/mobile-bottom-nav.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,16 +32,30 @@ describe('MobileBottomNav', () => {

it('highlights the active tab', () => {
render(<MobileBottomNav />)
// 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(<MobileBottomNav />)
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(<MobileBottomNav />)
const links = screen.getAllByRole('link')
links.forEach((link) => {
const iconSpan = link.querySelector('span[aria-hidden="true"]')
expect(iconSpan).not.toBeNull()
})
})
})
4 changes: 2 additions & 2 deletions tests/mobile-header.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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()
})
})
Expand Down
16 changes: 15 additions & 1 deletion tests/movie-row.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => <img src={src} alt={alt} />,
Expand Down Expand Up @@ -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(
<MovieRow
movie={makeMovie({ seerrStatus: 'available' })}
{...defaultProps}
streamingProviders={[{ id: 99, movieId: 1, providerId: 8, providerName: 'Netflix' }]}
streamingLink="https://netflix.com"
/>
)
const watchLink = screen.getByRole('link', { name: /watch/i })
expect(watchLink).not.toHaveClass('bg-stone-800')
expect(watchLink).toHaveClass('border-amber-400')
})
})
16 changes: 8 additions & 8 deletions tests/sidebar-utils.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ describe('PlexSyncButton', () => {

it('renders in idle state', () => {
render(<PlexSyncButton />)
expect(screen.getByText('🎭 Sync Plex')).toBeInTheDocument()
expect(screen.getByRole('button', { name: /sync plex/i })).toBeInTheDocument()
})
})

Expand All @@ -23,37 +23,37 @@ describe('StreamingRefreshButton', () => {

it('renders in idle state', () => {
render(<StreamingRefreshButton />)
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(<StreamingRefreshButton />)
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(<StreamingRefreshButton />)
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 () => {
vi.mocked(global.fetch).mockResolvedValueOnce({ ok: true, json: async () => ({}) } as any)
const listener = vi.fn()
window.addEventListener('streaming-refreshed', listener)
render(<StreamingRefreshButton />)
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)
})

it('shows error state when refresh fails', async () => {
vi.mocked(global.fetch).mockResolvedValueOnce({ ok: false } as any)
render(<StreamingRefreshButton />)
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())
})
})
39 changes: 39 additions & 0 deletions tests/sidebar.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<Sidebar />)
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(<Sidebar />)
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(<Sidebar />)
const navLinks = screen.getAllByRole('link')
navLinks.forEach((link) => {
const iconSpan = link.querySelector('span[aria-hidden="true"]')
expect(iconSpan).not.toBeNull()
})
})
})
Loading