This commit is contained in:
Danilo Reyes
2025-12-28 20:59:09 -06:00
commit 96fcc2b9e8
35 changed files with 2603 additions and 0 deletions

15
frontend/index.html Normal file
View 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
View 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
View 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
View 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

View 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='&copy; <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>
)
}

View 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;
}

View 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='&copy; <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>
)
}

View File

@@ -0,0 +1,2 @@
// Components exports

19
frontend/src/index.css Normal file
View 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
View 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
View 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" }]
}

View 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
View 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,
},
},
},
})