Add new API endpoints for media retrieval by country and enhance configuration
Some checks failed
Test Suite / test (push) Has been cancelled

- Introduced `/api/tmdb` and `/api/collection/missing-locations` endpoints to the backend for improved media management.
- Added a new `get_media_by_country` function in the collection API to fetch media items based on country codes.
- Updated configuration to allow overriding *arr base URLs via environment variables for better flexibility.
- Enhanced frontend with a new `MissingLocations` component and integrated it into the routing structure.
- Improved the `CollectionMap` component to handle country selection and display media items accordingly.
- Added testing dependencies in `requirements.txt` and updated frontend configuration for testing support.
This commit is contained in:
Danilo Reyes
2025-12-28 22:35:06 -06:00
parent 4caba81599
commit 2b1a92fb49
32 changed files with 2733 additions and 76 deletions

View File

@@ -20,7 +20,19 @@
"@types/leaflet": "^1.9.8",
"@vitejs/plugin-react": "^4.2.1",
"typescript": "^5.3.3",
"vite": "^5.0.8"
"vite": "^5.0.8",
"vitest": "^1.0.4",
"@testing-library/react": "^14.1.2",
"@testing-library/jest-dom": "^6.1.5",
"@testing-library/user-event": "^14.5.1",
"jsdom": "^23.0.1"
},
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"test": "vitest",
"test:ui": "vitest --ui"
}
}

View File

