init
This commit is contained in:
15
frontend/index.html
Normal file
15
frontend/index.html
Normal file
@@ -0,0 +1,15 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Movie Map</title>
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
26
frontend/package.json
Normal file
26
frontend/package.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "moviemap-frontend",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^6.20.0",
|
||||
"leaflet": "^1.9.4",
|
||||
"react-leaflet": "^4.2.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.43",
|
||||
"@types/react-dom": "^18.2.17",
|
||||
"@types/leaflet": "^1.9.8",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"typescript": "^5.3.3",
|
||||
"vite": "^5.0.8"
|
||||
}
|
||||
}
|
||||
|
||||
50
frontend/src/App.css
Normal file
50
frontend/src/App.css
Normal file
@@ -0,0 +1,50 @@
|
||||
.app {
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.navbar {
|
||||
background: #1a1a1a;
|
||||
color: white;
|
||||
padding: 1rem 2rem;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.nav-container {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.nav-title {
|
||||
font-size: 1.5rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.nav-links {
|
||||
display: flex;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.nav-links a {
|
||||
color: #ccc;
|
||||
text-decoration: none;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 4px;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.nav-links a:hover {
|
||||
background: #333;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.nav-links a.active {
|
||||
background: #4a90e2;
|
||||
color: white;
|
||||
}
|
||||
|
||||
48
frontend/src/App.tsx
Normal file
48
frontend/src/App.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
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 './App.css'
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<Router>
|
||||
<div className="app">
|
||||
<NavBar />
|
||||
<Routes>
|
||||
<Route path="/" element={<CollectionMap />} />
|
||||
<Route path="/watched" element={<WatchedMap />} />
|
||||
</Routes>
|
||||
</div>
|
||||
</Router>
|
||||
)
|
||||
}
|
||||
|
||||
function NavBar() {
|
||||
const location = useLocation()
|
||||
|
||||
return (
|
||||
<nav className="navbar">
|
||||
<div className="nav-container">
|
||||
<h1 className="nav-title">Movie Map</h1>
|
||||
<div className="nav-links">
|
||||
<Link
|
||||
to="/"
|
||||
className={location.pathname === '/' ? 'active' : ''}
|
||||
>
|
||||
Collection Map
|
||||
</Link>
|
||||
<Link
|
||||
to="/watched"
|
||||
className={location.pathname === '/watched' ? 'active' : ''}
|
||||
>
|
||||
Watched Map
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
|
||||
217
frontend/src/components/CollectionMap.tsx
Normal file
217
frontend/src/components/CollectionMap.tsx
Normal file
@@ -0,0 +1,217 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { MapContainer, TileLayer, GeoJSON, CircleMarker, Popup } from 'react-leaflet'
|
||||
import { LatLngExpression } from 'leaflet'
|
||||
import 'leaflet/dist/leaflet.css'
|
||||
import './Map.css'
|
||||
|
||||
interface CountryData {
|
||||
[countryCode: string]: {
|
||||
movie?: number
|
||||
show?: number
|
||||
music?: number
|
||||
}
|
||||
}
|
||||
|
||||
interface MediaTypeFilter {
|
||||
movie: boolean
|
||||
show: boolean
|
||||
music: boolean
|
||||
}
|
||||
|
||||
export default function CollectionMap() {
|
||||
const [countryData, setCountryData] = useState<CountryData>({})
|
||||
const [filters, setFilters] = useState<MediaTypeFilter>({
|
||||
movie: true,
|
||||
show: true,
|
||||
music: true,
|
||||
})
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [worldGeoJson, setWorldGeoJson] = useState<any>(null)
|
||||
|
||||
useEffect(() => {
|
||||
// Load world countries GeoJSON
|
||||
fetch('https://raw.githubusercontent.com/holtzy/D3-graph-gallery/master/DATA/world.geojson')
|
||||
.then(res => res.json())
|
||||
.then(data => setWorldGeoJson(data))
|
||||
.catch(err => console.error('Failed to load GeoJSON:', err))
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchCollectionData()
|
||||
}, [filters])
|
||||
|
||||
const fetchCollectionData = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const types = Object.entries(filters)
|
||||
.filter(([_, enabled]) => enabled)
|
||||
.map(([type, _]) => type)
|
||||
.join(',')
|
||||
|
||||
const response = await fetch(`/api/collection/summary?types=${types}`)
|
||||
const data = await response.json()
|
||||
setCountryData(data)
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch collection data:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const getCountryCount = (countryCode: string): number => {
|
||||
const data = countryData[countryCode] || {}
|
||||
let total = 0
|
||||
if (filters.movie) total += data.movie || 0
|
||||
if (filters.show) total += data.show || 0
|
||||
if (filters.music) total += data.music || 0
|
||||
return total
|
||||
}
|
||||
|
||||
const getMaxCount = (): number => {
|
||||
const counts = Object.keys(countryData).map(code => getCountryCount(code))
|
||||
return Math.max(...counts, 1)
|
||||
}
|
||||
|
||||
const getCountryColor = (countryCode: string): string => {
|
||||
const count = getCountryCount(countryCode)
|
||||
const maxCount = getMaxCount()
|
||||
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))
|
||||
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 toggleFilter = (type: keyof MediaTypeFilter) => {
|
||||
setFilters(prev => ({ ...prev, [type]: !prev[type] }))
|
||||
}
|
||||
|
||||
if (loading && !worldGeoJson) {
|
||||
return <div className="loading">Loading map...</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="map-container">
|
||||
<div className="map-controls">
|
||||
<h2>Collection Map</h2>
|
||||
<div className="filters">
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={filters.movie}
|
||||
onChange={() => toggleFilter('movie')}
|
||||
/>
|
||||
Movies
|
||||
</label>
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={filters.show}
|
||||
onChange={() => toggleFilter('show')}
|
||||
/>
|
||||
Shows
|
||||
</label>
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={filters.music}
|
||||
onChange={() => toggleFilter('music')}
|
||||
/>
|
||||
Music
|
||||
</label>
|
||||
</div>
|
||||
<button onClick={fetchCollectionData} className="sync-button">
|
||||
Refresh Data
|
||||
</button>
|
||||
</div>
|
||||
<MapContainer
|
||||
center={[20, 0]}
|
||||
zoom={2}
|
||||
style={{ height: '100%', width: '100%' }}
|
||||
>
|
||||
<TileLayer
|
||||
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
/>
|
||||
{worldGeoJson && (
|
||||
<GeoJSON
|
||||
data={worldGeoJson}
|
||||
style={(feature) => {
|
||||
const code = feature?.properties?.ISO_A2 || feature?.properties?.ISO_A3?.substring(0, 2)
|
||||
return {
|
||||
fillColor: getCountryColor(code),
|
||||
fillOpacity: 0.7,
|
||||
color: '#666',
|
||||
weight: 1,
|
||||
}
|
||||
}}
|
||||
onEachFeature={(feature, layer) => {
|
||||
const code = feature?.properties?.ISO_A2 || feature?.properties?.ISO_A3?.substring(0, 2)
|
||||
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}` : ''}
|
||||
`)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{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>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
139
frontend/src/components/Map.css
Normal file
139
frontend/src/components/Map.css
Normal file
@@ -0,0 +1,139 @@
|
||||
.map-container {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.map-controls {
|
||||
width: 300px;
|
||||
background: #f5f5f5;
|
||||
padding: 1.5rem;
|
||||
overflow-y: auto;
|
||||
border-right: 1px solid #ddd;
|
||||
}
|
||||
|
||||
.map-controls h2 {
|
||||
margin-bottom: 1rem;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.filters {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.filters label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.sync-button,
|
||||
.add-button {
|
||||
background: #4a90e2;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.sync-button:hover,
|
||||
.add-button:hover {
|
||||
background: #357abd;
|
||||
}
|
||||
|
||||
.add-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
padding: 1rem;
|
||||
background: white;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #ddd;
|
||||
}
|
||||
|
||||
.add-form input,
|
||||
.add-form select,
|
||||
.add-form textarea {
|
||||
padding: 0.5rem;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.add-form textarea {
|
||||
min-height: 60px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.form-actions button {
|
||||
flex: 1;
|
||||
padding: 0.5rem;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
background: #4a90e2;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.form-actions button:hover {
|
||||
background: #357abd;
|
||||
}
|
||||
|
||||
.watched-list {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.watched-list h3 {
|
||||
margin-bottom: 0.5rem;
|
||||
color: #333;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.watched-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
background: white;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #ddd;
|
||||
}
|
||||
|
||||
.watched-item button {
|
||||
background: #e74c3c;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.watched-item button:hover {
|
||||
background: #c0392b;
|
||||
}
|
||||
|
||||
.loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
font-size: 1.2rem;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
280
frontend/src/components/WatchedMap.tsx
Normal file
280
frontend/src/components/WatchedMap.tsx
Normal file
@@ -0,0 +1,280 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { MapContainer, TileLayer, GeoJSON, CircleMarker, Popup } from 'react-leaflet'
|
||||
import { LatLngExpression } from 'leaflet'
|
||||
import 'leaflet/dist/leaflet.css'
|
||||
import './Map.css'
|
||||
|
||||
interface WatchedItem {
|
||||
id: string
|
||||
media_type: 'movie' | 'show'
|
||||
title: string
|
||||
year?: number
|
||||
country_code: string
|
||||
watched_at?: string
|
||||
notes?: string
|
||||
}
|
||||
|
||||
interface Pin {
|
||||
id: string
|
||||
country_code: string
|
||||
label?: string
|
||||
pinned_at: string
|
||||
}
|
||||
|
||||
interface CountrySummary {
|
||||
[countryCode: string]: {
|
||||
movie?: number
|
||||
show?: number
|
||||
}
|
||||
}
|
||||
|
||||
export default function WatchedMap() {
|
||||
const [watchedItems, setWatchedItems] = useState<WatchedItem[]>([])
|
||||
const [pins, setPins] = useState<Pin[]>([])
|
||||
const [summary, setSummary] = useState<CountrySummary>({})
|
||||
const [worldGeoJson, setWorldGeoJson] = useState<any>(null)
|
||||
const [showAddForm, setShowAddForm] = useState(false)
|
||||
const [newItem, setNewItem] = useState({
|
||||
media_type: 'movie' as 'movie' | 'show',
|
||||
title: '',
|
||||
year: '',
|
||||
country_code: '',
|
||||
notes: '',
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
fetch('https://raw.githubusercontent.com/holtzy/D3-graph-gallery/master/DATA/world.geojson')
|
||||
.then(res => res.json())
|
||||
.then(data => setWorldGeoJson(data))
|
||||
.catch(err => console.error('Failed to load GeoJSON:', err))
|
||||
|
||||
fetchData()
|
||||
}, [])
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const [watchedRes, pinsRes, summaryRes] = await Promise.all([
|
||||
fetch('/api/watched'),
|
||||
fetch('/api/pins'),
|
||||
fetch('/api/watched/summary'),
|
||||
])
|
||||
|
||||
setWatchedItems(await watchedRes.json())
|
||||
setPins(await pinsRes.json())
|
||||
setSummary(await summaryRes.json())
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch data:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleAddWatched = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
try {
|
||||
await fetch('/api/watched', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
...newItem,
|
||||
year: newItem.year ? parseInt(newItem.year) : null,
|
||||
watched_at: new Date().toISOString(),
|
||||
}),
|
||||
})
|
||||
setShowAddForm(false)
|
||||
setNewItem({ media_type: 'movie', title: '', year: '', country_code: '', notes: '' })
|
||||
fetchData()
|
||||
} catch (error) {
|
||||
console.error('Failed to add watched item:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleAddPin = async () => {
|
||||
if (!newItem.country_code) return
|
||||
try {
|
||||
await fetch('/api/pins', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
country_code: newItem.country_code,
|
||||
label: newItem.title || undefined,
|
||||
}),
|
||||
})
|
||||
setNewItem({ media_type: 'movie', title: '', year: '', country_code: '', notes: '' })
|
||||
fetchData()
|
||||
} catch (error) {
|
||||
console.error('Failed to add pin:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteWatched = async (id: string) => {
|
||||
try {
|
||||
await fetch(`/api/watched/${id}`, { method: 'DELETE' })
|
||||
fetchData()
|
||||
} catch (error) {
|
||||
console.error('Failed to delete watched item:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeletePin = async (id: string) => {
|
||||
try {
|
||||
await fetch(`/api/pins/${id}`, { method: 'DELETE' })
|
||||
fetchData()
|
||||
} catch (error) {
|
||||
console.error('Failed to delete pin:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const getCountryCount = (countryCode: string): number => {
|
||||
const data = summary[countryCode] || {}
|
||||
return (data.movie || 0) + (data.show || 0)
|
||||
}
|
||||
|
||||
const getMaxCount = (): number => {
|
||||
const counts = Object.keys(summary).map(code => getCountryCount(code))
|
||||
return Math.max(...counts, 1)
|
||||
}
|
||||
|
||||
const getCountryColor = (countryCode: string): string => {
|
||||
const count = getCountryCount(countryCode)
|
||||
const maxCount = getMaxCount()
|
||||
if (count === 0) return '#e0e0e0'
|
||||
|
||||
const intensity = count / maxCount
|
||||
const r = Math.floor(255 - (255 - 100) * intensity)
|
||||
const g = Math.floor(200 - (200 - 50) * intensity)
|
||||
const b = Math.floor(100 - (100 - 20) * intensity)
|
||||
return `rgb(${r}, ${g}, ${b})`
|
||||
}
|
||||
|
||||
const getCountryCenter = (countryCode: string): LatLngExpression | null => {
|
||||
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
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="map-container">
|
||||
<div className="map-controls">
|
||||
<h2>Watched Map</h2>
|
||||
<button onClick={() => setShowAddForm(!showAddForm)} className="add-button">
|
||||
{showAddForm ? 'Cancel' : 'Add Watched Item'}
|
||||
</button>
|
||||
{showAddForm && (
|
||||
<form onSubmit={handleAddWatched} className="add-form">
|
||||
<select
|
||||
value={newItem.media_type}
|
||||
onChange={(e) => setNewItem({ ...newItem, media_type: e.target.value as 'movie' | 'show' })}
|
||||
>
|
||||
<option value="movie">Movie</option>
|
||||
<option value="show">Show</option>
|
||||
</select>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Title"
|
||||
value={newItem.title}
|
||||
onChange={(e) => setNewItem({ ...newItem, title: e.target.value })}
|
||||
required
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
placeholder="Year (optional)"
|
||||
value={newItem.year}
|
||||
onChange={(e) => setNewItem({ ...newItem, year: e.target.value })}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Country Code (e.g., TH, JP)"
|
||||
value={newItem.country_code}
|
||||
onChange={(e) => setNewItem({ ...newItem, country_code: e.target.value.toUpperCase() })}
|
||||
required
|
||||
maxLength={2}
|
||||
/>
|
||||
<textarea
|
||||
placeholder="Notes (optional)"
|
||||
value={newItem.notes}
|
||||
onChange={(e) => setNewItem({ ...newItem, notes: e.target.value })}
|
||||
/>
|
||||
<div className="form-actions">
|
||||
<button type="submit">Add Watched</button>
|
||||
<button type="button" onClick={handleAddPin}>Add Pin Only</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
<div className="watched-list">
|
||||
<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>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<MapContainer
|
||||
center={[20, 0]}
|
||||
zoom={2}
|
||||
style={{ height: '100%', width: '100%' }}
|
||||
>
|
||||
<TileLayer
|
||||
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
/>
|
||||
{worldGeoJson && (
|
||||
<GeoJSON
|
||||
data={worldGeoJson}
|
||||
style={(feature) => {
|
||||
const code = feature?.properties?.ISO_A2 || feature?.properties?.ISO_A3?.substring(0, 2)
|
||||
return {
|
||||
fillColor: getCountryColor(code),
|
||||
fillOpacity: 0.7,
|
||||
color: '#666',
|
||||
weight: 1,
|
||||
}
|
||||
}}
|
||||
onEachFeature={(feature, layer) => {
|
||||
const code = feature?.properties?.ISO_A2 || feature?.properties?.ISO_A3?.substring(0, 2)
|
||||
const count = getCountryCount(code)
|
||||
const data = summary[code] || {}
|
||||
|
||||
layer.bindPopup(`
|
||||
<strong>${feature.properties.NAME || code}</strong><br/>
|
||||
Watched: ${count}<br/>
|
||||
${data.movie ? `Movies: ${data.movie}<br/>` : ''}
|
||||
${data.show ? `Shows: ${data.show}` : ''}
|
||||
`)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{pins.map(pin => {
|
||||
const center = getCountryCenter(pin.country_code)
|
||||
if (!center) return null
|
||||
|
||||
return (
|
||||
<CircleMarker
|
||||
key={pin.id}
|
||||
center={center}
|
||||
radius={10}
|
||||
fillColor="#ffd700"
|
||||
fillOpacity={0.8}
|
||||
color="#ff8c00"
|
||||
weight={2}
|
||||
>
|
||||
<Popup>
|
||||
<strong>{pin.country_code}</strong>
|
||||
{pin.label && <><br/>{pin.label}</>}
|
||||
<br/>
|
||||
<button onClick={() => handleDeletePin(pin.id)}>Delete</button>
|
||||
</Popup>
|
||||
</CircleMarker>
|
||||
)
|
||||
})}
|
||||
</MapContainer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
2
frontend/src/components/__init__.ts
Normal file
2
frontend/src/components/__init__.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
// Components exports
|
||||
|
||||
19
frontend/src/index.css
Normal file
19
frontend/src/index.css
Normal file
@@ -0,0 +1,19 @@
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
#root {
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
11
frontend/src/main.tsx
Normal file
11
frontend/src/main.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App'
|
||||
import './index.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
)
|
||||
|
||||
22
frontend/tsconfig.json
Normal file
22
frontend/tsconfig.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
|
||||
11
frontend/tsconfig.node.json
Normal file
11
frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
|
||||
22
frontend/vite.config.ts
Normal file
22
frontend/vite.config.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://127.0.0.1:8080',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/admin': {
|
||||
target: 'http://127.0.0.1:8080',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user