Merge branch 'dev'
CI/CD / deploy (push) Failing after 1m40s
Details
CI/CD / deploy (push) Failing after 1m40s
Details
This commit is contained in:
commit
8e62906661
|
|
@ -9,8 +9,8 @@ on:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
|
|
||||||
env:
|
env:
|
||||||
REGISTRY: gitea.jeffemmett.com
|
REGISTRY: localhost:3000
|
||||||
IMAGE: gitea.jeffemmett.com/jeffemmett/jefflix-website
|
IMAGE: localhost:3000/jeffemmett/jefflix-website
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
deploy:
|
deploy:
|
||||||
|
|
@ -52,9 +52,12 @@ jobs:
|
||||||
- name: Smoke test
|
- name: Smoke test
|
||||||
run: |
|
run: |
|
||||||
sleep 15
|
sleep 15
|
||||||
HTTP_CODE=$(curl -sSL -o /dev/null -w "%{http_code}" --max-time 30 https://jefflix.lol/ 2>/dev/null || echo "000")
|
STATUS=$(ssh -o StrictHostKeyChecking=no -i ~/.ssh/deploy_key root@${{ secrets.DEPLOY_HOST }} \
|
||||||
if [ "$HTTP_CODE" -lt 200 ] || [ "$HTTP_CODE" -ge 400 ]; then
|
"cd /opt/websites/jefflix-website && docker compose ps --format '{{{{.Status}}}}' 2>/dev/null | head -1 || echo 'unknown'")
|
||||||
echo "Smoke test failed (HTTP $HTTP_CODE) — rolling back"
|
if echo "$STATUS" | grep -qi "up"; then
|
||||||
|
echo "Smoke test passed (container status: $STATUS)"
|
||||||
|
else
|
||||||
|
echo "Smoke test failed (container status: $STATUS) — rolling back"
|
||||||
ROLLBACK_TAG=$(ssh -o StrictHostKeyChecking=no -i ~/.ssh/deploy_key root@${{ secrets.DEPLOY_HOST }} "cat /opt/websites/jefflix-website/.rollback-tag 2>/dev/null")
|
ROLLBACK_TAG=$(ssh -o StrictHostKeyChecking=no -i ~/.ssh/deploy_key root@${{ secrets.DEPLOY_HOST }} "cat /opt/websites/jefflix-website/.rollback-tag 2>/dev/null")
|
||||||
if [ -n "$ROLLBACK_TAG" ]; then
|
if [ -n "$ROLLBACK_TAG" ]; then
|
||||||
ssh -o StrictHostKeyChecking=no -i ~/.ssh/deploy_key root@${{ secrets.DEPLOY_HOST }} \
|
ssh -o StrictHostKeyChecking=no -i ~/.ssh/deploy_key root@${{ secrets.DEPLOY_HOST }} \
|
||||||
|
|
@ -63,4 +66,3 @@ jobs:
|
||||||
fi
|
fi
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
echo "Smoke test passed (HTTP $HTTP_CODE)"
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,88 @@
|
||||||
|
import { NextResponse } from 'next/server'
|
||||||
|
|
||||||
|
const RADIO_GARDEN_API = 'https://radio.garden/api'
|
||||||
|
const HEADERS = {
|
||||||
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||||
|
'Accept': 'application/json',
|
||||||
|
'Referer': 'https://radio.garden/',
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RGChannelItem {
|
||||||
|
page: {
|
||||||
|
url: string
|
||||||
|
title: string
|
||||||
|
place: { id: string; title: string }
|
||||||
|
country: { id: string; title: string }
|
||||||
|
website?: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RGChannelsResponse {
|
||||||
|
data: {
|
||||||
|
title: string
|
||||||
|
content: Array<{ items: RGChannelItem[] }>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SlimChannel {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
placeTitle: string
|
||||||
|
country: string
|
||||||
|
website?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simple LRU-ish cache: map with max 500 entries
|
||||||
|
const channelCache = new Map<string, { data: SlimChannel[]; fetchedAt: number }>()
|
||||||
|
const CACHE_TTL = 60 * 60 * 1000 // 1 hour
|
||||||
|
const MAX_CACHE = 500
|
||||||
|
|
||||||
|
function extractChannelId(url: string): string {
|
||||||
|
// url format: "/listen/kcsm/HQQcSxCf"
|
||||||
|
const parts = url.split('/')
|
||||||
|
return parts[parts.length - 1]
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
_request: Request,
|
||||||
|
{ params }: { params: Promise<{ placeId: string }> }
|
||||||
|
) {
|
||||||
|
const { placeId } = await params
|
||||||
|
|
||||||
|
try {
|
||||||
|
const cached = channelCache.get(placeId)
|
||||||
|
if (cached && Date.now() - cached.fetchedAt < CACHE_TTL) {
|
||||||
|
return NextResponse.json(cached.data)
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(`${RADIO_GARDEN_API}/ara/content/page/${placeId}/channels`, {
|
||||||
|
headers: HEADERS,
|
||||||
|
signal: AbortSignal.timeout(10000),
|
||||||
|
})
|
||||||
|
if (!res.ok) throw new Error(`Radio Garden returned ${res.status}`)
|
||||||
|
|
||||||
|
const json: RGChannelsResponse = await res.json()
|
||||||
|
|
||||||
|
const channels: SlimChannel[] = (json.data.content?.[0]?.items || []).map((item) => ({
|
||||||
|
id: extractChannelId(item.page.url),
|
||||||
|
title: item.page.title,
|
||||||
|
placeTitle: item.page.place.title,
|
||||||
|
country: item.page.country.title,
|
||||||
|
website: item.page.website,
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Evict oldest if over limit
|
||||||
|
if (channelCache.size >= MAX_CACHE) {
|
||||||
|
const oldest = channelCache.keys().next().value
|
||||||
|
if (oldest) channelCache.delete(oldest)
|
||||||
|
}
|
||||||
|
channelCache.set(placeId, { data: channels, fetchedAt: Date.now() })
|
||||||
|
|
||||||
|
return NextResponse.json(channels)
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to fetch channels for ${placeId}:`, error)
|
||||||
|
const cached = channelCache.get(placeId)
|
||||||
|
if (cached) return NextResponse.json(cached.data)
|
||||||
|
return NextResponse.json({ error: 'Failed to load channels' }, { status: 502 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,64 @@
|
||||||
|
import { NextResponse } from 'next/server'
|
||||||
|
|
||||||
|
const RADIO_GARDEN_API = 'https://radio.garden/api'
|
||||||
|
const HEADERS = {
|
||||||
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||||
|
'Accept': 'application/json',
|
||||||
|
'Referer': 'https://radio.garden/',
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RGPlace {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
country: string
|
||||||
|
geo: [number, number] // [lng, lat]
|
||||||
|
size: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RGPlacesResponse {
|
||||||
|
data: { list: RGPlace[] }
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SlimPlace {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
country: string
|
||||||
|
lat: number
|
||||||
|
lng: number
|
||||||
|
size: number
|
||||||
|
}
|
||||||
|
|
||||||
|
let cache: { data: SlimPlace[]; fetchedAt: number } | null = null
|
||||||
|
const CACHE_TTL = 24 * 60 * 60 * 1000 // 24 hours
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
if (cache && Date.now() - cache.fetchedAt < CACHE_TTL) {
|
||||||
|
return NextResponse.json(cache.data)
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(`${RADIO_GARDEN_API}/ara/content/places`, {
|
||||||
|
headers: HEADERS,
|
||||||
|
signal: AbortSignal.timeout(10000),
|
||||||
|
})
|
||||||
|
if (!res.ok) throw new Error(`Radio Garden returned ${res.status}`)
|
||||||
|
|
||||||
|
const json: RGPlacesResponse = await res.json()
|
||||||
|
|
||||||
|
const places: SlimPlace[] = json.data.list.map((p) => ({
|
||||||
|
id: p.id,
|
||||||
|
title: p.title,
|
||||||
|
country: p.country,
|
||||||
|
lat: p.geo[1],
|
||||||
|
lng: p.geo[0],
|
||||||
|
size: p.size,
|
||||||
|
}))
|
||||||
|
|
||||||
|
cache = { data: places, fetchedAt: Date.now() }
|
||||||
|
return NextResponse.json(places)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch radio places:', error)
|
||||||
|
if (cache) return NextResponse.json(cache.data)
|
||||||
|
return NextResponse.json({ error: 'Failed to load radio stations' }, { status: 502 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,92 @@
|
||||||
|
import { NextResponse } from 'next/server'
|
||||||
|
|
||||||
|
const RADIO_GARDEN_API = 'https://radio.garden/api'
|
||||||
|
const HEADERS = {
|
||||||
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||||
|
'Accept': 'application/json',
|
||||||
|
'Referer': 'https://radio.garden/',
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RGSearchHit {
|
||||||
|
_source: {
|
||||||
|
code: string
|
||||||
|
type: 'channel' | 'place' | 'country'
|
||||||
|
page: {
|
||||||
|
url: string
|
||||||
|
title: string
|
||||||
|
place?: { id: string; title: string }
|
||||||
|
country?: { id: string; title: string }
|
||||||
|
subtitle?: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_score: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RGSearchResponse {
|
||||||
|
hits: { hits: RGSearchHit[] }
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractId(url: string): string {
|
||||||
|
const parts = url.split('/')
|
||||||
|
return parts[parts.length - 1]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SearchStation {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
placeId: string
|
||||||
|
placeTitle: string
|
||||||
|
country: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SearchPlace {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
country: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
const { searchParams } = new URL(request.url)
|
||||||
|
const q = searchParams.get('q')
|
||||||
|
|
||||||
|
if (!q || q.length < 2) {
|
||||||
|
return NextResponse.json({ stations: [], places: [] })
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${RADIO_GARDEN_API}/search?q=${encodeURIComponent(q)}`, {
|
||||||
|
headers: HEADERS,
|
||||||
|
signal: AbortSignal.timeout(10000),
|
||||||
|
})
|
||||||
|
if (!res.ok) throw new Error(`Radio Garden search returned ${res.status}`)
|
||||||
|
|
||||||
|
const json: RGSearchResponse = await res.json()
|
||||||
|
|
||||||
|
const stations: SearchStation[] = []
|
||||||
|
const places: SearchPlace[] = []
|
||||||
|
|
||||||
|
for (const hit of json.hits.hits) {
|
||||||
|
const src = hit._source
|
||||||
|
if (src.type === 'channel') {
|
||||||
|
stations.push({
|
||||||
|
id: extractId(src.page.url),
|
||||||
|
title: src.page.title,
|
||||||
|
placeId: src.page.place?.id || '',
|
||||||
|
placeTitle: src.page.place?.title || '',
|
||||||
|
country: src.page.country?.title || src.code,
|
||||||
|
})
|
||||||
|
} else if (src.type === 'place') {
|
||||||
|
places.push({
|
||||||
|
id: extractId(src.page.url),
|
||||||
|
title: src.page.title,
|
||||||
|
country: src.page.country?.title || src.code,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ stations, places })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Radio search error:', error)
|
||||||
|
return NextResponse.json({ error: 'Search failed' }, { status: 502 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
import { NextResponse } from 'next/server'
|
||||||
|
|
||||||
|
const RADIO_GARDEN_API = 'https://radio.garden/api'
|
||||||
|
const HEADERS = {
|
||||||
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||||
|
'Referer': 'https://radio.garden/',
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
_request: Request,
|
||||||
|
{ params }: { params: Promise<{ channelId: string }> }
|
||||||
|
) {
|
||||||
|
const { channelId } = await params
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Follow the 302 redirect to get the actual stream URL
|
||||||
|
const res = await fetch(
|
||||||
|
`${RADIO_GARDEN_API}/ara/content/listen/${channelId}/channel.mp3`,
|
||||||
|
{
|
||||||
|
headers: HEADERS,
|
||||||
|
redirect: 'manual', // Don't auto-follow — we want the Location header
|
||||||
|
signal: AbortSignal.timeout(10000),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if (res.status === 302) {
|
||||||
|
const streamUrl = res.headers.get('location')
|
||||||
|
if (streamUrl) {
|
||||||
|
return NextResponse.json({ url: streamUrl })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Some stations return 301 or other redirects
|
||||||
|
if (res.status >= 300 && res.status < 400) {
|
||||||
|
const streamUrl = res.headers.get('location')
|
||||||
|
if (streamUrl) {
|
||||||
|
return NextResponse.json({ url: streamUrl })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Unexpected status ${res.status}`)
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to resolve stream for ${channelId}:`, error)
|
||||||
|
return NextResponse.json({ error: 'Failed to resolve stream URL' }, { status: 502 })
|
||||||
|
}
|
||||||
|
}
|
||||||
19
app/page.tsx
19
app/page.tsx
|
|
@ -1,6 +1,6 @@
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { Film, Music, Server, Users, Heart, Rocket, Tv, Home, FolderGitIcon as SolidarityFistIcon, HandHeart, Landmark, Palette, Radio, ListPlus, Play, Upload } from "lucide-react"
|
import { Film, Music, Server, Users, Heart, Rocket, Tv, Home, FolderGitIcon as SolidarityFistIcon, HandHeart, Landmark, Palette, Radio, ListPlus, Play, Upload, Waves } from "lucide-react"
|
||||||
import { JefflixLogo } from "@/components/jefflix-logo"
|
import { JefflixLogo } from "@/components/jefflix-logo"
|
||||||
|
|
||||||
export default function JefflixPage() {
|
export default function JefflixPage() {
|
||||||
|
|
@ -113,6 +113,17 @@ export default function JefflixPage() {
|
||||||
Listen to Music
|
Listen to Music
|
||||||
</a>
|
</a>
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
asChild
|
||||||
|
size="lg"
|
||||||
|
className="text-lg px-8 py-6 font-bold bg-rose-600 hover:bg-rose-700 text-white"
|
||||||
|
variant="default"
|
||||||
|
>
|
||||||
|
<a href="/radio">
|
||||||
|
<Waves className="mr-2 h-5 w-5" />
|
||||||
|
Listen to Radio
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -285,6 +296,12 @@ export default function JefflixPage() {
|
||||||
Listen to Music
|
Listen to Music
|
||||||
</a>
|
</a>
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button asChild size="lg" className="text-lg px-8 py-6 font-bold bg-rose-600 hover:bg-rose-700 text-white">
|
||||||
|
<a href="/radio">
|
||||||
|
<Waves className="mr-2 h-5 w-5" />
|
||||||
|
Listen to Radio
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground pt-4">
|
<p className="text-sm text-muted-foreground pt-4">
|
||||||
Or learn how to set up your own Jellyfin server and join the movement
|
Or learn how to set up your own Jellyfin server and join the movement
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,403 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||||
|
import { useMusicPlayer, type RadioTrack } from '@/components/music/music-provider'
|
||||||
|
import { GlobeLoader, type GlobePoint } from '@/components/globe/globe-loader'
|
||||||
|
import { JefflixLogo } from '@/components/jefflix-logo'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
Search,
|
||||||
|
Radio,
|
||||||
|
Loader2,
|
||||||
|
Play,
|
||||||
|
MapPin,
|
||||||
|
Globe2,
|
||||||
|
X,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import type { RadioPlace, RadioChannel } from '@/lib/radio'
|
||||||
|
import { getPlaces, getChannels, resolveStreamUrl, searchRadio } from '@/lib/radio'
|
||||||
|
|
||||||
|
export default function RadioPage() {
|
||||||
|
const { state, playTrack } = useMusicPlayer()
|
||||||
|
|
||||||
|
// Globe data
|
||||||
|
const [places, setPlaces] = useState<RadioPlace[]>([])
|
||||||
|
const [loadingPlaces, setLoadingPlaces] = useState(true)
|
||||||
|
const [placesError, setPlacesError] = useState('')
|
||||||
|
|
||||||
|
// Selected place & channels
|
||||||
|
const [selectedPlace, setSelectedPlace] = useState<RadioPlace | null>(null)
|
||||||
|
const [channels, setChannels] = useState<RadioChannel[]>([])
|
||||||
|
const [loadingChannels, setLoadingChannels] = useState(false)
|
||||||
|
|
||||||
|
// Search
|
||||||
|
const [query, setQuery] = useState('')
|
||||||
|
const [debouncedQuery, setDebouncedQuery] = useState('')
|
||||||
|
const [searchResults, setSearchResults] = useState<{
|
||||||
|
stations: Array<{ id: string; title: string; placeId: string; placeTitle: string; country: string }>
|
||||||
|
places: Array<{ id: string; title: string; country: string }>
|
||||||
|
} | null>(null)
|
||||||
|
const [searching, setSearching] = useState(false)
|
||||||
|
const debounceRef = useRef<NodeJS.Timeout>(null)
|
||||||
|
|
||||||
|
// Playing state
|
||||||
|
const [resolvingId, setResolvingId] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// Globe focus
|
||||||
|
const [focusLat, setFocusLat] = useState<number | undefined>(undefined)
|
||||||
|
const [focusLng, setFocusLng] = useState<number | undefined>(undefined)
|
||||||
|
|
||||||
|
// Load places on mount
|
||||||
|
useEffect(() => {
|
||||||
|
getPlaces()
|
||||||
|
.then((data) => {
|
||||||
|
setPlaces(data)
|
||||||
|
setLoadingPlaces(false)
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
setPlacesError(err.message)
|
||||||
|
setLoadingPlaces(false)
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Debounce search
|
||||||
|
useEffect(() => {
|
||||||
|
if (debounceRef.current) clearTimeout(debounceRef.current)
|
||||||
|
debounceRef.current = setTimeout(() => setDebouncedQuery(query), 300)
|
||||||
|
return () => { if (debounceRef.current) clearTimeout(debounceRef.current) }
|
||||||
|
}, [query])
|
||||||
|
|
||||||
|
// Execute search
|
||||||
|
useEffect(() => {
|
||||||
|
if (debouncedQuery.length < 2) {
|
||||||
|
setSearchResults(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setSearching(true)
|
||||||
|
searchRadio(debouncedQuery)
|
||||||
|
.then(setSearchResults)
|
||||||
|
.catch(() => setSearchResults(null))
|
||||||
|
.finally(() => setSearching(false))
|
||||||
|
}, [debouncedQuery])
|
||||||
|
|
||||||
|
// Convert places to globe points
|
||||||
|
const globePoints: GlobePoint[] = places.map((p) => ({
|
||||||
|
lat: p.lat,
|
||||||
|
lng: p.lng,
|
||||||
|
id: p.id,
|
||||||
|
label: `${p.title}, ${p.country}`,
|
||||||
|
color: '#f43f5e', // rose-500
|
||||||
|
size: p.size,
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Handle globe point click
|
||||||
|
const handlePointClick = useCallback((point: GlobePoint) => {
|
||||||
|
const place = places.find((p) => p.id === point.id)
|
||||||
|
if (!place) return
|
||||||
|
setSelectedPlace(place)
|
||||||
|
setFocusLat(place.lat)
|
||||||
|
setFocusLng(place.lng)
|
||||||
|
setLoadingChannels(true)
|
||||||
|
getChannels(place.id)
|
||||||
|
.then(setChannels)
|
||||||
|
.catch(() => setChannels([]))
|
||||||
|
.finally(() => setLoadingChannels(false))
|
||||||
|
}, [places])
|
||||||
|
|
||||||
|
// Handle search place click
|
||||||
|
const handleSearchPlaceClick = useCallback((placeId: string, placeTitle: string, country: string) => {
|
||||||
|
// Find in places array for geo data, or just load channels
|
||||||
|
const place = places.find((p) => p.id === placeId)
|
||||||
|
if (place) {
|
||||||
|
setSelectedPlace(place)
|
||||||
|
setFocusLat(place.lat)
|
||||||
|
setFocusLng(place.lng)
|
||||||
|
} else {
|
||||||
|
setSelectedPlace({ id: placeId, title: placeTitle, country, lat: 0, lng: 0, size: 1 })
|
||||||
|
}
|
||||||
|
setQuery('')
|
||||||
|
setSearchResults(null)
|
||||||
|
setLoadingChannels(true)
|
||||||
|
getChannels(placeId)
|
||||||
|
.then(setChannels)
|
||||||
|
.catch(() => setChannels([]))
|
||||||
|
.finally(() => setLoadingChannels(false))
|
||||||
|
}, [places])
|
||||||
|
|
||||||
|
// Play a station
|
||||||
|
const handlePlayStation = useCallback(async (channel: RadioChannel) => {
|
||||||
|
setResolvingId(channel.id)
|
||||||
|
try {
|
||||||
|
const streamUrl = await resolveStreamUrl(channel.id)
|
||||||
|
const radioTrack: RadioTrack = {
|
||||||
|
type: 'radio',
|
||||||
|
id: `radio:${channel.id}`,
|
||||||
|
title: channel.title,
|
||||||
|
artist: `${channel.placeTitle}, ${channel.country}`,
|
||||||
|
album: '',
|
||||||
|
albumId: '',
|
||||||
|
duration: 0,
|
||||||
|
coverArt: '',
|
||||||
|
streamUrl,
|
||||||
|
}
|
||||||
|
playTrack(radioTrack)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to play station:', err)
|
||||||
|
} finally {
|
||||||
|
setResolvingId(null)
|
||||||
|
}
|
||||||
|
}, [playTrack])
|
||||||
|
|
||||||
|
// Play search station directly
|
||||||
|
const handlePlaySearchStation = useCallback(async (station: { id: string; title: string; placeTitle: string; country: string }) => {
|
||||||
|
setResolvingId(station.id)
|
||||||
|
try {
|
||||||
|
const streamUrl = await resolveStreamUrl(station.id)
|
||||||
|
const radioTrack: RadioTrack = {
|
||||||
|
type: 'radio',
|
||||||
|
id: `radio:${station.id}`,
|
||||||
|
title: station.title,
|
||||||
|
artist: `${station.placeTitle}, ${station.country}`,
|
||||||
|
album: '',
|
||||||
|
albumId: '',
|
||||||
|
duration: 0,
|
||||||
|
coverArt: '',
|
||||||
|
streamUrl,
|
||||||
|
}
|
||||||
|
playTrack(radioTrack)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to play station:', err)
|
||||||
|
} finally {
|
||||||
|
setResolvingId(null)
|
||||||
|
}
|
||||||
|
}, [playTrack])
|
||||||
|
|
||||||
|
const isPlaying = (channelId: string) =>
|
||||||
|
state.currentTrack?.id === `radio:${channelId}` && state.isPlaying
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`min-h-screen bg-background ${state.currentTrack ? 'pb-20' : ''}`}>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="border-b border-border bg-card/50 backdrop-blur-sm sticky top-0 z-40">
|
||||||
|
<div className="container mx-auto px-4 py-3 flex items-center gap-4">
|
||||||
|
<Link href="/" className="flex-shrink-0">
|
||||||
|
<JefflixLogo />
|
||||||
|
</Link>
|
||||||
|
<div className="flex-1" />
|
||||||
|
<Link href="/">
|
||||||
|
<Button variant="ghost" size="sm">
|
||||||
|
<ArrowLeft className="h-4 w-4 mr-1" />
|
||||||
|
Home
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Hero */}
|
||||||
|
<div className="container mx-auto px-4 py-6 text-center">
|
||||||
|
<div className="flex items-center justify-center gap-3 mb-2">
|
||||||
|
<Radio className="h-8 w-8 text-rose-500" />
|
||||||
|
<h1 className="text-3xl font-bold">World Radio</h1>
|
||||||
|
</div>
|
||||||
|
<p className="text-muted-foreground max-w-lg mx-auto">
|
||||||
|
Explore live radio stations from around the globe. Click a point on the globe or search for stations.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main content */}
|
||||||
|
<div className="container mx-auto px-4 pb-8">
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-5 gap-6">
|
||||||
|
{/* Globe */}
|
||||||
|
<div className="lg:col-span-3 relative">
|
||||||
|
<div className="rounded-xl border border-border bg-card overflow-hidden">
|
||||||
|
{loadingPlaces ? (
|
||||||
|
<div className="flex items-center justify-center h-[400px] lg:h-[550px]">
|
||||||
|
<div className="flex flex-col items-center gap-3 text-muted-foreground">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin" />
|
||||||
|
<span className="text-sm">Loading {places.length > 0 ? `${places.length.toLocaleString()} stations` : 'stations'}...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : placesError ? (
|
||||||
|
<div className="flex items-center justify-center h-[400px] lg:h-[550px] text-destructive">
|
||||||
|
<p>Failed to load stations: {placesError}</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<GlobeLoader
|
||||||
|
points={globePoints}
|
||||||
|
onPointClick={handlePointClick}
|
||||||
|
height={typeof window !== 'undefined' && window.innerWidth >= 1024 ? 550 : 400}
|
||||||
|
autoRotate={!selectedPlace}
|
||||||
|
focusLat={focusLat}
|
||||||
|
focusLng={focusLng}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{places.length > 0 && (
|
||||||
|
<p className="text-xs text-muted-foreground text-center mt-2">
|
||||||
|
<Globe2 className="h-3 w-3 inline mr-1" />
|
||||||
|
{places.length.toLocaleString()} locations worldwide
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Station panel */}
|
||||||
|
<div className="lg:col-span-2">
|
||||||
|
{/* Search */}
|
||||||
|
<div className="relative mb-4">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
placeholder="Search stations, cities, countries..."
|
||||||
|
className="w-full pl-10 pr-10 py-2.5 rounded-lg border border-border bg-background text-sm focus:outline-none focus:ring-2 focus:ring-rose-500/50"
|
||||||
|
/>
|
||||||
|
{query && (
|
||||||
|
<button
|
||||||
|
onClick={() => { setQuery(''); setSearchResults(null) }}
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search results */}
|
||||||
|
{searchResults && (
|
||||||
|
<div className="rounded-lg border border-border bg-card mb-4">
|
||||||
|
<div className="px-4 py-2 border-b border-border">
|
||||||
|
<h3 className="text-sm font-medium text-muted-foreground">
|
||||||
|
{searching ? 'Searching...' : `${searchResults.stations.length} stations, ${searchResults.places.length} places`}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<ScrollArea className="max-h-[300px]">
|
||||||
|
{/* Station results */}
|
||||||
|
{searchResults.stations.map((station) => (
|
||||||
|
<button
|
||||||
|
key={station.id}
|
||||||
|
onClick={() => handlePlaySearchStation(station)}
|
||||||
|
disabled={resolvingId === station.id}
|
||||||
|
className="w-full flex items-center gap-3 px-4 py-2.5 hover:bg-muted/50 transition-colors text-left border-b border-border/50 last:border-0"
|
||||||
|
>
|
||||||
|
<div className="flex-shrink-0 w-8 h-8 rounded-full bg-rose-500/10 flex items-center justify-center">
|
||||||
|
{resolvingId === station.id ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin text-rose-500" />
|
||||||
|
) : isPlaying(station.id) ? (
|
||||||
|
<div className="flex items-center gap-0.5">
|
||||||
|
<div className="w-0.5 h-3 bg-rose-500 animate-pulse rounded-full" />
|
||||||
|
<div className="w-0.5 h-4 bg-rose-500 animate-pulse rounded-full" style={{ animationDelay: '150ms' }} />
|
||||||
|
<div className="w-0.5 h-2 bg-rose-500 animate-pulse rounded-full" style={{ animationDelay: '300ms' }} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Play className="h-3.5 w-3.5 text-rose-500 ml-0.5" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-sm font-medium truncate">{station.title}</div>
|
||||||
|
<div className="text-xs text-muted-foreground truncate">
|
||||||
|
{station.placeTitle}, {station.country}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Radio className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{/* Place results */}
|
||||||
|
{searchResults.places.map((place) => (
|
||||||
|
<button
|
||||||
|
key={place.id}
|
||||||
|
onClick={() => handleSearchPlaceClick(place.id, place.title, place.country)}
|
||||||
|
className="w-full flex items-center gap-3 px-4 py-2.5 hover:bg-muted/50 transition-colors text-left border-b border-border/50 last:border-0"
|
||||||
|
>
|
||||||
|
<div className="flex-shrink-0 w-8 h-8 rounded-full bg-blue-500/10 flex items-center justify-center">
|
||||||
|
<MapPin className="h-3.5 w-3.5 text-blue-500" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-sm font-medium truncate">{place.title}</div>
|
||||||
|
<div className="text-xs text-muted-foreground">{place.country}</div>
|
||||||
|
</div>
|
||||||
|
<Badge variant="secondary" className="text-[10px]">Place</Badge>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Selected place channels */}
|
||||||
|
{selectedPlace && !searchResults && (
|
||||||
|
<div className="rounded-lg border border-border bg-card">
|
||||||
|
<div className="px-4 py-3 border-b border-border flex items-center gap-2">
|
||||||
|
<MapPin className="h-4 w-4 text-rose-500" />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h3 className="text-sm font-semibold truncate">{selectedPlace.title}</h3>
|
||||||
|
<p className="text-xs text-muted-foreground">{selectedPlace.country}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => { setSelectedPlace(null); setChannels([]) }}
|
||||||
|
className="text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{loadingChannels ? (
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
) : channels.length === 0 ? (
|
||||||
|
<div className="px-4 py-8 text-center text-sm text-muted-foreground">
|
||||||
|
No stations found in this area
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ScrollArea className="max-h-[400px]">
|
||||||
|
{channels.map((channel) => (
|
||||||
|
<button
|
||||||
|
key={channel.id}
|
||||||
|
onClick={() => handlePlayStation(channel)}
|
||||||
|
disabled={resolvingId === channel.id}
|
||||||
|
className="w-full flex items-center gap-3 px-4 py-3 hover:bg-muted/50 transition-colors text-left border-b border-border/50 last:border-0"
|
||||||
|
>
|
||||||
|
<div className="flex-shrink-0 w-10 h-10 rounded-full bg-rose-500/10 flex items-center justify-center">
|
||||||
|
{resolvingId === channel.id ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin text-rose-500" />
|
||||||
|
) : isPlaying(channel.id) ? (
|
||||||
|
<div className="flex items-center gap-0.5">
|
||||||
|
<div className="w-0.5 h-3 bg-rose-500 animate-pulse rounded-full" />
|
||||||
|
<div className="w-0.5 h-5 bg-rose-500 animate-pulse rounded-full" style={{ animationDelay: '150ms' }} />
|
||||||
|
<div className="w-0.5 h-2.5 bg-rose-500 animate-pulse rounded-full" style={{ animationDelay: '300ms' }} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Play className="h-5 w-5 text-rose-500 ml-0.5" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="font-medium truncate">{channel.title}</div>
|
||||||
|
<div className="text-xs text-muted-foreground truncate">
|
||||||
|
{channel.placeTitle}, {channel.country}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</ScrollArea>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Empty state */}
|
||||||
|
{!selectedPlace && !searchResults && (
|
||||||
|
<div className="rounded-lg border border-border bg-card px-6 py-12 text-center">
|
||||||
|
<Globe2 className="h-12 w-12 text-muted-foreground/30 mx-auto mb-4" />
|
||||||
|
<h3 className="font-medium mb-1">Explore World Radio</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Click a point on the globe to discover radio stations, or use the search bar to find specific stations and cities.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,10 +1,12 @@
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
import { useState, useEffect, useRef, useCallback, useMemo } from 'react'
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { JefflixLogo } from "@/components/jefflix-logo"
|
import { JefflixLogo } from "@/components/jefflix-logo"
|
||||||
import { Search, CheckCircle, AlertCircle, ArrowLeft, X, Loader2 } from "lucide-react"
|
import { GlobeLoader, type GlobePoint } from "@/components/globe/globe-loader"
|
||||||
|
import { countryToLatLng } from "@/lib/country-centroids"
|
||||||
|
import { Search, CheckCircle, AlertCircle, ArrowLeft, X, Loader2, Globe2, List } from "lucide-react"
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
|
|
||||||
interface Channel {
|
interface Channel {
|
||||||
|
|
@ -26,6 +28,45 @@ export default function RequestChannelPage() {
|
||||||
const [errorMessage, setErrorMessage] = useState('')
|
const [errorMessage, setErrorMessage] = useState('')
|
||||||
const debounceRef = useRef<NodeJS.Timeout>(null)
|
const debounceRef = useRef<NodeJS.Timeout>(null)
|
||||||
|
|
||||||
|
// Globe view toggle
|
||||||
|
const [view, setView] = useState<'search' | 'globe'>('search')
|
||||||
|
const [globeCountry, setGlobeCountry] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// Build globe points from channels (aggregate by country)
|
||||||
|
const globePoints: GlobePoint[] = useMemo(() => {
|
||||||
|
const countryMap = new Map<string, { count: number; coords: [number, number] }>()
|
||||||
|
for (const ch of channels) {
|
||||||
|
if (!ch.country) continue
|
||||||
|
const existing = countryMap.get(ch.country)
|
||||||
|
if (existing) {
|
||||||
|
existing.count++
|
||||||
|
} else {
|
||||||
|
const coords = countryToLatLng(ch.country)
|
||||||
|
if (coords) {
|
||||||
|
countryMap.set(ch.country, { count: 1, coords })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Array.from(countryMap.entries()).map(([country, { count, coords }]) => ({
|
||||||
|
lat: coords[0],
|
||||||
|
lng: coords[1],
|
||||||
|
id: country,
|
||||||
|
label: `${country} (${count} channels)`,
|
||||||
|
color: '#06b6d4', // cyan-500
|
||||||
|
size: Math.max(1, Math.min(8, Math.log2(count + 1) * 2)),
|
||||||
|
}))
|
||||||
|
}, [channels])
|
||||||
|
|
||||||
|
// Channels filtered for globe-selected country
|
||||||
|
const globeFilteredChannels = useMemo(() => {
|
||||||
|
if (!globeCountry) return []
|
||||||
|
return channels.filter((ch) => ch.country === globeCountry)
|
||||||
|
}, [channels, globeCountry])
|
||||||
|
|
||||||
|
const handleGlobePointClick = useCallback((point: GlobePoint) => {
|
||||||
|
setGlobeCountry(point.id) // id = country name
|
||||||
|
}, [])
|
||||||
|
|
||||||
// Fetch channel list on mount
|
// Fetch channel list on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch('/api/channels')
|
fetch('/api/channels')
|
||||||
|
|
@ -151,6 +192,30 @@ export default function RequestChannelPage() {
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
Search the iptv-org catalog and select channels you'd like us to add to the Live TV lineup.
|
Search the iptv-org catalog and select channels you'd like us to add to the Live TV lineup.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
{/* View toggle */}
|
||||||
|
{!loading && !loadError && (
|
||||||
|
<div className="flex items-center justify-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant={view === 'search' ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setView('search')}
|
||||||
|
className={view === 'search' ? 'bg-cyan-600 hover:bg-cyan-700' : ''}
|
||||||
|
>
|
||||||
|
<List className="h-4 w-4 mr-1.5" />
|
||||||
|
Search
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={view === 'globe' ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setView('globe')}
|
||||||
|
className={view === 'globe' ? 'bg-cyan-600 hover:bg-cyan-700' : ''}
|
||||||
|
>
|
||||||
|
<Globe2 className="h-4 w-4 mr-1.5" />
|
||||||
|
Globe
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
|
|
@ -166,6 +231,113 @@ export default function RequestChannelPage() {
|
||||||
Try Again
|
Try Again
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
) : view === 'globe' ? (
|
||||||
|
/* Globe View */
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="rounded-xl border border-border bg-card overflow-hidden">
|
||||||
|
<GlobeLoader
|
||||||
|
points={globePoints}
|
||||||
|
onPointClick={handleGlobePointClick}
|
||||||
|
height={450}
|
||||||
|
autoRotate={!globeCountry}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Selected chips (shared with search view) */}
|
||||||
|
{selected.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{selected.map((ch) => (
|
||||||
|
<Badge
|
||||||
|
key={ch.id}
|
||||||
|
className="bg-cyan-600 text-white pl-3 pr-1 py-1.5 text-sm flex items-center gap-1 cursor-pointer hover:bg-cyan-700"
|
||||||
|
onClick={() => removeChannel(ch.id)}
|
||||||
|
>
|
||||||
|
{ch.name}
|
||||||
|
<X className="h-3.5 w-3.5 ml-1" />
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Country channel list */}
|
||||||
|
{globeCountry && (
|
||||||
|
<div className="border border-border rounded-lg">
|
||||||
|
<div className="px-4 py-3 border-b border-border flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold">{globeCountry}</h3>
|
||||||
|
<p className="text-xs text-muted-foreground">{globeFilteredChannels.length} channels</p>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => setGlobeCountry(null)} className="text-muted-foreground hover:text-foreground">
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="max-h-[300px] overflow-y-auto divide-y divide-border">
|
||||||
|
{globeFilteredChannels.map((ch) => {
|
||||||
|
const isSelected = selected.some((s) => s.id === ch.id)
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={ch.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleChannel(ch)}
|
||||||
|
className={`w-full text-left px-4 py-3 flex items-center justify-between gap-3 hover:bg-muted/50 transition-colors ${
|
||||||
|
isSelected ? 'bg-cyan-50 dark:bg-cyan-950/30' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="font-medium truncate">{ch.name}</div>
|
||||||
|
<div className="flex flex-wrap gap-1 mt-1">
|
||||||
|
{ch.categories.map((cat) => (
|
||||||
|
<Badge key={cat} variant="secondary" className="text-xs">{cat}</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{isSelected && <CheckCircle className="h-5 w-5 text-cyan-600 flex-shrink-0" />}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!globeCountry && (
|
||||||
|
<p className="text-center text-sm text-muted-foreground">
|
||||||
|
Click a point on the globe to browse channels by country
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Email + Submit (same as search view) */}
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="globe-email" className="text-sm font-medium">Your Email</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
id="globe-email"
|
||||||
|
required
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
className="w-full px-4 py-3 rounded-lg border border-border bg-background focus:outline-none focus:ring-2 focus:ring-cyan-500"
|
||||||
|
placeholder="your@email.com"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{status === 'error' && (
|
||||||
|
<div className="flex items-center gap-2 p-4 bg-red-100 dark:bg-red-900/30 rounded-lg text-red-600 dark:text-red-400">
|
||||||
|
<AlertCircle className="h-5 w-5 flex-shrink-0" />
|
||||||
|
<p className="text-sm">{errorMessage}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={status === 'submitting' || selected.length === 0}
|
||||||
|
className="w-full py-6 text-lg font-bold bg-cyan-600 hover:bg-cyan-700 text-white disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{status === 'submitting'
|
||||||
|
? 'Submitting...'
|
||||||
|
: selected.length === 0
|
||||||
|
? 'Select channels to request'
|
||||||
|
: `Request ${selected.length} Channel${selected.length > 1 ? 's' : ''}`}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<form onSubmit={handleSubmit} className="space-y-6">
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
{/* Search input */}
|
{/* Search input */}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import dynamic from 'next/dynamic'
|
||||||
|
import { Loader2 } from 'lucide-react'
|
||||||
|
|
||||||
|
export type { GlobePoint } from './globe-visualization'
|
||||||
|
|
||||||
|
const GlobeVisualization = dynamic(
|
||||||
|
() => import('./globe-visualization').then((m) => m.GlobeVisualization),
|
||||||
|
{
|
||||||
|
ssr: false,
|
||||||
|
loading: () => (
|
||||||
|
<div className="flex items-center justify-center h-full w-full min-h-[300px]">
|
||||||
|
<div className="flex flex-col items-center gap-3 text-muted-foreground">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin" />
|
||||||
|
<span className="text-sm">Loading globe...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export { GlobeVisualization as GlobeLoader }
|
||||||
|
|
@ -0,0 +1,97 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useRef, useEffect, useState, useCallback } from 'react'
|
||||||
|
import Globe from 'react-globe.gl'
|
||||||
|
|
||||||
|
export interface GlobePoint {
|
||||||
|
lat: number
|
||||||
|
lng: number
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
color: string
|
||||||
|
size?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GlobeVisualizationProps {
|
||||||
|
points: GlobePoint[]
|
||||||
|
onPointClick: (point: GlobePoint) => void
|
||||||
|
height?: number
|
||||||
|
autoRotate?: boolean
|
||||||
|
focusLat?: number
|
||||||
|
focusLng?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GlobeVisualization({
|
||||||
|
points,
|
||||||
|
onPointClick,
|
||||||
|
height = 500,
|
||||||
|
autoRotate = true,
|
||||||
|
focusLat,
|
||||||
|
focusLng,
|
||||||
|
}: GlobeVisualizationProps) {
|
||||||
|
const globeRef = useRef<any>(null)
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
|
const [containerWidth, setContainerWidth] = useState(0)
|
||||||
|
|
||||||
|
// Responsive width
|
||||||
|
useEffect(() => {
|
||||||
|
if (!containerRef.current) return
|
||||||
|
const observer = new ResizeObserver((entries) => {
|
||||||
|
for (const entry of entries) {
|
||||||
|
setContainerWidth(entry.contentRect.width)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
observer.observe(containerRef.current)
|
||||||
|
setContainerWidth(containerRef.current.clientWidth)
|
||||||
|
return () => observer.disconnect()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Configure controls after globe is ready
|
||||||
|
const handleGlobeReady = useCallback(() => {
|
||||||
|
const globe = globeRef.current
|
||||||
|
if (!globe) return
|
||||||
|
const controls = globe.controls()
|
||||||
|
if (controls) {
|
||||||
|
controls.autoRotate = autoRotate
|
||||||
|
controls.autoRotateSpeed = 0.5
|
||||||
|
controls.enableDamping = true
|
||||||
|
controls.dampingFactor = 0.1
|
||||||
|
}
|
||||||
|
// Set initial view
|
||||||
|
globe.pointOfView({ lat: focusLat ?? 20, lng: focusLng ?? 0, altitude: 2.5 }, 0)
|
||||||
|
}, [autoRotate, focusLat, focusLng])
|
||||||
|
|
||||||
|
// Focus on specific coordinates when they change
|
||||||
|
useEffect(() => {
|
||||||
|
if (globeRef.current && focusLat != null && focusLng != null) {
|
||||||
|
globeRef.current.pointOfView({ lat: focusLat, lng: focusLng, altitude: 1.5 }, 1000)
|
||||||
|
}
|
||||||
|
}, [focusLat, focusLng])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={containerRef} className="w-full" style={{ height }}>
|
||||||
|
{containerWidth > 0 && (
|
||||||
|
<Globe
|
||||||
|
ref={globeRef}
|
||||||
|
width={containerWidth}
|
||||||
|
height={height}
|
||||||
|
globeImageUrl="/textures/earth-blue-marble.jpg"
|
||||||
|
backgroundColor="rgba(0,0,0,0)"
|
||||||
|
showAtmosphere={true}
|
||||||
|
atmosphereColor="rgba(100,150,255,0.3)"
|
||||||
|
atmosphereAltitude={0.15}
|
||||||
|
pointsData={points}
|
||||||
|
pointLat="lat"
|
||||||
|
pointLng="lng"
|
||||||
|
pointColor="color"
|
||||||
|
pointAltitude={0.01}
|
||||||
|
pointRadius={(d: any) => Math.max(0.15, Math.min(0.6, (d.size || 1) * 0.12))}
|
||||||
|
pointLabel={(d: any) => `<div class="text-sm font-medium px-2 py-1 bg-background/90 border border-border rounded shadow-lg">${d.label}</div>`}
|
||||||
|
onPointClick={(point: any) => onPointClick(point as GlobePoint)}
|
||||||
|
onGlobeReady={handleGlobeReady}
|
||||||
|
enablePointerInteraction={true}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import { Drawer } from 'vaul'
|
import { Drawer } from 'vaul'
|
||||||
import { useMusicPlayer } from './music-provider'
|
import { useMusicPlayer, isRadioTrack } from './music-provider'
|
||||||
import { DownloadButton } from './download-button'
|
import { DownloadButton } from './download-button'
|
||||||
import { PlaylistPicker } from './playlist-picker'
|
import { PlaylistPicker } from './playlist-picker'
|
||||||
import { SyncedLyrics } from './synced-lyrics'
|
import { SyncedLyrics } from './synced-lyrics'
|
||||||
|
|
@ -23,6 +23,7 @@ import {
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
Speaker,
|
Speaker,
|
||||||
Shuffle,
|
Shuffle,
|
||||||
|
Radio,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
|
||||||
function formatTime(secs: number) {
|
function formatTime(secs: number) {
|
||||||
|
|
@ -43,9 +44,16 @@ export function FullScreenPlayer() {
|
||||||
|
|
||||||
const track = state.currentTrack
|
const track = state.currentTrack
|
||||||
|
|
||||||
// Fetch lyrics when track changes
|
const isRadio = track ? isRadioTrack(track) : false
|
||||||
|
|
||||||
|
// Fetch lyrics when track changes (skip for radio)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!track) return
|
if (!track || isRadioTrack(track)) {
|
||||||
|
setLyrics(null)
|
||||||
|
setSyncedLyrics(null)
|
||||||
|
setLoadingLyrics(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
setLyrics(null)
|
setLyrics(null)
|
||||||
setSyncedLyrics(null)
|
setSyncedLyrics(null)
|
||||||
setLoadingLyrics(true)
|
setLoadingLyrics(true)
|
||||||
|
|
@ -79,9 +87,9 @@ export function FullScreenPlayer() {
|
||||||
<Drawer.Content className="fixed inset-x-0 bottom-0 z-50 flex max-h-[96vh] flex-col rounded-t-2xl bg-background border-t border-border">
|
<Drawer.Content className="fixed inset-x-0 bottom-0 z-50 flex max-h-[96vh] flex-col rounded-t-2xl bg-background border-t border-border">
|
||||||
<div className="mx-auto mt-2 h-1.5 w-12 rounded-full bg-muted-foreground/30" />
|
<div className="mx-auto mt-2 h-1.5 w-12 rounded-full bg-muted-foreground/30" />
|
||||||
|
|
||||||
<Drawer.Title className="sr-only">Music Player</Drawer.Title>
|
<Drawer.Title className="sr-only">{isRadio ? 'Radio Player' : 'Music Player'}</Drawer.Title>
|
||||||
<Drawer.Description className="sr-only">
|
<Drawer.Description className="sr-only">
|
||||||
Full-screen music player with controls, lyrics, and playlist management
|
{isRadio ? 'Live radio player with station controls' : 'Full-screen music player with controls, lyrics, and playlist management'}
|
||||||
</Drawer.Description>
|
</Drawer.Description>
|
||||||
|
|
||||||
{track && (
|
{track && (
|
||||||
|
|
@ -104,7 +112,7 @@ export function FullScreenPlayer() {
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="w-full h-full flex items-center justify-center text-muted-foreground">
|
<div className="w-full h-full flex items-center justify-center text-muted-foreground">
|
||||||
<Volume2 className="h-16 w-16" />
|
{isRadio ? <Radio className="h-16 w-16 text-rose-400" /> : <Volume2 className="h-16 w-16" />}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -126,28 +134,43 @@ export function FullScreenPlayer() {
|
||||||
|
|
||||||
{/* Progress */}
|
{/* Progress */}
|
||||||
<div className="w-full max-w-sm mb-4">
|
<div className="w-full max-w-sm mb-4">
|
||||||
<Slider
|
{isRadio ? (
|
||||||
value={[state.progress]}
|
<>
|
||||||
max={state.duration || 1}
|
<div className="h-2 w-full bg-rose-500/20 rounded-full overflow-hidden mb-2">
|
||||||
step={1}
|
<div className="h-full w-full bg-rose-500 animate-pulse" />
|
||||||
onValueChange={([v]) => seek(v)}
|
</div>
|
||||||
className="mb-2"
|
<div className="flex justify-center text-xs">
|
||||||
/>
|
<span className="font-bold px-2 py-0.5 rounded bg-rose-500/20 text-rose-400 animate-pulse">LIVE</span>
|
||||||
<div className="flex justify-between text-xs text-muted-foreground">
|
</div>
|
||||||
<span>{formatTime(state.progress)}</span>
|
</>
|
||||||
<span>{formatTime(state.duration)}</span>
|
) : (
|
||||||
</div>
|
<>
|
||||||
|
<Slider
|
||||||
|
value={[state.progress]}
|
||||||
|
max={state.duration || 1}
|
||||||
|
step={1}
|
||||||
|
onValueChange={([v]) => seek(v)}
|
||||||
|
className="mb-2"
|
||||||
|
/>
|
||||||
|
<div className="flex justify-between text-xs text-muted-foreground">
|
||||||
|
<span>{formatTime(state.progress)}</span>
|
||||||
|
<span>{formatTime(state.duration)}</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Controls */}
|
{/* Controls */}
|
||||||
<div className="flex items-center gap-6 mb-6">
|
<div className="flex items-center gap-6 mb-6">
|
||||||
<button
|
{!isRadio && (
|
||||||
onClick={toggleShuffle}
|
<button
|
||||||
className={`p-2 rounded-full transition-colors ${state.shuffleEnabled ? 'text-purple-400' : 'hover:bg-muted/50'}`}
|
onClick={toggleShuffle}
|
||||||
title={state.shuffleEnabled ? 'Disable shuffle' : 'Enable shuffle'}
|
className={`p-2 rounded-full transition-colors ${state.shuffleEnabled ? 'text-purple-400' : 'hover:bg-muted/50'}`}
|
||||||
>
|
title={state.shuffleEnabled ? 'Disable shuffle' : 'Enable shuffle'}
|
||||||
<Shuffle className="h-5 w-5" />
|
>
|
||||||
</button>
|
<Shuffle className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button onClick={prevTrack} className="p-2 hover:bg-muted/50 rounded-full transition-colors">
|
<button onClick={prevTrack} className="p-2 hover:bg-muted/50 rounded-full transition-colors">
|
||||||
<SkipBack className="h-6 w-6" />
|
<SkipBack className="h-6 w-6" />
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -199,34 +222,38 @@ export function FullScreenPlayer() {
|
||||||
<ListMusic className="h-4 w-4 mr-1.5" />
|
<ListMusic className="h-4 w-4 mr-1.5" />
|
||||||
Queue
|
Queue
|
||||||
</Button>
|
</Button>
|
||||||
<DownloadButton track={track} size="md" />
|
{!isRadio && <DownloadButton track={track} size="md" />}
|
||||||
<Button variant="outline" size="sm" onClick={() => setPlaylistOpen(true)}>
|
{!isRadio && (
|
||||||
<ListPlus className="h-4 w-4 mr-1.5" />
|
<Button variant="outline" size="sm" onClick={() => setPlaylistOpen(true)}>
|
||||||
Add to Playlist
|
<ListPlus className="h-4 w-4 mr-1.5" />
|
||||||
</Button>
|
Add to Playlist
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Button variant="outline" size="sm" onClick={handleShare}>
|
<Button variant="outline" size="sm" onClick={handleShare}>
|
||||||
<Share2 className="h-4 w-4 mr-1.5" />
|
<Share2 className="h-4 w-4 mr-1.5" />
|
||||||
Share
|
Share
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Lyrics */}
|
{/* Lyrics (hidden for radio) */}
|
||||||
{loadingLyrics ? (
|
{!isRadio && (
|
||||||
<p className="text-sm text-muted-foreground animate-pulse">Loading lyrics...</p>
|
loadingLyrics ? (
|
||||||
) : syncedLyrics ? (
|
<p className="text-sm text-muted-foreground animate-pulse">Loading lyrics...</p>
|
||||||
<SyncedLyrics
|
) : syncedLyrics ? (
|
||||||
syncedLyrics={syncedLyrics}
|
<SyncedLyrics
|
||||||
currentTime={state.progress}
|
syncedLyrics={syncedLyrics}
|
||||||
onSeek={seek}
|
currentTime={state.progress}
|
||||||
/>
|
onSeek={seek}
|
||||||
) : lyrics ? (
|
/>
|
||||||
<div className="w-full max-w-sm">
|
) : lyrics ? (
|
||||||
<h3 className="text-sm font-semibold mb-2 text-muted-foreground">Lyrics</h3>
|
<div className="w-full max-w-sm">
|
||||||
<pre className="text-sm leading-relaxed whitespace-pre-wrap text-muted-foreground font-sans">
|
<h3 className="text-sm font-semibold mb-2 text-muted-foreground">Lyrics</h3>
|
||||||
{lyrics}
|
<pre className="text-sm leading-relaxed whitespace-pre-wrap text-muted-foreground font-sans">
|
||||||
</pre>
|
{lyrics}
|
||||||
</div>
|
</pre>
|
||||||
) : null}
|
</div>
|
||||||
|
) : null
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Drawer.Content>
|
</Drawer.Content>
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useMusicPlayer } from './music-provider'
|
import { useMusicPlayer, isRadioTrack } from './music-provider'
|
||||||
import { FullScreenPlayer } from './full-screen-player'
|
import { FullScreenPlayer } from './full-screen-player'
|
||||||
import { QueueView } from './queue-view'
|
import { QueueView } from './queue-view'
|
||||||
import { Slider } from '@/components/ui/slider'
|
import { Slider } from '@/components/ui/slider'
|
||||||
import { Play, Pause, SkipBack, SkipForward, ListMusic, Shuffle } from 'lucide-react'
|
import { Play, Pause, SkipBack, SkipForward, ListMusic, Shuffle, Radio } from 'lucide-react'
|
||||||
|
|
||||||
function formatTime(secs: number) {
|
function formatTime(secs: number) {
|
||||||
if (!secs || !isFinite(secs)) return '0:00'
|
if (!secs || !isFinite(secs)) return '0:00'
|
||||||
|
|
@ -21,19 +21,26 @@ export function MiniPlayer() {
|
||||||
if (!state.currentTrack) return null
|
if (!state.currentTrack) return null
|
||||||
|
|
||||||
const track = state.currentTrack
|
const track = state.currentTrack
|
||||||
|
const isRadio = isRadioTrack(track)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="fixed bottom-0 inset-x-0 z-50 bg-card border-t border-border shadow-lg">
|
<div className="fixed bottom-0 inset-x-0 z-50 bg-card border-t border-border shadow-lg">
|
||||||
{/* Progress bar (thin, above the player) */}
|
{/* Progress bar (thin, above the player) — disabled for radio */}
|
||||||
<div className="px-2">
|
<div className="px-2">
|
||||||
<Slider
|
{isRadio ? (
|
||||||
value={[state.progress]}
|
<div className="h-1 w-full bg-rose-500/40 rounded-full overflow-hidden">
|
||||||
max={state.duration || 1}
|
<div className="h-full w-full bg-rose-500 animate-pulse" />
|
||||||
step={1}
|
</div>
|
||||||
onValueChange={([v]) => seek(v)}
|
) : (
|
||||||
className="h-1 [&_[data-slot=slider]]:h-1 [&_span[data-slot=scroll-bar]]:hidden"
|
<Slider
|
||||||
/>
|
value={[state.progress]}
|
||||||
|
max={state.duration || 1}
|
||||||
|
step={1}
|
||||||
|
onValueChange={([v]) => seek(v)}
|
||||||
|
className="h-1 [&_[data-slot=slider]]:h-1 [&_span[data-slot=scroll-bar]]:hidden"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-3 px-4 py-2 h-14">
|
<div className="flex items-center gap-3 px-4 py-2 h-14">
|
||||||
|
|
@ -49,7 +56,9 @@ export function MiniPlayer() {
|
||||||
className="w-full h-full object-cover"
|
className="w-full h-full object-cover"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="w-full h-full bg-muted" />
|
<div className="w-full h-full flex items-center justify-center bg-muted">
|
||||||
|
{isRadio && <Radio className="h-5 w-5 text-rose-400" />}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
|
@ -62,20 +71,28 @@ export function MiniPlayer() {
|
||||||
<div className="text-xs text-muted-foreground truncate">{track.artist}</div>
|
<div className="text-xs text-muted-foreground truncate">{track.artist}</div>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Time */}
|
{/* Time / LIVE badge */}
|
||||||
<span className="text-xs text-muted-foreground tabular-nums hidden sm:block">
|
{isRadio ? (
|
||||||
{formatTime(state.progress)} / {formatTime(state.duration)}
|
<span className="text-[10px] font-bold px-1.5 py-0.5 rounded bg-rose-500/20 text-rose-400 animate-pulse hidden sm:block">
|
||||||
</span>
|
LIVE
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-muted-foreground tabular-nums hidden sm:block">
|
||||||
|
{formatTime(state.progress)} / {formatTime(state.duration)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Controls */}
|
{/* Controls */}
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<button
|
{!isRadio && (
|
||||||
onClick={toggleShuffle}
|
<button
|
||||||
className={`p-1.5 rounded-full transition-colors ${state.shuffleEnabled ? 'text-purple-400' : 'text-muted-foreground hover:bg-muted/50'}`}
|
onClick={toggleShuffle}
|
||||||
title={state.shuffleEnabled ? 'Disable shuffle' : 'Enable shuffle'}
|
className={`p-1.5 rounded-full transition-colors ${state.shuffleEnabled ? 'text-purple-400' : 'text-muted-foreground hover:bg-muted/50'}`}
|
||||||
>
|
title={state.shuffleEnabled ? 'Disable shuffle' : 'Enable shuffle'}
|
||||||
<Shuffle className="h-3.5 w-3.5" />
|
>
|
||||||
</button>
|
<Shuffle className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={prevTrack}
|
onClick={prevTrack}
|
||||||
className="p-1.5 hover:bg-muted/50 rounded-full transition-colors"
|
className="p-1.5 hover:bg-muted/50 rounded-full transition-colors"
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import React, { createContext, useContext, useReducer, useRef, useEffect, useCal
|
||||||
import { getTrackBlob } from '@/lib/offline-db'
|
import { getTrackBlob } from '@/lib/offline-db'
|
||||||
import { precacheUpcoming } from '@/lib/precache'
|
import { precacheUpcoming } from '@/lib/precache'
|
||||||
|
|
||||||
export interface Track {
|
interface TrackBase {
|
||||||
id: string
|
id: string
|
||||||
title: string
|
title: string
|
||||||
artist: string
|
artist: string
|
||||||
|
|
@ -14,6 +14,21 @@ export interface Track {
|
||||||
coverArt: string
|
coverArt: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface MusicTrack extends TrackBase {
|
||||||
|
type?: 'music'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RadioTrack extends TrackBase {
|
||||||
|
type: 'radio'
|
||||||
|
streamUrl: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Track = MusicTrack | RadioTrack
|
||||||
|
|
||||||
|
export function isRadioTrack(track: Track): track is RadioTrack {
|
||||||
|
return track.type === 'radio'
|
||||||
|
}
|
||||||
|
|
||||||
interface PlayerState {
|
interface PlayerState {
|
||||||
currentTrack: Track | null
|
currentTrack: Track | null
|
||||||
queue: Track[]
|
queue: Track[]
|
||||||
|
|
@ -267,7 +282,15 @@ export function MusicProvider({ children }: { children: React.ReactNode }) {
|
||||||
blobUrlRef.current = null
|
blobUrlRef.current = null
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try offline first, fall back to streaming
|
// Radio streams: use streamUrl directly, skip offline/IndexedDB
|
||||||
|
if (isRadioTrack(state.currentTrack)) {
|
||||||
|
audio.src = state.currentTrack.streamUrl
|
||||||
|
playWithRetry(audio)
|
||||||
|
requestAnimationFrame(() => { transitioningRef.current = false })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Music: try offline first, fall back to streaming
|
||||||
getTrackBlob(trackId).then((blob) => {
|
getTrackBlob(trackId).then((blob) => {
|
||||||
// Guard: track may have changed while we awaited
|
// Guard: track may have changed while we awaited
|
||||||
if (state.currentTrack?.id !== trackId) return
|
if (state.currentTrack?.id !== trackId) return
|
||||||
|
|
@ -322,9 +345,10 @@ export function MusicProvider({ children }: { children: React.ReactNode }) {
|
||||||
navigator.mediaSession.setActionHandler('nexttrack', () => dispatch({ type: 'NEXT_TRACK' }))
|
navigator.mediaSession.setActionHandler('nexttrack', () => dispatch({ type: 'NEXT_TRACK' }))
|
||||||
}, [state.currentTrack])
|
}, [state.currentTrack])
|
||||||
|
|
||||||
// Pre-cache next 3 tracks in queue when current track changes
|
// Pre-cache next 3 tracks in queue when current track changes (skip for radio)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (state.queueIndex < 0 || state.queue.length <= state.queueIndex + 1) return
|
if (state.queueIndex < 0 || state.queue.length <= state.queueIndex + 1) return
|
||||||
|
if (state.currentTrack && isRadioTrack(state.currentTrack)) return
|
||||||
|
|
||||||
const ac = new AbortController()
|
const ac = new AbortController()
|
||||||
const delay = setTimeout(() => {
|
const delay = setTimeout(() => {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,268 @@
|
||||||
|
/**
|
||||||
|
* ISO 3166-1 alpha-2 country codes mapped to [latitude, longitude] centroids.
|
||||||
|
* Coordinates represent approximate geographic centroids of each country.
|
||||||
|
*/
|
||||||
|
export const COUNTRY_CENTROIDS: Record<string, [number, number]> = {
|
||||||
|
AD: [42.5462, 1.6016], // Andorra
|
||||||
|
AE: [23.4241, 53.8478], // United Arab Emirates
|
||||||
|
AF: [33.9391, 67.7100], // Afghanistan
|
||||||
|
AG: [17.0608, -61.7964], // Antigua and Barbuda
|
||||||
|
AI: [18.2206, -63.0686], // Anguilla
|
||||||
|
AL: [41.1533, 20.1683], // Albania
|
||||||
|
AM: [40.0691, 45.0382], // Armenia
|
||||||
|
AO: [-11.2027, 17.8739], // Angola
|
||||||
|
AQ: [-90.0000, 0.0000], // Antarctica
|
||||||
|
AR: [-38.4161, -63.6167],// Argentina
|
||||||
|
AS: [-14.2710, -170.1322],// American Samoa
|
||||||
|
AT: [47.5162, 14.5501], // Austria
|
||||||
|
AU: [-25.2744, 133.7751],// Australia
|
||||||
|
AW: [12.5211, -69.9683], // Aruba
|
||||||
|
AX: [60.1785, 19.9156], // Aland Islands
|
||||||
|
AZ: [40.1431, 47.5769], // Azerbaijan
|
||||||
|
BA: [43.9159, 17.6791], // Bosnia and Herzegovina
|
||||||
|
BB: [13.1939, -59.5432], // Barbados
|
||||||
|
BD: [23.6850, 90.3563], // Bangladesh
|
||||||
|
BE: [50.5039, 4.4699], // Belgium
|
||||||
|
BF: [12.3641, -1.5275], // Burkina Faso
|
||||||
|
BG: [42.7339, 25.4858], // Bulgaria
|
||||||
|
BH: [26.0275, 50.5500], // Bahrain
|
||||||
|
BI: [-3.3731, 29.9189], // Burundi
|
||||||
|
BJ: [9.3077, 2.3158], // Benin
|
||||||
|
BL: [17.9000, -62.8333], // Saint Barthelemy
|
||||||
|
BM: [32.3213, -64.7572], // Bermuda
|
||||||
|
BN: [4.5353, 114.7277], // Brunei
|
||||||
|
BO: [-16.2902, -63.5887],// Bolivia
|
||||||
|
BQ: [12.1784, -68.2385], // Bonaire, Saint Eustatius and Saba
|
||||||
|
BR: [-14.2350, -51.9253],// Brazil
|
||||||
|
BS: [25.0343, -77.3963], // Bahamas
|
||||||
|
BT: [27.5142, 90.4336], // Bhutan
|
||||||
|
BV: [-54.4208, 3.3464], // Bouvet Island
|
||||||
|
BW: [-22.3285, 24.6849], // Botswana
|
||||||
|
BY: [53.7098, 27.9534], // Belarus
|
||||||
|
BZ: [17.1899, -88.4976], // Belize
|
||||||
|
CA: [56.1304, -106.3468],// Canada
|
||||||
|
CC: [-12.1642, 96.8710], // Cocos (Keeling) Islands
|
||||||
|
CD: [-4.0383, 21.7587], // Democratic Republic of the Congo
|
||||||
|
CF: [6.6111, 20.9394], // Central African Republic
|
||||||
|
CG: [-0.2280, 15.8277], // Republic of the Congo
|
||||||
|
CH: [46.8182, 8.2275], // Switzerland
|
||||||
|
CI: [7.5400, -5.5471], // Ivory Coast
|
||||||
|
CK: [-21.2368, -159.7777],// Cook Islands
|
||||||
|
CL: [-35.6751, -71.5430],// Chile
|
||||||
|
CM: [3.8480, 11.5021], // Cameroon
|
||||||
|
CN: [35.8617, 104.1954], // China
|
||||||
|
CO: [4.5709, -74.2973], // Colombia
|
||||||
|
CR: [9.7489, -83.7534], // Costa Rica
|
||||||
|
CU: [21.5218, -77.7812], // Cuba
|
||||||
|
CV: [16.5388, -23.0418], // Cape Verde
|
||||||
|
CW: [12.1696, -68.9900], // Curacao
|
||||||
|
CX: [-10.4475, 105.6904],// Christmas Island
|
||||||
|
CY: [35.1264, 33.4299], // Cyprus
|
||||||
|
CZ: [49.8175, 15.4730], // Czech Republic
|
||||||
|
DE: [51.1657, 10.4515], // Germany
|
||||||
|
DJ: [11.8251, 42.5903], // Djibouti
|
||||||
|
DK: [56.2639, 9.5018], // Denmark
|
||||||
|
DM: [15.4150, -61.3710], // Dominica
|
||||||
|
DO: [18.7357, -70.1627], // Dominican Republic
|
||||||
|
DZ: [28.0339, 1.6596], // Algeria
|
||||||
|
EC: [-1.8312, -78.1834], // Ecuador
|
||||||
|
EE: [58.5953, 25.0136], // Estonia
|
||||||
|
EG: [26.8206, 30.8025], // Egypt
|
||||||
|
EH: [24.2155, -12.8858], // Western Sahara
|
||||||
|
ER: [15.1794, 39.7823], // Eritrea
|
||||||
|
ES: [40.4637, -3.7492], // Spain
|
||||||
|
ET: [9.1450, 40.4897], // Ethiopia
|
||||||
|
FI: [61.9241, 25.7482], // Finland
|
||||||
|
FJ: [-16.5782, 179.4144],// Fiji
|
||||||
|
FK: [-51.7963, -59.5236],// Falkland Islands
|
||||||
|
FM: [7.4256, 150.5508], // Micronesia
|
||||||
|
FO: [61.8926, -6.9118], // Faroe Islands
|
||||||
|
FR: [46.2276, 2.2137], // France
|
||||||
|
GA: [-0.8037, 11.6094], // Gabon
|
||||||
|
GB: [55.3781, -3.4360], // United Kingdom
|
||||||
|
GD: [12.1165, -61.6790], // Grenada
|
||||||
|
GE: [42.3154, 43.3569], // Georgia
|
||||||
|
GF: [3.9339, -53.1258], // French Guiana
|
||||||
|
GG: [49.4657, -2.5853], // Guernsey
|
||||||
|
GH: [7.9465, -1.0232], // Ghana
|
||||||
|
GI: [36.1408, -5.3536], // Gibraltar
|
||||||
|
GL: [71.7069, -42.6043], // Greenland
|
||||||
|
GM: [13.4432, -15.3101], // Gambia
|
||||||
|
GN: [9.9456, -11.2874], // Guinea
|
||||||
|
GP: [16.9950, -62.0670], // Guadeloupe
|
||||||
|
GQ: [1.6508, 10.2679], // Equatorial Guinea
|
||||||
|
GR: [39.0742, 21.8243], // Greece
|
||||||
|
GS: [-54.4296, -36.5879],// South Georgia and the South Sandwich Islands
|
||||||
|
GT: [15.7835, -90.2308], // Guatemala
|
||||||
|
GU: [13.4443, 144.7937], // Guam
|
||||||
|
GW: [11.8037, -15.1804], // Guinea-Bissau
|
||||||
|
GY: [4.8604, -58.9302], // Guyana
|
||||||
|
HK: [22.3193, 114.1694], // Hong Kong
|
||||||
|
HM: [-53.0818, 73.5042], // Heard Island and McDonald Islands
|
||||||
|
HN: [15.1999, -86.2419], // Honduras
|
||||||
|
HR: [45.1000, 15.2000], // Croatia
|
||||||
|
HT: [18.9712, -72.2852], // Haiti
|
||||||
|
HU: [47.1625, 19.5033], // Hungary
|
||||||
|
ID: [-0.7893, 113.9213], // Indonesia
|
||||||
|
IE: [53.4129, -8.2439], // Ireland
|
||||||
|
IL: [31.0461, 34.8516], // Israel
|
||||||
|
IM: [54.2361, -4.5481], // Isle of Man
|
||||||
|
IN: [20.5937, 78.9629], // India
|
||||||
|
IO: [-6.3432, 71.8765], // British Indian Ocean Territory
|
||||||
|
IQ: [33.2232, 43.6793], // Iraq
|
||||||
|
IR: [32.4279, 53.6880], // Iran
|
||||||
|
IS: [64.9631, -19.0208], // Iceland
|
||||||
|
IT: [41.8719, 12.5674], // Italy
|
||||||
|
JE: [49.2144, -2.1312], // Jersey
|
||||||
|
JM: [18.1096, -77.2975], // Jamaica
|
||||||
|
JO: [30.5852, 36.2384], // Jordan
|
||||||
|
JP: [36.2048, 138.2529], // Japan
|
||||||
|
KE: [-0.0236, 37.9062], // Kenya
|
||||||
|
KG: [41.2044, 74.7661], // Kyrgyzstan
|
||||||
|
KH: [12.5657, 104.9910], // Cambodia
|
||||||
|
KI: [-3.3704, -168.7340],// Kiribati
|
||||||
|
KM: [-11.8750, 43.8722], // Comoros
|
||||||
|
KN: [17.3578, -62.7830], // Saint Kitts and Nevis
|
||||||
|
KP: [40.3399, 127.5101], // North Korea
|
||||||
|
KR: [35.9078, 127.7669], // South Korea
|
||||||
|
KW: [29.3117, 47.4818], // Kuwait
|
||||||
|
KY: [19.3133, -81.2546], // Cayman Islands
|
||||||
|
KZ: [48.0196, 66.9237], // Kazakhstan
|
||||||
|
LA: [19.8563, 102.4955], // Laos
|
||||||
|
LB: [33.8547, 35.8623], // Lebanon
|
||||||
|
LC: [13.9094, -60.9789], // Saint Lucia
|
||||||
|
LI: [47.1660, 9.5554], // Liechtenstein
|
||||||
|
LK: [7.8731, 80.7718], // Sri Lanka
|
||||||
|
LR: [6.4281, -9.4295], // Liberia
|
||||||
|
LS: [-29.6100, 28.2336], // Lesotho
|
||||||
|
LT: [55.1694, 23.8813], // Lithuania
|
||||||
|
LU: [49.8153, 6.1296], // Luxembourg
|
||||||
|
LV: [56.8796, 24.6032], // Latvia
|
||||||
|
LY: [26.3351, 17.2283], // Libya
|
||||||
|
MA: [31.7917, -7.0926], // Morocco
|
||||||
|
MC: [43.7384, 7.4246], // Monaco
|
||||||
|
MD: [47.4116, 28.3699], // Moldova
|
||||||
|
ME: [42.7087, 19.3744], // Montenegro
|
||||||
|
MF: [18.0708, -63.0501], // Saint Martin
|
||||||
|
MG: [-18.7669, 46.8691], // Madagascar
|
||||||
|
MH: [7.1315, 171.1845], // Marshall Islands
|
||||||
|
MK: [41.6086, 21.7453], // North Macedonia
|
||||||
|
ML: [17.5707, -3.9962], // Mali
|
||||||
|
MM: [21.9162, 95.9560], // Myanmar
|
||||||
|
MN: [46.8625, 103.8467], // Mongolia
|
||||||
|
MO: [22.1987, 113.5439], // Macao
|
||||||
|
MP: [17.3308, 145.3847], // Northern Mariana Islands
|
||||||
|
MQ: [14.6415, -61.0242], // Martinique
|
||||||
|
MR: [21.0079, -10.9408], // Mauritania
|
||||||
|
MS: [16.7425, -62.1874], // Montserrat
|
||||||
|
MT: [35.9375, 14.3754], // Malta
|
||||||
|
MU: [-20.3484, 57.5522], // Mauritius
|
||||||
|
MV: [3.2028, 73.2207], // Maldives
|
||||||
|
MW: [-13.2543, 34.3015], // Malawi
|
||||||
|
MX: [23.6345, -102.5528],// Mexico
|
||||||
|
MY: [4.2105, 101.9758], // Malaysia
|
||||||
|
MZ: [-18.6657, 35.5296], // Mozambique
|
||||||
|
NA: [-22.9576, 18.4904], // Namibia
|
||||||
|
NC: [-20.9043, 165.6180],// New Caledonia
|
||||||
|
NE: [17.6078, 8.0817], // Niger
|
||||||
|
NF: [-29.0408, 167.9547],// Norfolk Island
|
||||||
|
NG: [9.0820, 8.6753], // Nigeria
|
||||||
|
NI: [12.8654, -85.2072], // Nicaragua
|
||||||
|
NL: [52.1326, 5.2913], // Netherlands
|
||||||
|
NO: [60.4720, 8.4689], // Norway
|
||||||
|
NP: [28.3949, 84.1240], // Nepal
|
||||||
|
NR: [-0.5228, 166.9315], // Nauru
|
||||||
|
NU: [-19.0544, -169.8672],// Niue
|
||||||
|
NZ: [-40.9006, 174.8860],// New Zealand
|
||||||
|
OM: [21.4735, 55.9754], // Oman
|
||||||
|
PA: [8.5380, -80.7821], // Panama
|
||||||
|
PE: [-9.1900, -75.0152], // Peru
|
||||||
|
PF: [-17.6797, -149.4068],// French Polynesia
|
||||||
|
PG: [-6.3150, 143.9555], // Papua New Guinea
|
||||||
|
PH: [12.8797, 121.7740], // Philippines
|
||||||
|
PK: [30.3753, 69.3451], // Pakistan
|
||||||
|
PL: [51.9194, 19.1451], // Poland
|
||||||
|
PM: [46.8852, -56.3159], // Saint Pierre and Miquelon
|
||||||
|
PN: [-24.7036, -127.4392],// Pitcairn
|
||||||
|
PR: [18.2208, -66.5901], // Puerto Rico
|
||||||
|
PS: [31.9522, 35.2332], // Palestinian Territory
|
||||||
|
PT: [39.3999, -8.2245], // Portugal
|
||||||
|
PW: [7.5150, 134.5825], // Palau
|
||||||
|
PY: [-23.4425, -58.4438],// Paraguay
|
||||||
|
QA: [25.3548, 51.1839], // Qatar
|
||||||
|
RE: [-21.1151, 55.5364], // Reunion
|
||||||
|
RO: [45.9432, 24.9668], // Romania
|
||||||
|
RS: [44.0165, 21.0059], // Serbia
|
||||||
|
RU: [61.5240, 105.3188], // Russia
|
||||||
|
RW: [-1.9403, 29.8739], // Rwanda
|
||||||
|
SA: [23.8859, 45.0792], // Saudi Arabia
|
||||||
|
SB: [-9.6457, 160.1562], // Solomon Islands
|
||||||
|
SC: [-4.6796, 55.4920], // Seychelles
|
||||||
|
SD: [12.8628, 30.2176], // Sudan
|
||||||
|
SE: [60.1282, 18.6435], // Sweden
|
||||||
|
SG: [1.3521, 103.8198], // Singapore
|
||||||
|
SH: [-24.1435, -10.0307],// Saint Helena
|
||||||
|
SI: [46.1512, 14.9955], // Slovenia
|
||||||
|
SJ: [77.5536, 23.6703], // Svalbard and Jan Mayen
|
||||||
|
SK: [48.6690, 19.6990], // Slovakia
|
||||||
|
SL: [8.4606, -11.7799], // Sierra Leone
|
||||||
|
SM: [43.9424, 12.4578], // San Marino
|
||||||
|
SN: [14.4974, -14.4524], // Senegal
|
||||||
|
SO: [5.1521, 46.1996], // Somalia
|
||||||
|
SR: [3.9193, -56.0278], // Suriname
|
||||||
|
SS: [4.8594, 31.5713], // South Sudan
|
||||||
|
ST: [0.1864, 6.6131], // Sao Tome and Principe
|
||||||
|
SV: [13.7942, -88.8965], // El Salvador
|
||||||
|
SX: [18.0425, -63.0548], // Sint Maarten
|
||||||
|
SY: [34.8021, 38.9968], // Syria
|
||||||
|
SZ: [-26.5225, 31.4659], // Eswatini (Swaziland)
|
||||||
|
TC: [21.6940, -71.7979], // Turks and Caicos Islands
|
||||||
|
TD: [15.4542, 18.7322], // Chad
|
||||||
|
TF: [-49.2804, 69.3486], // French Southern Territories
|
||||||
|
TG: [8.6195, 0.8248], // Togo
|
||||||
|
TH: [15.8700, 100.9925], // Thailand
|
||||||
|
TJ: [38.8610, 71.2761], // Tajikistan
|
||||||
|
TK: [-9.2000, -171.8484],// Tokelau
|
||||||
|
TL: [-8.8742, 125.7275], // Timor-Leste
|
||||||
|
TM: [38.9697, 59.5563], // Turkmenistan
|
||||||
|
TN: [33.8869, 9.5375], // Tunisia
|
||||||
|
TO: [-21.1790, -175.1982],// Tonga
|
||||||
|
TR: [38.9637, 35.2433], // Turkey
|
||||||
|
TT: [10.6918, -61.2225], // Trinidad and Tobago
|
||||||
|
TV: [-7.1095, 177.6493], // Tuvalu
|
||||||
|
TW: [23.6978, 120.9605], // Taiwan
|
||||||
|
TZ: [-6.3690, 34.8888], // Tanzania
|
||||||
|
UA: [48.3794, 31.1656], // Ukraine
|
||||||
|
UG: [1.3733, 32.2903], // Uganda
|
||||||
|
UM: [19.2823, 166.6470], // United States Minor Outlying Islands
|
||||||
|
US: [37.0902, -95.7129], // United States
|
||||||
|
UY: [-32.5228, -55.7658],// Uruguay
|
||||||
|
UZ: [41.3775, 64.5853], // Uzbekistan
|
||||||
|
VA: [41.9029, 12.4534], // Vatican City
|
||||||
|
VC: [12.9843, -61.2872], // Saint Vincent and the Grenadines
|
||||||
|
VE: [6.4238, -66.5897], // Venezuela
|
||||||
|
VG: [18.4207, -64.6400], // British Virgin Islands
|
||||||
|
VI: [18.3358, -64.8963], // U.S. Virgin Islands
|
||||||
|
VN: [14.0583, 108.2772], // Vietnam
|
||||||
|
VU: [-15.3767, 166.9592],// Vanuatu
|
||||||
|
WF: [-13.7687, -177.1561],// Wallis and Futuna
|
||||||
|
WS: [-13.7590, -172.1046],// Samoa
|
||||||
|
XK: [42.6026, 20.9030], // Kosovo
|
||||||
|
YE: [15.5527, 48.5164], // Yemen
|
||||||
|
YT: [-12.8275, 45.1662], // Mayotte
|
||||||
|
ZA: [-30.5595, 22.9375], // South Africa
|
||||||
|
ZM: [-13.1339, 27.8493], // Zambia
|
||||||
|
ZW: [-19.0154, 29.1549], // Zimbabwe
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the [latitude, longitude] centroid for a given ISO 3166-1 alpha-2
|
||||||
|
* country code, or null if the code is not recognised.
|
||||||
|
*
|
||||||
|
* The lookup is case-insensitive.
|
||||||
|
*/
|
||||||
|
export function countryToLatLng(code: string): [number, number] | null {
|
||||||
|
if (!code) return null;
|
||||||
|
const normalised = code.trim().toUpperCase();
|
||||||
|
return COUNTRY_CENTROIDS[normalised] ?? null;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,56 @@
|
||||||
|
export interface RadioPlace {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
country: string
|
||||||
|
lat: number
|
||||||
|
lng: number
|
||||||
|
size: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RadioChannel {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
placeTitle: string
|
||||||
|
country: string
|
||||||
|
website?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RadioSearchResult {
|
||||||
|
stations: Array<{
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
placeId: string
|
||||||
|
placeTitle: string
|
||||||
|
country: string
|
||||||
|
}>
|
||||||
|
places: Array<{
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
country: string
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPlaces(): Promise<RadioPlace[]> {
|
||||||
|
const res = await fetch('/api/radio/places')
|
||||||
|
if (!res.ok) throw new Error('Failed to load radio places')
|
||||||
|
return res.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getChannels(placeId: string): Promise<RadioChannel[]> {
|
||||||
|
const res = await fetch(`/api/radio/channels/${placeId}`)
|
||||||
|
if (!res.ok) throw new Error('Failed to load channels')
|
||||||
|
return res.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveStreamUrl(channelId: string): Promise<string> {
|
||||||
|
const res = await fetch(`/api/radio/stream/${channelId}`)
|
||||||
|
if (!res.ok) throw new Error('Failed to resolve stream')
|
||||||
|
const data = await res.json()
|
||||||
|
return data.url
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function searchRadio(q: string): Promise<RadioSearchResult> {
|
||||||
|
const res = await fetch(`/api/radio/search?q=${encodeURIComponent(q)}`)
|
||||||
|
if (!res.ok) throw new Error('Search failed')
|
||||||
|
return res.json()
|
||||||
|
}
|
||||||
|
|
@ -51,6 +51,7 @@
|
||||||
"react": "19.2.0",
|
"react": "19.2.0",
|
||||||
"react-day-picker": "9.8.0",
|
"react-day-picker": "9.8.0",
|
||||||
"react-dom": "19.2.0",
|
"react-dom": "19.2.0",
|
||||||
|
"react-globe.gl": "^2.37.1",
|
||||||
"react-hook-form": "^7.60.0",
|
"react-hook-form": "^7.60.0",
|
||||||
"react-resizable-panels": "^2.1.7",
|
"react-resizable-panels": "^2.1.7",
|
||||||
"recharts": "2.15.4",
|
"recharts": "2.15.4",
|
||||||
|
|
|
||||||
333
pnpm-lock.yaml
333
pnpm-lock.yaml
|
|
@ -134,6 +134,9 @@ importers:
|
||||||
react-dom:
|
react-dom:
|
||||||
specifier: 19.2.0
|
specifier: 19.2.0
|
||||||
version: 19.2.0(react@19.2.0)
|
version: 19.2.0(react@19.2.0)
|
||||||
|
react-globe.gl:
|
||||||
|
specifier: ^2.37.1
|
||||||
|
version: 2.37.1(react@19.2.0)
|
||||||
react-hook-form:
|
react-hook-form:
|
||||||
specifier: ^7.60.0
|
specifier: ^7.60.0
|
||||||
version: 7.69.0(react@19.2.0)
|
version: 7.69.0(react@19.2.0)
|
||||||
|
|
@ -1180,6 +1183,18 @@ packages:
|
||||||
'@tailwindcss/postcss@4.1.18':
|
'@tailwindcss/postcss@4.1.18':
|
||||||
resolution: {integrity: sha512-Ce0GFnzAOuPyfV5SxjXGn0CubwGcuDB0zcdaPuCSzAa/2vII24JTkH+I6jcbXLb1ctjZMZZI6OjDaLPJQL1S0g==}
|
resolution: {integrity: sha512-Ce0GFnzAOuPyfV5SxjXGn0CubwGcuDB0zcdaPuCSzAa/2vII24JTkH+I6jcbXLb1ctjZMZZI6OjDaLPJQL1S0g==}
|
||||||
|
|
||||||
|
'@turf/boolean-point-in-polygon@7.3.4':
|
||||||
|
resolution: {integrity: sha512-v/4hfyY90Vz9cDgs2GwjQf+Lft8o7mNCLJOTz/iv8SHAIgMMX0czEoIaNVOJr7tBqPqwin1CGwsncrkf5C9n8Q==}
|
||||||
|
|
||||||
|
'@turf/helpers@7.3.4':
|
||||||
|
resolution: {integrity: sha512-U/S5qyqgx3WTvg4twaH0WxF3EixoTCfDsmk98g1E3/5e2YKp7JKYZdz0vivsS5/UZLJeZDEElOSFH4pUgp+l7g==}
|
||||||
|
|
||||||
|
'@turf/invariant@7.3.4':
|
||||||
|
resolution: {integrity: sha512-88Eo4va4rce9sNZs6XiMJowWkikM3cS2TBhaCKlU+GFHdNf8PFEpiU42VDU8q5tOF6/fu21Rvlke5odgOGW4AQ==}
|
||||||
|
|
||||||
|
'@tweenjs/tween.js@25.0.0':
|
||||||
|
resolution: {integrity: sha512-XKLA6syeBUaPzx4j3qwMqzzq+V4uo72BnlbOjmuljLrRqdsd3qnzvZZoxvMHZ23ndsRS4aufU6JOZYpCbU6T1A==}
|
||||||
|
|
||||||
'@types/d3-array@3.2.2':
|
'@types/d3-array@3.2.2':
|
||||||
resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==}
|
resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==}
|
||||||
|
|
||||||
|
|
@ -1207,6 +1222,9 @@ packages:
|
||||||
'@types/d3-timer@3.0.2':
|
'@types/d3-timer@3.0.2':
|
||||||
resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==}
|
resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==}
|
||||||
|
|
||||||
|
'@types/geojson@7946.0.16':
|
||||||
|
resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==}
|
||||||
|
|
||||||
'@types/node@22.19.3':
|
'@types/node@22.19.3':
|
||||||
resolution: {integrity: sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA==}
|
resolution: {integrity: sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA==}
|
||||||
|
|
||||||
|
|
@ -1224,6 +1242,10 @@ packages:
|
||||||
'@types/ws@8.18.1':
|
'@types/ws@8.18.1':
|
||||||
resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==}
|
resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==}
|
||||||
|
|
||||||
|
accessor-fn@1.5.3:
|
||||||
|
resolution: {integrity: sha512-rkAofCwe/FvYFUlMB0v0gWmhqtfAtV1IUkdPbfhTUyYniu5LrC0A0UJkTH0Jv3S8SvwkmfuAlY+mQIJATdocMA==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
aria-hidden@1.2.6:
|
aria-hidden@1.2.6:
|
||||||
resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==}
|
resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
@ -1274,6 +1296,10 @@ packages:
|
||||||
resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==}
|
resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
d3-delaunay@6.0.4:
|
||||||
|
resolution: {integrity: sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
d3-ease@3.0.1:
|
d3-ease@3.0.1:
|
||||||
resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==}
|
resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
|
|
@ -1282,18 +1308,37 @@ packages:
|
||||||
resolution: {integrity: sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==}
|
resolution: {integrity: sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
d3-geo-voronoi@2.1.0:
|
||||||
|
resolution: {integrity: sha512-kqE4yYuOjPbKdBXG0xztCacPwkVSK2REF1opSNrnqqtXJmNcM++UbwQ8SxvwP6IQTj9RvIjjK4qeiVsEfj0Z2Q==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
d3-geo@3.1.1:
|
||||||
|
resolution: {integrity: sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
d3-interpolate@3.0.1:
|
d3-interpolate@3.0.1:
|
||||||
resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==}
|
resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
d3-octree@1.1.0:
|
||||||
|
resolution: {integrity: sha512-F8gPlqpP+HwRPMO/8uOu5wjH110+6q4cgJvgJT6vlpy3BEaDIKlTZrgHKZSp/i1InRpVfh4puY/kvL6MxK930A==}
|
||||||
|
|
||||||
d3-path@3.1.0:
|
d3-path@3.1.0:
|
||||||
resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==}
|
resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
d3-scale-chromatic@3.1.0:
|
||||||
|
resolution: {integrity: sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
d3-scale@4.0.2:
|
d3-scale@4.0.2:
|
||||||
resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==}
|
resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
d3-selection@3.0.0:
|
||||||
|
resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
d3-shape@3.2.0:
|
d3-shape@3.2.0:
|
||||||
resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==}
|
resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
|
|
@ -1310,6 +1355,14 @@ packages:
|
||||||
resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==}
|
resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
d3-tricontour@1.1.0:
|
||||||
|
resolution: {integrity: sha512-G7gHKj89n2owmkGb6WX6ixcnQ0Kf/0wpa9VIh9DGdbHu8wdrlaHU4ir3/bFNERl8N8nn4G7e7qbtBG8N9caihQ==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
data-bind-mapper@1.0.3:
|
||||||
|
resolution: {integrity: sha512-QmU3lyEnbENQPo0M1F9BMu4s6cqNNp8iJA+b/HP2sSb7pf3dxwF3+EP1eO69rwBfH9kFJ1apmzrtogAmVt2/Xw==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
date-fns-jalali@4.1.0-0:
|
date-fns-jalali@4.1.0-0:
|
||||||
resolution: {integrity: sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg==}
|
resolution: {integrity: sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg==}
|
||||||
|
|
||||||
|
|
@ -1319,6 +1372,9 @@ packages:
|
||||||
decimal.js-light@2.5.1:
|
decimal.js-light@2.5.1:
|
||||||
resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==}
|
resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==}
|
||||||
|
|
||||||
|
delaunator@5.1.0:
|
||||||
|
resolution: {integrity: sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==}
|
||||||
|
|
||||||
detect-libc@2.1.2:
|
detect-libc@2.1.2:
|
||||||
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
|
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
|
|
@ -1329,6 +1385,9 @@ packages:
|
||||||
dom-helpers@5.2.1:
|
dom-helpers@5.2.1:
|
||||||
resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==}
|
resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==}
|
||||||
|
|
||||||
|
earcut@3.0.2:
|
||||||
|
resolution: {integrity: sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==}
|
||||||
|
|
||||||
electron-to-chromium@1.5.267:
|
electron-to-chromium@1.5.267:
|
||||||
resolution: {integrity: sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==}
|
resolution: {integrity: sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==}
|
||||||
|
|
||||||
|
|
@ -1360,16 +1419,35 @@ packages:
|
||||||
resolution: {integrity: sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==}
|
resolution: {integrity: sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==}
|
||||||
engines: {node: '>=6.0.0'}
|
engines: {node: '>=6.0.0'}
|
||||||
|
|
||||||
|
float-tooltip@1.7.5:
|
||||||
|
resolution: {integrity: sha512-/kXzuDnnBqyyWyhDMH7+PfP8J/oXiAavGzcRxASOMRHFuReDtofizLLJsf7nnDLAfEaMW4pVWaXrAjtnglpEkg==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
fraction.js@5.3.4:
|
fraction.js@5.3.4:
|
||||||
resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==}
|
resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==}
|
||||||
|
|
||||||
|
frame-ticker@1.0.3:
|
||||||
|
resolution: {integrity: sha512-E0X2u2JIvbEMrqEg5+4BpTqaD22OwojJI63K7MdKHdncjtAhGRbCR8nJCr2vwEt9NWBPCPcu70X9smPviEBy8Q==}
|
||||||
|
|
||||||
get-nonce@1.0.1:
|
get-nonce@1.0.1:
|
||||||
resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==}
|
resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
|
globe.gl@2.45.3:
|
||||||
|
resolution: {integrity: sha512-oR7iZBjD60ltBcRlHFvlEzKGL8PMfk2Xl19vSfu1SN4SWXAs3NB3kfelanL+wj5XgG2rTQiNtPQncCR2O9oj+w==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
graceful-fs@4.2.11:
|
graceful-fs@4.2.11:
|
||||||
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
|
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
|
||||||
|
|
||||||
|
h3-js@4.4.0:
|
||||||
|
resolution: {integrity: sha512-DvJh07MhGgY2KcC4OeZc8SSyA+ZXpdvoh6uCzGpoKvWtZxJB+g6VXXC1+eWYkaMIsLz7J/ErhOalHCpcs1KYog==}
|
||||||
|
engines: {node: '>=4', npm: '>=3', yarn: '>=1.3.0'}
|
||||||
|
|
||||||
|
index-array-by@1.4.2:
|
||||||
|
resolution: {integrity: sha512-SP23P27OUKzXWEC/TOyWlwLviofQkCSCKONnc62eItjp69yCZZPqDQtr3Pw5gJDnPeUMqExmKydNZaJO0FU9pw==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
input-otp@1.4.1:
|
input-otp@1.4.1:
|
||||||
resolution: {integrity: sha512-+yvpmKYKHi9jIGngxagY9oWiiblPB7+nEO75F2l2o4vs+6vpPZZmUl4tBNYuTCvQjhvEIbdNeJu70bhfYP2nbw==}
|
resolution: {integrity: sha512-+yvpmKYKHi9jIGngxagY9oWiiblPB7+nEO75F2l2o4vs+6vpPZZmUl4tBNYuTCvQjhvEIbdNeJu70bhfYP2nbw==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
|
|
@ -1380,6 +1458,10 @@ packages:
|
||||||
resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==}
|
resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
jerrypick@1.1.2:
|
||||||
|
resolution: {integrity: sha512-YKnxXEekXKzhpf7CLYA0A+oDP8V0OhICNCr5lv96FvSsDEmrb0GKM776JgQvHTMjr7DTTPEVv/1Ciaw0uEWzBA==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
jiti@2.6.1:
|
jiti@2.6.1:
|
||||||
resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
|
resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
@ -1387,6 +1469,10 @@ packages:
|
||||||
js-tokens@4.0.0:
|
js-tokens@4.0.0:
|
||||||
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
|
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
|
||||||
|
|
||||||
|
kapsule@1.16.3:
|
||||||
|
resolution: {integrity: sha512-4+5mNNf4vZDSwPhKprKwz3330iisPrb08JyMgbsdFrimBCKNHecua/WBwvVg3n7vwx0C1ARjfhwIpbrbd9n5wg==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
lightningcss-android-arm64@1.30.2:
|
lightningcss-android-arm64@1.30.2:
|
||||||
resolution: {integrity: sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==}
|
resolution: {integrity: sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==}
|
||||||
engines: {node: '>= 12.0.0'}
|
engines: {node: '>= 12.0.0'}
|
||||||
|
|
@ -1457,6 +1543,9 @@ packages:
|
||||||
resolution: {integrity: sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==}
|
resolution: {integrity: sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==}
|
||||||
engines: {node: '>= 12.0.0'}
|
engines: {node: '>= 12.0.0'}
|
||||||
|
|
||||||
|
lodash-es@4.18.1:
|
||||||
|
resolution: {integrity: sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==}
|
||||||
|
|
||||||
lodash@4.17.21:
|
lodash@4.17.21:
|
||||||
resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==}
|
resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==}
|
||||||
|
|
||||||
|
|
@ -1518,6 +1607,13 @@ packages:
|
||||||
picocolors@1.1.1:
|
picocolors@1.1.1:
|
||||||
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
|
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
|
||||||
|
|
||||||
|
point-in-polygon-hao@1.2.4:
|
||||||
|
resolution: {integrity: sha512-x2pcvXeqhRHlNRdhLs/tgFapAbSSe86wa/eqmj1G6pWftbEs5aVRJhRGM6FYSUERKu0PjekJzMq0gsI2XyiclQ==}
|
||||||
|
|
||||||
|
polished@4.3.1:
|
||||||
|
resolution: {integrity: sha512-OBatVyC/N7SCW/FaDHrSd+vn0o5cS855TOmYi4OkdWUMSJCET/xip//ch8xGUvtr3i44X9LVyWwQlRMTN3pwSA==}
|
||||||
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
postcss-value-parser@4.2.0:
|
postcss-value-parser@4.2.0:
|
||||||
resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==}
|
resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==}
|
||||||
|
|
||||||
|
|
@ -1529,6 +1625,9 @@ packages:
|
||||||
resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==}
|
resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==}
|
||||||
engines: {node: ^10 || ^12 || >=14}
|
engines: {node: ^10 || ^12 || >=14}
|
||||||
|
|
||||||
|
preact@10.29.1:
|
||||||
|
resolution: {integrity: sha512-gQCLc/vWroE8lIpleXtdJhTFDogTdZG9AjMUpVkDf2iTCNwYNWA+u16dL41TqUDJO4gm2IgrcMv3uTpjd4Pwmg==}
|
||||||
|
|
||||||
prop-types@15.8.1:
|
prop-types@15.8.1:
|
||||||
resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
|
resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
|
||||||
|
|
||||||
|
|
@ -1543,6 +1642,12 @@ packages:
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
react: ^19.2.0
|
react: ^19.2.0
|
||||||
|
|
||||||
|
react-globe.gl@2.37.1:
|
||||||
|
resolution: {integrity: sha512-/YvnaB+ID+WTBTx5Qa67fGkGhco2jPlKg4jbXNTNWSl22/it7GrfByZ4nksgUDy4176VWuHh4BFvQg+hziSk8Q==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
peerDependencies:
|
||||||
|
react: '*'
|
||||||
|
|
||||||
react-hook-form@7.69.0:
|
react-hook-form@7.69.0:
|
||||||
resolution: {integrity: sha512-yt6ZGME9f4F6WHwevrvpAjh42HMvocuSnSIHUGycBqXIJdhqGSPQzTpGF+1NLREk/58IdPxEMfPcFCjlMhclGw==}
|
resolution: {integrity: sha512-yt6ZGME9f4F6WHwevrvpAjh42HMvocuSnSIHUGycBqXIJdhqGSPQzTpGF+1NLREk/58IdPxEMfPcFCjlMhclGw==}
|
||||||
engines: {node: '>=18.0.0'}
|
engines: {node: '>=18.0.0'}
|
||||||
|
|
@ -1555,6 +1660,12 @@ packages:
|
||||||
react-is@18.3.1:
|
react-is@18.3.1:
|
||||||
resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==}
|
resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==}
|
||||||
|
|
||||||
|
react-kapsule@2.5.7:
|
||||||
|
resolution: {integrity: sha512-kifAF4ZPD77qZKc4CKLmozq6GY1sBzPEJTIJb0wWFK6HsePJatK3jXplZn2eeAt3x67CDozgi7/rO8fNQ/AL7A==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
peerDependencies:
|
||||||
|
react: '>=16.13.1'
|
||||||
|
|
||||||
react-remove-scroll-bar@2.3.8:
|
react-remove-scroll-bar@2.3.8:
|
||||||
resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==}
|
resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
@ -1617,6 +1728,9 @@ packages:
|
||||||
react: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
react: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||||
react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||||
|
|
||||||
|
robust-predicates@3.0.3:
|
||||||
|
resolution: {integrity: sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==}
|
||||||
|
|
||||||
scheduler@0.27.0:
|
scheduler@0.27.0:
|
||||||
resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
|
resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
|
||||||
|
|
||||||
|
|
@ -1629,6 +1743,9 @@ packages:
|
||||||
resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==}
|
resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==}
|
||||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
|
|
||||||
|
simplesignal@2.1.7:
|
||||||
|
resolution: {integrity: sha512-PEo2qWpUke7IMhlqiBxrulIFvhJRLkl1ih52Rwa+bPjzhJepcd4GIjn2RiQmFSx3dQvsEAgF0/lXMwMN7vODaA==}
|
||||||
|
|
||||||
sonner@1.7.4:
|
sonner@1.7.4:
|
||||||
resolution: {integrity: sha512-DIS8z4PfJRbIyfVFDVnK9rO3eYDtse4Omcm6bt0oEr5/jtLgysmjuBl1frJ9E/EQZrFmKx2A8m/s5s9CRXIzhw==}
|
resolution: {integrity: sha512-DIS8z4PfJRbIyfVFDVnK9rO3eYDtse4Omcm6bt0oEr5/jtLgysmjuBl1frJ9E/EQZrFmKx2A8m/s5s9CRXIzhw==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
|
|
@ -1667,9 +1784,45 @@ packages:
|
||||||
resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==}
|
resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
|
three-conic-polygon-geometry@2.1.2:
|
||||||
|
resolution: {integrity: sha512-NaP3RWLJIyPGI+zyaZwd0Yj6rkoxm4FJHqAX1Enb4L64oNYLCn4bz1ESgOEYavgcUwCNYINu1AgEoUBJr1wZcA==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
peerDependencies:
|
||||||
|
three: '>=0.72.0'
|
||||||
|
|
||||||
|
three-geojson-geometry@2.1.1:
|
||||||
|
resolution: {integrity: sha512-dC7bF3ri1goDcihYhzACHOBQqu7YNNazYLa2bSydVIiJUb3jDFojKSy+gNj2pMkqZNSVjssSmdY9zlmnhEpr1w==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
peerDependencies:
|
||||||
|
three: '>=0.72.0'
|
||||||
|
|
||||||
|
three-globe@2.45.2:
|
||||||
|
resolution: {integrity: sha512-3qJE2LAdyHsUPt02mgMRc+PG3j9kGEA0fUYrwKPGIVtvMR1XjDn9hCXu31AWocdgHOFcXkrRVz7jJZzTIvR0eQ==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
peerDependencies:
|
||||||
|
three: '>=0.154'
|
||||||
|
|
||||||
|
three-render-objects@1.41.1:
|
||||||
|
resolution: {integrity: sha512-0H7l7yREPVKfO3HL7RjPQ67T0phHgnyMeEc4ww/OCEfK6jbsm7psEcrR0SGFqGDyS/pDQTPi4DyPbS/xlHRJKw==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
peerDependencies:
|
||||||
|
three: '>=0.179'
|
||||||
|
|
||||||
|
three-slippy-map-globe@1.0.6:
|
||||||
|
resolution: {integrity: sha512-PCUR+X+1kYFYtQBf8+b/ct8xBHtnkeu7FItRYBeFxyIe3ksnGuLi0H9RAxAfVSSUsZVbKIKNz9q1atEjynrrkg==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
peerDependencies:
|
||||||
|
three: '>=0.154'
|
||||||
|
|
||||||
|
three@0.183.2:
|
||||||
|
resolution: {integrity: sha512-di3BsL2FEQ1PA7Hcvn4fyJOlxRRgFYBpMTcyOgkwJIaDOdJMebEFPA+t98EvjuljDx4hNulAGwF6KIjtwI5jgQ==}
|
||||||
|
|
||||||
tiny-invariant@1.3.3:
|
tiny-invariant@1.3.3:
|
||||||
resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
|
resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
|
||||||
|
|
||||||
|
tinycolor2@1.6.0:
|
||||||
|
resolution: {integrity: sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==}
|
||||||
|
|
||||||
tslib@2.8.1:
|
tslib@2.8.1:
|
||||||
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
|
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
|
||||||
|
|
||||||
|
|
@ -2680,6 +2833,27 @@ snapshots:
|
||||||
postcss: 8.5.6
|
postcss: 8.5.6
|
||||||
tailwindcss: 4.1.18
|
tailwindcss: 4.1.18
|
||||||
|
|
||||||
|
'@turf/boolean-point-in-polygon@7.3.4':
|
||||||
|
dependencies:
|
||||||
|
'@turf/helpers': 7.3.4
|
||||||
|
'@turf/invariant': 7.3.4
|
||||||
|
'@types/geojson': 7946.0.16
|
||||||
|
point-in-polygon-hao: 1.2.4
|
||||||
|
tslib: 2.8.1
|
||||||
|
|
||||||
|
'@turf/helpers@7.3.4':
|
||||||
|
dependencies:
|
||||||
|
'@types/geojson': 7946.0.16
|
||||||
|
tslib: 2.8.1
|
||||||
|
|
||||||
|
'@turf/invariant@7.3.4':
|
||||||
|
dependencies:
|
||||||
|
'@turf/helpers': 7.3.4
|
||||||
|
'@types/geojson': 7946.0.16
|
||||||
|
tslib: 2.8.1
|
||||||
|
|
||||||
|
'@tweenjs/tween.js@25.0.0': {}
|
||||||
|
|
||||||
'@types/d3-array@3.2.2': {}
|
'@types/d3-array@3.2.2': {}
|
||||||
|
|
||||||
'@types/d3-color@3.1.3': {}
|
'@types/d3-color@3.1.3': {}
|
||||||
|
|
@ -2704,6 +2878,8 @@ snapshots:
|
||||||
|
|
||||||
'@types/d3-timer@3.0.2': {}
|
'@types/d3-timer@3.0.2': {}
|
||||||
|
|
||||||
|
'@types/geojson@7946.0.16': {}
|
||||||
|
|
||||||
'@types/node@22.19.3':
|
'@types/node@22.19.3':
|
||||||
dependencies:
|
dependencies:
|
||||||
undici-types: 6.21.0
|
undici-types: 6.21.0
|
||||||
|
|
@ -2724,6 +2900,8 @@ snapshots:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/node': 22.19.3
|
'@types/node': 22.19.3
|
||||||
|
|
||||||
|
accessor-fn@1.5.3: {}
|
||||||
|
|
||||||
aria-hidden@1.2.6:
|
aria-hidden@1.2.6:
|
||||||
dependencies:
|
dependencies:
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
|
|
@ -2777,16 +2955,38 @@ snapshots:
|
||||||
|
|
||||||
d3-color@3.1.0: {}
|
d3-color@3.1.0: {}
|
||||||
|
|
||||||
|
d3-delaunay@6.0.4:
|
||||||
|
dependencies:
|
||||||
|
delaunator: 5.1.0
|
||||||
|
|
||||||
d3-ease@3.0.1: {}
|
d3-ease@3.0.1: {}
|
||||||
|
|
||||||
d3-format@3.1.0: {}
|
d3-format@3.1.0: {}
|
||||||
|
|
||||||
|
d3-geo-voronoi@2.1.0:
|
||||||
|
dependencies:
|
||||||
|
d3-array: 3.2.4
|
||||||
|
d3-delaunay: 6.0.4
|
||||||
|
d3-geo: 3.1.1
|
||||||
|
d3-tricontour: 1.1.0
|
||||||
|
|
||||||
|
d3-geo@3.1.1:
|
||||||
|
dependencies:
|
||||||
|
d3-array: 3.2.4
|
||||||
|
|
||||||
d3-interpolate@3.0.1:
|
d3-interpolate@3.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
d3-color: 3.1.0
|
d3-color: 3.1.0
|
||||||
|
|
||||||
|
d3-octree@1.1.0: {}
|
||||||
|
|
||||||
d3-path@3.1.0: {}
|
d3-path@3.1.0: {}
|
||||||
|
|
||||||
|
d3-scale-chromatic@3.1.0:
|
||||||
|
dependencies:
|
||||||
|
d3-color: 3.1.0
|
||||||
|
d3-interpolate: 3.0.1
|
||||||
|
|
||||||
d3-scale@4.0.2:
|
d3-scale@4.0.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
d3-array: 3.2.4
|
d3-array: 3.2.4
|
||||||
|
|
@ -2795,6 +2995,8 @@ snapshots:
|
||||||
d3-time: 3.1.0
|
d3-time: 3.1.0
|
||||||
d3-time-format: 4.1.0
|
d3-time-format: 4.1.0
|
||||||
|
|
||||||
|
d3-selection@3.0.0: {}
|
||||||
|
|
||||||
d3-shape@3.2.0:
|
d3-shape@3.2.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
d3-path: 3.1.0
|
d3-path: 3.1.0
|
||||||
|
|
@ -2809,12 +3011,25 @@ snapshots:
|
||||||
|
|
||||||
d3-timer@3.0.1: {}
|
d3-timer@3.0.1: {}
|
||||||
|
|
||||||
|
d3-tricontour@1.1.0:
|
||||||
|
dependencies:
|
||||||
|
d3-delaunay: 6.0.4
|
||||||
|
d3-scale: 4.0.2
|
||||||
|
|
||||||
|
data-bind-mapper@1.0.3:
|
||||||
|
dependencies:
|
||||||
|
accessor-fn: 1.5.3
|
||||||
|
|
||||||
date-fns-jalali@4.1.0-0: {}
|
date-fns-jalali@4.1.0-0: {}
|
||||||
|
|
||||||
date-fns@4.1.0: {}
|
date-fns@4.1.0: {}
|
||||||
|
|
||||||
decimal.js-light@2.5.1: {}
|
decimal.js-light@2.5.1: {}
|
||||||
|
|
||||||
|
delaunator@5.1.0:
|
||||||
|
dependencies:
|
||||||
|
robust-predicates: 3.0.3
|
||||||
|
|
||||||
detect-libc@2.1.2: {}
|
detect-libc@2.1.2: {}
|
||||||
|
|
||||||
detect-node-es@1.1.0: {}
|
detect-node-es@1.1.0: {}
|
||||||
|
|
@ -2824,6 +3039,8 @@ snapshots:
|
||||||
'@babel/runtime': 7.28.4
|
'@babel/runtime': 7.28.4
|
||||||
csstype: 3.2.3
|
csstype: 3.2.3
|
||||||
|
|
||||||
|
earcut@3.0.2: {}
|
||||||
|
|
||||||
electron-to-chromium@1.5.267: {}
|
electron-to-chromium@1.5.267: {}
|
||||||
|
|
||||||
embla-carousel-react@8.5.1(react@19.2.0):
|
embla-carousel-react@8.5.1(react@19.2.0):
|
||||||
|
|
@ -2849,12 +3066,35 @@ snapshots:
|
||||||
|
|
||||||
fast-equals@5.4.0: {}
|
fast-equals@5.4.0: {}
|
||||||
|
|
||||||
|
float-tooltip@1.7.5:
|
||||||
|
dependencies:
|
||||||
|
d3-selection: 3.0.0
|
||||||
|
kapsule: 1.16.3
|
||||||
|
preact: 10.29.1
|
||||||
|
|
||||||
fraction.js@5.3.4: {}
|
fraction.js@5.3.4: {}
|
||||||
|
|
||||||
|
frame-ticker@1.0.3:
|
||||||
|
dependencies:
|
||||||
|
simplesignal: 2.1.7
|
||||||
|
|
||||||
get-nonce@1.0.1: {}
|
get-nonce@1.0.1: {}
|
||||||
|
|
||||||
|
globe.gl@2.45.3:
|
||||||
|
dependencies:
|
||||||
|
'@tweenjs/tween.js': 25.0.0
|
||||||
|
accessor-fn: 1.5.3
|
||||||
|
kapsule: 1.16.3
|
||||||
|
three: 0.183.2
|
||||||
|
three-globe: 2.45.2(three@0.183.2)
|
||||||
|
three-render-objects: 1.41.1(three@0.183.2)
|
||||||
|
|
||||||
graceful-fs@4.2.11: {}
|
graceful-fs@4.2.11: {}
|
||||||
|
|
||||||
|
h3-js@4.4.0: {}
|
||||||
|
|
||||||
|
index-array-by@1.4.2: {}
|
||||||
|
|
||||||
input-otp@1.4.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0):
|
input-otp@1.4.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0):
|
||||||
dependencies:
|
dependencies:
|
||||||
react: 19.2.0
|
react: 19.2.0
|
||||||
|
|
@ -2862,10 +3102,16 @@ snapshots:
|
||||||
|
|
||||||
internmap@2.0.3: {}
|
internmap@2.0.3: {}
|
||||||
|
|
||||||
|
jerrypick@1.1.2: {}
|
||||||
|
|
||||||
jiti@2.6.1: {}
|
jiti@2.6.1: {}
|
||||||
|
|
||||||
js-tokens@4.0.0: {}
|
js-tokens@4.0.0: {}
|
||||||
|
|
||||||
|
kapsule@1.16.3:
|
||||||
|
dependencies:
|
||||||
|
lodash-es: 4.18.1
|
||||||
|
|
||||||
lightningcss-android-arm64@1.30.2:
|
lightningcss-android-arm64@1.30.2:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
|
@ -2915,6 +3161,8 @@ snapshots:
|
||||||
lightningcss-win32-arm64-msvc: 1.30.2
|
lightningcss-win32-arm64-msvc: 1.30.2
|
||||||
lightningcss-win32-x64-msvc: 1.30.2
|
lightningcss-win32-x64-msvc: 1.30.2
|
||||||
|
|
||||||
|
lodash-es@4.18.1: {}
|
||||||
|
|
||||||
lodash@4.17.21: {}
|
lodash@4.17.21: {}
|
||||||
|
|
||||||
loose-envify@1.4.0:
|
loose-envify@1.4.0:
|
||||||
|
|
@ -2967,6 +3215,14 @@ snapshots:
|
||||||
|
|
||||||
picocolors@1.1.1: {}
|
picocolors@1.1.1: {}
|
||||||
|
|
||||||
|
point-in-polygon-hao@1.2.4:
|
||||||
|
dependencies:
|
||||||
|
robust-predicates: 3.0.3
|
||||||
|
|
||||||
|
polished@4.3.1:
|
||||||
|
dependencies:
|
||||||
|
'@babel/runtime': 7.28.4
|
||||||
|
|
||||||
postcss-value-parser@4.2.0: {}
|
postcss-value-parser@4.2.0: {}
|
||||||
|
|
||||||
postcss@8.4.31:
|
postcss@8.4.31:
|
||||||
|
|
@ -2981,6 +3237,8 @@ snapshots:
|
||||||
picocolors: 1.1.1
|
picocolors: 1.1.1
|
||||||
source-map-js: 1.2.1
|
source-map-js: 1.2.1
|
||||||
|
|
||||||
|
preact@10.29.1: {}
|
||||||
|
|
||||||
prop-types@15.8.1:
|
prop-types@15.8.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
loose-envify: 1.4.0
|
loose-envify: 1.4.0
|
||||||
|
|
@ -2999,6 +3257,13 @@ snapshots:
|
||||||
react: 19.2.0
|
react: 19.2.0
|
||||||
scheduler: 0.27.0
|
scheduler: 0.27.0
|
||||||
|
|
||||||
|
react-globe.gl@2.37.1(react@19.2.0):
|
||||||
|
dependencies:
|
||||||
|
globe.gl: 2.45.3
|
||||||
|
prop-types: 15.8.1
|
||||||
|
react: 19.2.0
|
||||||
|
react-kapsule: 2.5.7(react@19.2.0)
|
||||||
|
|
||||||
react-hook-form@7.69.0(react@19.2.0):
|
react-hook-form@7.69.0(react@19.2.0):
|
||||||
dependencies:
|
dependencies:
|
||||||
react: 19.2.0
|
react: 19.2.0
|
||||||
|
|
@ -3007,6 +3272,11 @@ snapshots:
|
||||||
|
|
||||||
react-is@18.3.1: {}
|
react-is@18.3.1: {}
|
||||||
|
|
||||||
|
react-kapsule@2.5.7(react@19.2.0):
|
||||||
|
dependencies:
|
||||||
|
jerrypick: 1.1.2
|
||||||
|
react: 19.2.0
|
||||||
|
|
||||||
react-remove-scroll-bar@2.3.8(@types/react@19.2.7)(react@19.2.0):
|
react-remove-scroll-bar@2.3.8(@types/react@19.2.7)(react@19.2.0):
|
||||||
dependencies:
|
dependencies:
|
||||||
react: 19.2.0
|
react: 19.2.0
|
||||||
|
|
@ -3075,6 +3345,8 @@ snapshots:
|
||||||
tiny-invariant: 1.3.3
|
tiny-invariant: 1.3.3
|
||||||
victory-vendor: 36.9.2
|
victory-vendor: 36.9.2
|
||||||
|
|
||||||
|
robust-predicates@3.0.3: {}
|
||||||
|
|
||||||
scheduler@0.27.0: {}
|
scheduler@0.27.0: {}
|
||||||
|
|
||||||
semver@7.7.3:
|
semver@7.7.3:
|
||||||
|
|
@ -3112,6 +3384,8 @@ snapshots:
|
||||||
'@img/sharp-win32-x64': 0.34.5
|
'@img/sharp-win32-x64': 0.34.5
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
simplesignal@2.1.7: {}
|
||||||
|
|
||||||
sonner@1.7.4(react-dom@19.2.0(react@19.2.0))(react@19.2.0):
|
sonner@1.7.4(react-dom@19.2.0(react@19.2.0))(react@19.2.0):
|
||||||
dependencies:
|
dependencies:
|
||||||
react: 19.2.0
|
react: 19.2.0
|
||||||
|
|
@ -3134,8 +3408,67 @@ snapshots:
|
||||||
|
|
||||||
tapable@2.3.0: {}
|
tapable@2.3.0: {}
|
||||||
|
|
||||||
|
three-conic-polygon-geometry@2.1.2(three@0.183.2):
|
||||||
|
dependencies:
|
||||||
|
'@turf/boolean-point-in-polygon': 7.3.4
|
||||||
|
d3-array: 3.2.4
|
||||||
|
d3-geo: 3.1.1
|
||||||
|
d3-geo-voronoi: 2.1.0
|
||||||
|
d3-scale: 4.0.2
|
||||||
|
delaunator: 5.1.0
|
||||||
|
earcut: 3.0.2
|
||||||
|
three: 0.183.2
|
||||||
|
|
||||||
|
three-geojson-geometry@2.1.1(three@0.183.2):
|
||||||
|
dependencies:
|
||||||
|
d3-geo: 3.1.1
|
||||||
|
d3-interpolate: 3.0.1
|
||||||
|
earcut: 3.0.2
|
||||||
|
three: 0.183.2
|
||||||
|
|
||||||
|
three-globe@2.45.2(three@0.183.2):
|
||||||
|
dependencies:
|
||||||
|
'@tweenjs/tween.js': 25.0.0
|
||||||
|
accessor-fn: 1.5.3
|
||||||
|
d3-array: 3.2.4
|
||||||
|
d3-color: 3.1.0
|
||||||
|
d3-geo: 3.1.1
|
||||||
|
d3-interpolate: 3.0.1
|
||||||
|
d3-scale: 4.0.2
|
||||||
|
d3-scale-chromatic: 3.1.0
|
||||||
|
data-bind-mapper: 1.0.3
|
||||||
|
frame-ticker: 1.0.3
|
||||||
|
h3-js: 4.4.0
|
||||||
|
index-array-by: 1.4.2
|
||||||
|
kapsule: 1.16.3
|
||||||
|
three: 0.183.2
|
||||||
|
three-conic-polygon-geometry: 2.1.2(three@0.183.2)
|
||||||
|
three-geojson-geometry: 2.1.1(three@0.183.2)
|
||||||
|
three-slippy-map-globe: 1.0.6(three@0.183.2)
|
||||||
|
tinycolor2: 1.6.0
|
||||||
|
|
||||||
|
three-render-objects@1.41.1(three@0.183.2):
|
||||||
|
dependencies:
|
||||||
|
'@tweenjs/tween.js': 25.0.0
|
||||||
|
accessor-fn: 1.5.3
|
||||||
|
float-tooltip: 1.7.5
|
||||||
|
kapsule: 1.16.3
|
||||||
|
polished: 4.3.1
|
||||||
|
three: 0.183.2
|
||||||
|
|
||||||
|
three-slippy-map-globe@1.0.6(three@0.183.2):
|
||||||
|
dependencies:
|
||||||
|
d3-geo: 3.1.1
|
||||||
|
d3-octree: 1.1.0
|
||||||
|
d3-scale: 4.0.2
|
||||||
|
three: 0.183.2
|
||||||
|
|
||||||
|
three@0.183.2: {}
|
||||||
|
|
||||||
tiny-invariant@1.3.3: {}
|
tiny-invariant@1.3.3: {}
|
||||||
|
|
||||||
|
tinycolor2@1.6.0: {}
|
||||||
|
|
||||||
tslib@2.8.1: {}
|
tslib@2.8.1: {}
|
||||||
|
|
||||||
tw-animate-css@1.3.3: {}
|
tw-animate-css@1.3.3: {}
|
||||||
|
|
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 1.4 MiB |
Loading…
Reference in New Issue