@@ -2,6 +2,7 @@ import { useState } from 'react'
import { BrowserRouter as Router, Routes, Route, Link, useLocation } from 'react-router-dom'
import CollectionMap from './components/CollectionMap'
import WatchedMap from './components/WatchedMap'
import MissingLocations from './components/MissingLocations'
import './App.css'
function App() {
@@ -12,6 +13,7 @@ function App() {
<Routes>
<Route path="/" element={<CollectionMap />} />
<Route path="/watched" element={<WatchedMap />} />
<Route path="/missing-locations" element={<MissingLocations />} />
</Routes>
</div>
</Router>
@@ -38,6 +40,12 @@ function NavBar() {
>
Watched Map
</Link>
<Link
to="/missing-locations"
className={location.pathname === '/missing-locations' ? 'active' : ''}
>
Missing Locations
</Link>
</div>
</div>
</nav>

View File

@@ -0,0 +1,79 @@
.autocomplete-container {
position: relative;
width: 100%;
}
.autocomplete-input {
width: 100%;
padding: 8px 12px;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 14px;
}
.autocomplete-input:focus {
outline: none;
border-color: #4a90e2;
box-shadow: 0 0 0 2px rgba(74, 144, 226, 0.2);
}
.autocomplete-loading {
position: absolute;
top: 100%;
left: 0;
right: 0;
padding: 8px;
background: white;
border: 1px solid #ccc;
border-top: none;
border-radius: 0 0 4px 4px;
font-size: 12px;
color: #666;
z-index: 1000;
}
.autocomplete-suggestions {
position: absolute;
top: 100%;
left: 0;
right: 0;
max-height: 300px;
overflow-y: auto;
background: white;
border: 1px solid #ccc;
border-top: none;
border-radius: 0 0 4px 4px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
z-index: 1000;
}
.autocomplete-suggestion {
padding: 12px;
cursor: pointer;
border-bottom: 1px solid #f0f0f0;
}
.autocomplete-suggestion:last-child {
border-bottom: none;
}
.autocomplete-suggestion:hover {
background: #f5f5f5;
}
.suggestion-title {
font-weight: 500;
margin-bottom: 4px;
}
.suggestion-year {
color: #666;
font-weight: normal;
}
.suggestion-overview {
font-size: 12px;
color: #666;
line-height: 1.4;
}

View File

@@ -0,0 +1,113 @@
import { useState, useEffect, useRef } from 'react'
import { searchTMDB, TMDBResult } from '../utils/tmdb'
import './AutocompleteInput.css'
interface AutocompleteInputProps {
value: string
onChange: (value: string) => void
onSelect: (result: TMDBResult) => void
type: 'movie' | 'tv'
placeholder?: string
disabled?: boolean
}
export default function AutocompleteInput({
value,
onChange,
onSelect,
type,
placeholder = 'Search...',
disabled = false,
}: AutocompleteInputProps) {
const [suggestions, setSuggestions] = useState<TMDBResult[]>([])
const [showSuggestions, setShowSuggestions] = useState(false)
const [loading, setLoading] = useState(false)
const inputRef = useRef<HTMLInputElement>(null)
const suggestionsRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const timeoutId = setTimeout(() => {
if (value.trim().length >= 2) {
performSearch(value)
} else {
setSuggestions([])
setShowSuggestions(false)
}
}, 300) // Debounce 300ms
return () => clearTimeout(timeoutId)
}, [value, type])
const performSearch = async (query: string) => {
setLoading(true)
try {
const results = await searchTMDB(query, type)
setSuggestions(results)
setShowSuggestions(results.length > 0)
} catch (error) {
console.error('Search error:', error)
setSuggestions([])
setShowSuggestions(false)
} finally {
setLoading(false)
}
}
const handleSelect = (result: TMDBResult) => {
onChange(result.title)
onSelect(result)
setShowSuggestions(false)
inputRef.current?.blur()
}
const handleBlur = () => {
// Delay hiding suggestions to allow click events
setTimeout(() => {
setShowSuggestions(false)
}, 200)
}
return (
<div className="autocomplete-container">
<input
ref={inputRef}
type="text"
value={value}
onChange={(e) => onChange(e.target.value)}
onFocus={() => {
if (suggestions.length > 0) {
setShowSuggestions(true)
}
}}
onBlur={handleBlur}
placeholder={placeholder}
disabled={disabled}
className="autocomplete-input"
/>
{loading && <div className="autocomplete-loading">Searching...</div>}
{showSuggestions && suggestions.length > 0 && (
<div ref={suggestionsRef} className="autocomplete-suggestions">
{suggestions.map((result) => (
<div
key={`${result.type}-${result.id}`}
className="autocomplete-suggestion"
onMouseDown={(e) => {
e.preventDefault()
handleSelect(result)
}}
>
<div className="suggestion-title">
{result.title}
{result.year && <span className="suggestion-year"> ({result.year})</span>}
</div>
{result.overview && (
<div className="suggestion-overview">{result.overview.substring(0, 100)}...</div>
)}
</div>
))}
</div>
)}
</div>
)
}

View File

@@ -1,8 +1,9 @@
import { useEffect, useState } from 'react'
import { MapContainer, TileLayer, GeoJSON, CircleMarker, Popup } from 'react-leaflet'
import { LatLngExpression } from 'leaflet'
import { MapContainer, TileLayer, GeoJSON } from 'react-leaflet'
import 'leaflet/dist/leaflet.css'
import './Map.css'
import { formatCountryDisplay, getCountryInfo } from '../utils/countries'
import CountryMediaList from './CountryMediaList'
interface CountryData {
[countryCode: string]: {
@@ -27,6 +28,7 @@ export default function CollectionMap() {
})
const [loading, setLoading] = useState(true)
const [worldGeoJson, setWorldGeoJson] = useState<any>(null)
const [selectedCountry, setSelectedCountry] = useState<string | null>(null)
useEffect(() => {
// Load world countries GeoJSON
@@ -78,36 +80,29 @@ export default function CollectionMap() {
if (count === 0) return '#e0e0e0'
const intensity = count / maxCount
// Blue gradient: light blue to dark blue
const r = Math.floor(74 + (180 - 74) * (1 - intensity))
const g = Math.floor(144 + (200 - 144) * (1 - intensity))
const b = Math.floor(226 + (255 - 226) * (1 - intensity))
// Enhanced gradient: light blue to vibrant dark blue
// More vibrant colors for better visibility
const r = Math.floor(100 + (30 - 100) * intensity)
const g = Math.floor(150 + (60 - 150) * intensity)
const b = Math.floor(255 + (200 - 255) * intensity)
return `rgb(${r}, ${g}, ${b})`
}
const getCountryCenter = (countryCode: string): LatLngExpression | null => {
// Simplified country centers - in production, use a proper lookup
const centers: { [key: string]: LatLngExpression } = {
'US': [39.8283, -98.5795],
'GB': [55.3781, -3.4360],
'FR': [46.2276, 2.2137],
'DE': [51.1657, 10.4515],
'JP': [36.2048, 138.2529],
'CN': [35.8617, 104.1954],
'IN': [20.5937, 78.9629],
'KR': [35.9078, 127.7669],
'TH': [15.8700, 100.9925],
'MX': [23.6345, -102.5528],
'BR': [-14.2350, -51.9253],
'CA': [56.1304, -106.3468],
'AU': [-25.2744, 133.7751],
'IT': [41.8719, 12.5674],
'ES': [40.4637, -3.7492],
'RU': [61.5240, 105.3188],
}
return centers[countryCode] || null
const handleCountryClick = (countryCode: string) => {
setSelectedCountry(countryCode)
}
useEffect(() => {
const handleShowCountryMedia = (event: CustomEvent) => {
setSelectedCountry(event.detail.countryCode)
}
window.addEventListener('showCountryMedia', handleShowCountryMedia as EventListener)
return () => {
window.removeEventListener('showCountryMedia', handleShowCountryMedia as EventListener)
}
}, [])
const toggleFilter = (type: keyof MediaTypeFilter) => {
setFilters(prev => ({ ...prev, [type]: !prev[type] }))
}
@@ -176,41 +171,51 @@ export default function CollectionMap() {
const count = getCountryCount(code)
const data = countryData[code] || {}
layer.bindPopup(`
<strong>${feature.properties.NAME || code}</strong><br/>
Total: ${count}<br/>
${data.movie ? `Movies: ${data.movie}<br/>` : ''}
${data.show ? `Shows: ${data.show}<br/>` : ''}
${data.music ? `Music: ${data.music}` : ''}
`)
if (count === 0) return
const countryInfo = getCountryInfo(code)
const displayName = formatCountryDisplay(code)
const popupContent = `
<div style="cursor: pointer; text-align: center;">
<strong style="font-size: 16px;">${displayName}</strong><br/>
<div style="margin-top: 8px;">
<strong>Count: ${count}</strong><br/>
${data.movie ? `<span>Movies: ${data.movie}</span><br/>` : ''}
${data.show ? `<span>Shows: ${data.show}</span><br/>` : ''}
${data.music ? `<span>Music: ${data.music}</span>` : ''}
</div>
<div style="margin-top: 8px; font-size: 12px; color: #666;">
Click to view media
</div>
</div>
`
layer.bindPopup(popupContent)
// Make popup clickable
layer.on('popupopen', () => {
const popup = layer.getPopup()
if (popup) {
const popupElement = popup.getElement()
if (popupElement) {
popupElement.style.cursor = 'pointer'
popupElement.addEventListener('click', () => {
handleCountryClick(code)
})
}
}
})
}}
/>
)}
{Object.keys(countryData).map(code => {
const count = getCountryCount(code)
if (count === 0) return null
const center = getCountryCenter(code)
if (!center) return null
return (
<CircleMarker
key={code}
center={center}
radius={Math.max(8, Math.min(30, count / 2))}
fillColor="#ff6b6b"
fillOpacity={0.8}
color="#fff"
weight={2}
>
<Popup>
<strong>{code}</strong><br/>
Count: {count}
</Popup>
</CircleMarker>
)
})}
</MapContainer>
{selectedCountry && (
<CountryMediaList
countryCode={selectedCountry}
onClose={() => setSelectedCountry(null)}
/>
)}
</div>
)
}

