Add new API endpoints for media retrieval by country and enhance configuration
Some checks failed
Test Suite / test (push) Has been cancelled
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:
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
79
frontend/src/components/AutocompleteInput.css
Normal file
79
frontend/src/components/AutocompleteInput.css
Normal 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;
|
||||
}
|
||||
|
||||
113
frontend/src/components/AutocompleteInput.tsx
Normal file
113
frontend/src/components/AutocompleteInput.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
130
frontend/src/components/CountryMediaList.css
Normal file
130
frontend/src/components/CountryMediaList.css
Normal 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;
|
||||
}
|
||||
|
||||
106
frontend/src/components/CountryMediaList.tsx
Normal file
106
frontend/src/components/CountryMediaList.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
184
frontend/src/components/MissingLocations.css
Normal file
184
frontend/src/components/MissingLocations.css
Normal 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;
|
||||
}
|
||||
|
||||
212
frontend/src/components/MissingLocations.tsx
Normal file
212
frontend/src/components/MissingLocations.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
70
frontend/src/components/__tests__/CollectionMap.test.tsx
Normal file
70
frontend/src/components/__tests__/CollectionMap.test.tsx
Normal 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')
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
68
frontend/src/components/__tests__/WatchedMap.test.tsx
Normal file
68
frontend/src/components/__tests__/WatchedMap.test.tsx
Normal 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')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
9
frontend/src/test/setup.ts
Normal file
9
frontend/src/test/setup.ts
Normal 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()
|
||||
})
|
||||
|
||||
167
frontend/src/utils/countries.ts
Normal file
167
frontend/src/utils/countries.ts
Normal 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}`
|
||||
}
|
||||
|
||||
43
frontend/src/utils/tmdb.ts
Normal file
43
frontend/src/utils/tmdb.ts
Normal 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 []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,4 +25,9 @@ export default defineConfig({
|
||||
},
|
||||
},
|
||||
},
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'jsdom',
|
||||
setupFiles: './src/test/setup.ts',
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user