View File

@@ -0,0 +1,130 @@
.country-media-modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
}
.country-media-modal {
background: white;
border-radius: 8px;
width: 90%;
max-width: 600px;
max-height: 80vh;
display: flex;
flex-direction: column;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
.country-media-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px;
border-bottom: 1px solid #e0e0e0;
}
.country-media-header h2 {
margin: 0;
font-size: 24px;
}
.close-button {
background: none;
border: none;
font-size: 32px;
cursor: pointer;
color: #666;
padding: 0;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
line-height: 1;
}
.close-button:hover {
color: #000;
}
.country-media-content {
padding: 20px;
overflow-y: auto;
flex: 1;
}
.media-count {
margin-bottom: 16px;
color: #666;
font-size: 14px;
}
.media-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.media-item {
padding: 12px;
border: 1px solid #e0e0e0;
border-radius: 4px;
background: #f9f9f9;
}
.media-item-title {
margin-bottom: 8px;
}
.media-item-title strong {
font-size: 16px;
}
.media-year {
color: #666;
font-weight: normal;
}
.media-item-meta {
display: flex;
gap: 12px;
font-size: 14px;
color: #666;
}
.media-type {
padding: 2px 8px;
background: #e3f2fd;
border-radius: 4px;
font-size: 12px;
}
.media-source {
padding: 2px 8px;
background: #f3e5f5;
border-radius: 4px;
font-size: 12px;
}
.no-items {
text-align: center;
color: #666;
padding: 40px 20px;
}
.loading, .error {
text-align: center;
padding: 40px 20px;
}
.error {
color: #d32f2f;
}

View File

@@ -0,0 +1,106 @@
import { useEffect, useState } from 'react'
import { formatCountryDisplay } from '../utils/countries'
import './CountryMediaList.css'
interface MediaItem {
id: string
source_kind: string
source_item_id: number
title: string
year: number | null
media_type: 'movie' | 'show' | 'music'
}
interface CountryMediaListProps {
countryCode: string
onClose: () => void
}
export default function CountryMediaList({ countryCode, onClose }: CountryMediaListProps) {
const [mediaItems, setMediaItems] = useState<MediaItem[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
fetchMediaItems()
}, [countryCode])
const fetchMediaItems = async () => {
setLoading(true)
setError(null)
try {
const response = await fetch(`/api/collection/by-country?country_code=${countryCode}`)
if (!response.ok) {
throw new Error('Failed to fetch media items')
}
const data = await response.json()
setMediaItems(data.items || [])
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error')
} finally {
setLoading(false)
}
}
const getMediaTypeLabel = (type: string) => {
switch (type) {
case 'movie': return 'Movie'
case 'show': return 'TV Show'
case 'music': return 'Music'
default: return type
}
}
const getSourceLabel = (source: string) => {
switch (source) {
case 'radarr': return 'Radarr'
case 'sonarr': return 'Sonarr'
case 'lidarr': return 'Lidarr'
default: return source
}
}
return (
<div className="country-media-modal-overlay" onClick={onClose}>
<div className="country-media-modal" onClick={(e) => e.stopPropagation()}>
<div className="country-media-header">
<h2>{formatCountryDisplay(countryCode)}</h2>
<button className="close-button" onClick={onClose}>×</button>
</div>
<div className="country-media-content">
{loading && <div className="loading">Loading media items...</div>}
{error && <div className="error">Error: {error}</div>}
{!loading && !error && (
<>
<div className="media-count">
{mediaItems.length} {mediaItems.length === 1 ? 'item' : 'items'}
</div>
{mediaItems.length === 0 ? (
<div className="no-items">No media items found for this country.</div>
) : (
<div className="media-list">
{mediaItems.map((item) => (
<div key={item.id} className="media-item">
<div className="media-item-title">
<strong>{item.title}</strong>
{item.year && <span className="media-year"> ({item.year})</span>}
</div>
<div className="media-item-meta">
<span className="media-type">{getMediaTypeLabel(item.media_type)}</span>
<span className="media-source">{getSourceLabel(item.source_kind)}</span>
</div>
</div>
))}
</div>
)}
</>
)}
</div>
</div>
</div>
)
}

View File

@@ -107,27 +107,81 @@
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem;
padding: 0.75rem;
margin-bottom: 0.5rem;
background: white;
border-radius: 4px;
border: 1px solid #ddd;
gap: 0.5rem;
}
.watched-item button {
.watched-item-info {
flex: 1;
display: flex;
align-items: center;
gap: 0.25rem;
}
.watched-item-year {
color: #666;
font-weight: normal;
}
.watched-item-country {
color: #999;
font-size: 0.9rem;
}
.delete-button {
background: #e74c3c;
color: white;
border: none;
padding: 0.25rem 0.75rem;
padding: 0.4rem 0.8rem;
border-radius: 4px;
cursor: pointer;
font-size: 0.85rem;
white-space: nowrap;
}
.delete-button:hover {
background: #c0392b;
}
.delete-confirm {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.85rem;
}
.confirm-button {
background: #e74c3c;
color: white;
border: none;
padding: 0.3rem 0.6rem;
border-radius: 4px;
cursor: pointer;
font-size: 0.8rem;
}
.watched-item button:hover {
.confirm-button:hover {
background: #c0392b;
}
.cancel-button {
background: #95a5a6;
color: white;
border: none;
padding: 0.3rem 0.6rem;
border-radius: 4px;
cursor: pointer;
font-size: 0.8rem;
}
.cancel-button:hover {
background: #7f8c8d;
}
.loading {
display: flex;
align-items: center;

View File

@@ -0,0 +1,184 @@
.missing-locations-container {
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
}
.missing-locations-header {
margin-bottom: 2rem;
}
.missing-locations-header h1 {
margin: 0 0 0.5rem 0;
color: #333;
}
.subtitle {
color: #666;
margin: 0;
}
.missing-locations-filters {
display: flex;
gap: 1rem;
margin-bottom: 1.5rem;
padding: 1rem;
background: #f5f5f5;
border-radius: 4px;
}
.filter-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.filter-group label {
font-weight: 500;
font-size: 0.9rem;
color: #333;
}
.filter-group select {
padding: 0.5rem;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 0.9rem;
background: white;
}
.missing-locations-content {
background: white;
border-radius: 4px;
padding: 1.5rem;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.missing-locations-summary {
margin-bottom: 1rem;
color: #666;
font-size: 0.9rem;
}
.media-table {
overflow-x: auto;
}
.media-table table {
width: 100%;
border-collapse: collapse;
}
.media-table thead {
background: #f5f5f5;
}
.media-table th {
padding: 0.75rem;
text-align: left;
font-weight: 600;
color: #333;
border-bottom: 2px solid #ddd;
}
.media-table td {
padding: 0.75rem;
border-bottom: 1px solid #eee;
}
.title-cell {
font-weight: 500;
}
.type-badge,
.source-badge {
display: inline-block;
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.85rem;
font-weight: 500;
}
.type-badge.type-movie {
background: #e3f2fd;
color: #1976d2;
}
.type-badge.type-show {
background: #f3e5f5;
color: #7b1fa2;
}
.type-badge.type-music {
background: #fff3e0;
color: #e65100;
}
.source-badge.source-radarr {
background: #e8f5e9;
color: #2e7d32;
}
.source-badge.source-sonarr {
background: #e1f5fe;
color: #0277bd;
}
.source-badge.source-lidarr {
background: #fce4ec;
color: #c2185b;
}
.no-items {
text-align: center;
padding: 3rem 1rem;
color: #666;
}
.subtext {
margin-top: 0.5rem;
font-size: 0.9rem;
color: #999;
}
.pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 1rem;
margin-top: 1.5rem;
}
.page-button {
padding: 0.5rem 1rem;
border: 1px solid #ddd;
border-radius: 4px;
background: white;
cursor: pointer;
font-size: 0.9rem;
}
.page-button:hover:not(:disabled) {
background: #f5f5f5;
}
.page-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.page-info {
color: #666;
font-size: 0.9rem;
}
.loading,
.error {
text-align: center;
padding: 2rem;
}
.error {
color: #d32f2f;
}

View File

@@ -0,0 +1,212 @@
import { useEffect, useState } from 'react'
import './MissingLocations.css'
interface MediaItem {
id: string
source_kind: string
source_item_id: number
title: string
year: number | null
media_type: 'movie' | 'show' | 'music'
}
interface MissingLocationsResponse {
total: number
returned: number
offset: number
limit: number
items: MediaItem[]
}
export default function MissingLocations() {
const [mediaItems, setMediaItems] = useState<MediaItem[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [total, setTotal] = useState(0)
const [offset, setOffset] = useState(0)
const [limit] = useState(50)
const [sourceFilter, setSourceFilter] = useState<string>('')
const [typeFilter, setTypeFilter] = useState<string>('')
useEffect(() => {
fetchMissingLocations()
}, [offset, sourceFilter, typeFilter])
const fetchMissingLocations = async () => {
setLoading(true)
setError(null)
try {
const params = new URLSearchParams({
limit: limit.toString(),
offset: offset.toString(),
})
if (sourceFilter) params.append('source_kind', sourceFilter)
if (typeFilter) params.append('media_type', typeFilter)
const response = await fetch(`/api/collection/missing-locations?${params}`)
if (!response.ok) {
throw new Error('Failed to fetch missing locations')
}
const data: MissingLocationsResponse = await response.json()
setMediaItems(data.items || [])
setTotal(data.total || 0)
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error')
} finally {
setLoading(false)
}
}
const getMediaTypeLabel = (type: string) => {
switch (type) {
case 'movie': return 'Movie'
case 'show': return 'TV Show'
case 'music': return 'Music'
default: return type
}
}
const getSourceLabel = (source: string) => {
switch (source) {
case 'radarr': return 'Radarr'
case 'sonarr': return 'Sonarr'
case 'lidarr': return 'Lidarr'
default: return source
}
}
const handlePrevPage = () => {
if (offset > 0) {
setOffset(Math.max(0, offset - limit))
}
}
const handleNextPage = () => {
if (offset + limit < total) {
setOffset(offset + limit)
}
}
return (
<div className="missing-locations-container">
<div className="missing-locations-header">
<h1>Media Without Locations</h1>
<p className="subtitle">
Media items in your collection that don't have country information assigned.
</p>
</div>
<div className="missing-locations-filters">
<div className="filter-group">
<label htmlFor="source-filter">Source:</label>
<select
id="source-filter"
value={sourceFilter}
onChange={(e) => {
setSourceFilter(e.target.value)
setOffset(0)
}}
>
<option value="">All</option>
<option value="radarr">Radarr</option>
<option value="sonarr">Sonarr</option>
<option value="lidarr">Lidarr</option>
</select>
</div>
<div className="filter-group">
<label htmlFor="type-filter">Type:</label>
<select
id="type-filter"
value={typeFilter}
onChange={(e) => {
setTypeFilter(e.target.value)
setOffset(0)
}}
>
<option value="">All</option>
<option value="movie">Movie</option>
<option value="show">TV Show</option>
<option value="music">Music</option>
</select>
</div>
</div>
<div className="missing-locations-content">
{loading && <div className="loading">Loading...</div>}
{error && <div className="error">Error: {error}</div>}
{!loading && !error && (
<>
<div className="missing-locations-summary">
Showing {mediaItems.length} of {total} items
</div>
{mediaItems.length === 0 ? (
<div className="no-items">
<p>No media items without locations found.</p>
<p className="subtext">All items in your collection have country information assigned!</p>
</div>
) : (
<>
<div className="media-table">
<table>
<thead>
<tr>
<th>Title</th>
<th>Year</th>
<th>Type</th>
<th>Source</th>
</tr>
</thead>
<tbody>
{mediaItems.map((item) => (
<tr key={item.id}>
<td className="title-cell">
<strong>{item.title}</strong>
</td>
<td>{item.year || '-'}</td>
<td>
<span className="type-badge type-{item.media_type}">
{getMediaTypeLabel(item.media_type)}
</span>
</td>
<td>
<span className="source-badge source-{item.source_kind}">
{getSourceLabel(item.source_kind)}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="pagination">
<button
onClick={handlePrevPage}
disabled={offset === 0}
className="page-button"
>
Previous
</button>
<span className="page-info">
Page {Math.floor(offset / limit) + 1} of {Math.ceil(total / limit) || 1}
</span>
<button
onClick={handleNextPage}
disabled={offset + limit >= total}
className="page-button"
>
Next
</button>
</div>
</>
)}
</>
)}
</div>
</div>
)
}

View File

@@ -3,6 +3,8 @@ import { MapContainer, TileLayer, GeoJSON, CircleMarker, Popup } from 'react-lea
import { LatLngExpression } from 'leaflet'
import 'leaflet/dist/leaflet.css'
import './Map.css'
import AutocompleteInput from './AutocompleteInput'
import { TMDBResult } from '../utils/tmdb'
interface WatchedItem {
id: string
@@ -41,6 +43,7 @@ export default function WatchedMap() {
country_code: '',
notes: '',
})
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null)
useEffect(() => {
fetch('https://raw.githubusercontent.com/holtzy/D3-graph-gallery/master/DATA/world.geojson')
@@ -123,11 +126,20 @@ export default function WatchedMap() {
try {
await fetch(`/api/watched/${id}`, { method: 'DELETE' })
fetchData()
setDeleteConfirmId(null)
} catch (error) {
console.error('Failed to delete watched item:', error)
}
}
const handleAutocompleteSelect = (result: TMDBResult) => {
setNewItem({
...newItem,
title: result.title,
year: result.year ? result.year.toString() : '',
})
}
const handleDeletePin = async (id: string) => {
try {
await fetch(`/api/pins/${id}`, { method: 'DELETE' })
@@ -189,12 +201,12 @@ export default function WatchedMap() {
<option value="movie">Movie</option>
<option value="show">Show</option>
</select>
<input
type="text"
placeholder="Title"
<AutocompleteInput
value={newItem.title}
onChange={(e) => setNewItem({ ...newItem, title: e.target.value })}
required
onChange={(value) => setNewItem({ ...newItem, title: value })}
onSelect={handleAutocompleteSelect}
type={newItem.media_type}
placeholder="Title (start typing to search)"
/>
<input
type="number"
@@ -225,8 +237,35 @@ export default function WatchedMap() {
<h3>Watched Items</h3>
{watchedItems.filter(item => item.watched_at).map(item => (
<div key={item.id} className="watched-item">
<strong>{item.title}</strong> ({item.country_code})
<button onClick={() => handleDeleteWatched(item.id)}>Delete</button>
<div className="watched-item-info">
<strong>{item.title}</strong>
{item.year && <span className="watched-item-year"> ({item.year})</span>}
<span className="watched-item-country"> {item.country_code}</span>
</div>
{deleteConfirmId === item.id ? (
<div className="delete-confirm">
<span>Delete?</span>
<button
className="confirm-button"
onClick={() => handleDeleteWatched(item.id)}
>
Yes
</button>
<button
className="cancel-button"
onClick={() => setDeleteConfirmId(null)}
>
No
</button>
</div>
) : (
<button
className="delete-button"
onClick={() => setDeleteConfirmId(item.id)}
>
Delete
</button>
)}
</div>
))}
</div>

View File

@@ -0,0 +1,70 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import CollectionMap from '../CollectionMap'
// Mock react-leaflet
vi.mock('react-leaflet', () => ({
MapContainer: ({ children }: { children: React.ReactNode }) => <div data-testid="map-container">{children}</div>,
TileLayer: () => <div data-testid="tile-layer" />,
GeoJSON: () => <div data-testid="geojson" />,
CircleMarker: () => null,
Popup: () => null,
}))
// Mock leaflet CSS
vi.mock('leaflet/dist/leaflet.css', () => ({}))
vi.mock('../Map.css', () => ({}))
// Mock fetch
global.fetch = vi.fn()
describe('CollectionMap', () => {
beforeEach(() => {
vi.clearAllMocks()
// Mock GeoJSON fetch
;(global.fetch as any).mockResolvedValueOnce({
ok: true,
json: async () => ({
type: 'FeatureCollection',
features: []
})
})
// Mock collection summary fetch
;(global.fetch as any).mockResolvedValueOnce({
ok: true,
json: async () => ({})
})
})
it('renders map container', async () => {
render(<CollectionMap />)
await waitFor(() => {
expect(screen.getByTestId('map-container')).toBeInTheDocument()
})
})
it('displays filters', async () => {
render(<CollectionMap />)
await waitFor(() => {
expect(screen.getByText('Collection Map')).toBeInTheDocument()
expect(screen.getByLabelText('Movies')).toBeInTheDocument()
expect(screen.getByLabelText('Shows')).toBeInTheDocument()
expect(screen.getByLabelText('Music')).toBeInTheDocument()
})
})
it('fetches collection data on mount', async () => {
render(<CollectionMap />)
await waitFor(() => {
expect(global.fetch).toHaveBeenCalledWith(
expect.stringContaining('/api/collection/summary')
)
})
})
})

View File

@@ -0,0 +1,68 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import WatchedMap from '../WatchedMap'
// Mock react-leaflet
vi.mock('react-leaflet', () => ({
MapContainer: ({ children }: { children: React.ReactNode }) => <div data-testid="map-container">{children}</div>,
TileLayer: () => <div data-testid="tile-layer" />,
GeoJSON: () => <div data-testid="geojson" />,
CircleMarker: () => null,
Popup: () => null,
}))
// Mock leaflet CSS
vi.mock('leaflet/dist/leaflet.css', () => ({}))
vi.mock('../Map.css', () => ({}))
// Mock fetch
global.fetch = vi.fn()
describe('WatchedMap', () => {
beforeEach(() => {
vi.clearAllMocks()
// Mock GeoJSON fetch
;(global.fetch as any).mockResolvedValueOnce({
ok: true,
json: async () => ({
type: 'FeatureCollection',
features: []
})
})
// Mock API fetches
;(global.fetch as any)
.mockResolvedValueOnce({ ok: true, json: async () => [] }) // watched
.mockResolvedValueOnce({ ok: true, json: async () => [] }) // pins
.mockResolvedValueOnce({ ok: true, json: async () => ({}) }) // summary
})
it('renders map container', async () => {
render(<WatchedMap />)
await waitFor(() => {
expect(screen.getByTestId('map-container')).toBeInTheDocument()
})
})
it('displays add watched item button', async () => {
render(<WatchedMap />)
await waitFor(() => {
expect(screen.getByText('Watched Map')).toBeInTheDocument()
expect(screen.getByText(/Add Watched Item/i)).toBeInTheDocument()
})
})
it('fetches watched data on mount', async () => {
render(<WatchedMap />)
await waitFor(() => {
expect(global.fetch).toHaveBeenCalledWith('/api/watched')
expect(global.fetch).toHaveBeenCalledWith('/api/pins')
expect(global.fetch).toHaveBeenCalledWith('/api/watched/summary')
})
})
})

View File

@@ -0,0 +1,9 @@
import '@testing-library/jest-dom'
import { expect, afterEach } from 'vitest'
import { cleanup } from '@testing-library/react'
// Cleanup after each test
afterEach(() => {
cleanup()
})

View File

@@ -0,0 +1,167 @@
/**
* Country code to name and flag emoji mappings
* Based on ISO 3166-1 alpha-2 codes
*/
export interface CountryInfo {
name: string
flag: string
}
// Country code to country name mapping
const countryNames: Record<string, string> = {
'US': 'United States',
'GB': 'United Kingdom',
'CA': 'Canada',
'AU': 'Australia',
'NZ': 'New Zealand',
'IE': 'Ireland',
'FR': 'France',
'DE': 'Germany',
'IT': 'Italy',
'ES': 'Spain',
'PT': 'Portugal',
'NL': 'Netherlands',
'BE': 'Belgium',
'CH': 'Switzerland',
'AT': 'Austria',
'SE': 'Sweden',
'NO': 'Norway',
'DK': 'Denmark',
'FI': 'Finland',
'PL': 'Poland',
'CZ': 'Czech Republic',
'GR': 'Greece',
'RU': 'Russia',
'JP': 'Japan',
'CN': 'China',
'KR': 'South Korea',
'IN': 'India',
'TH': 'Thailand',
'VN': 'Vietnam',
'PH': 'Philippines',
'ID': 'Indonesia',
'MY': 'Malaysia',
'SG': 'Singapore',
'MX': 'Mexico',
'BR': 'Brazil',
'AR': 'Argentina',
'CL': 'Chile',
'CO': 'Colombia',
'PE': 'Peru',
'ZA': 'South Africa',
'EG': 'Egypt',
'NG': 'Nigeria',
'KE': 'Kenya',
'IL': 'Israel',
'TR': 'Turkey',
'SA': 'Saudi Arabia',
'AE': 'United Arab Emirates',
'IR': 'Iran',
'PK': 'Pakistan',
'BD': 'Bangladesh',
'LK': 'Sri Lanka',
'NP': 'Nepal',
'MM': 'Myanmar',
'KH': 'Cambodia',
'LA': 'Laos',
'TW': 'Taiwan',
'HK': 'Hong Kong',
'MO': 'Macau',
}
// Country code to flag emoji mapping
const countryFlags: Record<string, string> = {
'US': '🇺🇸',
'GB': '🇬🇧',
'CA': '🇨🇦',
'AU': '🇦🇺',
'NZ': '🇳🇿',
'IE': '🇮🇪',
'FR': '🇫🇷',
'DE': '🇩🇪',
'IT': '🇮🇹',
'ES': '🇪🇸',
'PT': '🇵🇹',
'NL': '🇳🇱',
'BE': '🇧🇪',
'CH': '🇨🇭',
'AT': '🇦🇹',
'SE': '🇸🇪',
'NO': '🇳🇴',
'DK': '🇩🇰',
'FI': '🇫🇮',
'PL': '🇵🇱',
'CZ': '🇨🇿',
'GR': '🇬🇷',
'RU': '🇷🇺',
'JP': '🇯🇵',
'CN': '🇨🇳',
'KR': '🇰🇷',
'IN': '🇮🇳',
'TH': '🇹🇭',
'VN': '🇻🇳',
'PH': '🇵🇭',
'ID': '🇮🇩',
'MY': '🇲🇾',
'SG': '🇸🇬',
'MX': '🇲🇽',
'BR': '🇧🇷',
'AR': '🇦🇷',
'CL': '🇨🇱',
'CO': '🇨🇴',
'PE': '🇵🇪',
'ZA': '🇿🇦',
'EG': '🇪🇬',
'NG': '🇳🇬',
'KE': '🇰🇪',
'IL': '🇮🇱',
'TR': '🇹🇷',
'SA': '🇸🇦',
'AE': '🇦🇪',
'IR': '🇮🇷',
'PK': '🇵🇰',
'BD': '🇧🇩',
'LK': '🇱🇰',
'NP': '🇳🇵',
'MM': '🇲🇲',
'KH': '🇰🇭',
'LA': '🇱🇦',
'TW': '🇹🇼',
'HK': '🇭🇰',
'MO': '🇲🇴',
}
/**
* Get country name from country code
*/
export function getCountryName(countryCode: string): string {
return countryNames[countryCode.toUpperCase()] || countryCode.toUpperCase()
}
/**
* Get flag emoji from country code
*/
export function getCountryFlag(countryCode: string): string {
return countryFlags[countryCode.toUpperCase()] || '🏳️'
}
/**
* Get full country info (name and flag)
*/
export function getCountryInfo(countryCode: string): CountryInfo {
const code = countryCode.toUpperCase()
return {
name: countryNames[code] || code,
flag: countryFlags[code] || '🏳️',
}
}
/**
* Format country display string with flag and name
*/
export function formatCountryDisplay(countryCode: string): string {
const info = getCountryInfo(countryCode)
return `${info.flag} ${info.name}`
}

View File

@@ -0,0 +1,43 @@
/**
* TMDB API client utilities
*/
export interface TMDBResult {
id: number
title: string
year: number | null
type: 'movie' | 'tv'
overview?: string
poster_path?: string
}
export interface TMDBSearchResponse {
query: string
type: 'movie' | 'tv'
results: TMDBResult[]
}
/**
* Search TMDB for movies or TV shows
*/
export async function searchTMDB(
query: string,
type: 'movie' | 'tv' = 'movie'
): Promise<TMDBResult[]> {
if (!query.trim()) {
return []
}
try {
const response = await fetch(`/api/tmdb/search?query=${encodeURIComponent(query)}&type=${type}`)
if (!response.ok) {
throw new Error(`TMDB search failed: ${response.statusText}`)
}
const data: TMDBSearchResponse = await response.json()
return data.results || []
} catch (error) {
console.error('TMDB search error:', error)
return []
}
}

View File

@@ -25,4 +25,9 @@ export default defineConfig({
},
},
},
test: {
globals: true,
environment: 'jsdom',
setupFiles: './src/test/setup.ts',
},
})