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]
|
||||
|
||||
env:
|
||||
REGISTRY: gitea.jeffemmett.com
|
||||
IMAGE: gitea.jeffemmett.com/jeffemmett/jefflix-website
|
||||
REGISTRY: localhost:3000
|
||||
IMAGE: localhost:3000/jeffemmett/jefflix-website
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
|
|
@ -52,9 +52,12 @@ jobs:
|
|||
- name: Smoke test
|
||||
run: |
|
||||
sleep 15
|
||||
HTTP_CODE=$(curl -sSL -o /dev/null -w "%{http_code}" --max-time 30 https://jefflix.lol/ 2>/dev/null || echo "000")
|
||||
if [ "$HTTP_CODE" -lt 200 ] || [ "$HTTP_CODE" -ge 400 ]; then
|
||||
echo "Smoke test failed (HTTP $HTTP_CODE) — rolling back"
|
||||
STATUS=$(ssh -o StrictHostKeyChecking=no -i ~/.ssh/deploy_key root@${{ secrets.DEPLOY_HOST }} \
|
||||
"cd /opt/websites/jefflix-website && docker compose ps --format '{{{{.Status}}}}' 2>/dev/null | head -1 || echo 'unknown'")
|
||||
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")
|
||||
if [ -n "$ROLLBACK_TAG" ]; then
|
||||
ssh -o StrictHostKeyChecking=no -i ~/.ssh/deploy_key root@${{ secrets.DEPLOY_HOST }} \
|
||||
|
|
@ -63,4 +66,3 @@ jobs:
|
|||
fi
|
||||
exit 1
|
||||
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 { 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"
|
||||
|
||||
export default function JefflixPage() {
|
||||
|
|
@ -113,6 +113,17 @@ export default function JefflixPage() {
|
|||
Listen to Music
|
||||
</a>
|
||||
</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>
|
||||
|
|
@ -285,6 +296,12 @@ export default function JefflixPage() {
|
|||
Listen to Music
|
||||
</a>
|
||||
</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>
|
||||
<p className="text-sm text-muted-foreground pt-4">
|
||||
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'
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import { useState, useEffect, useRef, useCallback, useMemo } from 'react'
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
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'
|
||||
|
||||
interface Channel {
|
||||
|
|
@ -26,6 +28,45 @@ export default function RequestChannelPage() {
|
|||
const [errorMessage, setErrorMessage] = useState('')
|
||||
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
|
||||
useEffect(() => {
|
||||
fetch('/api/channels')
|
||||
|
|
@ -151,6 +192,30 @@ export default function RequestChannelPage() {
|
|||
<p className="text-muted-foreground">
|
||||
Search the iptv-org catalog and select channels you'd like us to add to the Live TV lineup.
|
||||
</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>
|
||||
|
||||
{loading ? (
|
||||
|
|
@ -166,6 +231,113 @@ export default function RequestChannelPage() {
|
|||
Try Again
|
||||
</Button>
|
||||
</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">
|
||||
{/* 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 { useRouter } from 'next/navigation'
|
||||
import { Drawer } from 'vaul'
|
||||
import { useMusicPlayer } from './music-provider'
|
||||
import { useMusicPlayer, isRadioTrack } from './music-provider'
|
||||
import { DownloadButton } from './download-button'
|
||||
import { PlaylistPicker } from './playlist-picker'
|
||||
import { SyncedLyrics } from './synced-lyrics'
|
||||
|
|
@ -23,6 +23,7 @@ import {
|
|||
ChevronDown,
|
||||
Speaker,
|
||||
Shuffle,
|
||||
Radio,
|
||||
} from 'lucide-react'
|
||||
|
||||
function formatTime(secs: number) {
|
||||
|
|
@ -43,9 +44,16 @@ export function FullScreenPlayer() {
|
|||
|
||||
const track = state.currentTrack
|
||||
|
||||
// Fetch lyrics when track changes
|
||||
const isRadio = track ? isRadioTrack(track) : false
|
||||
|
||||
// Fetch lyrics when track changes (skip for radio)
|
||||
useEffect(() => {
|
||||
if (!track) return
|
||||
if (!track || isRadioTrack(track)) {
|
||||
setLyrics(null)
|
||||
setSyncedLyrics(null)
|
||||
setLoadingLyrics(false)
|
||||
return
|
||||
}
|
||||
setLyrics(null)
|
||||
setSyncedLyrics(null)
|
||||
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">
|
||||
<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">
|
||||
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>
|
||||
|
||||
{track && (
|
||||
|
|
@ -104,7 +112,7 @@ export function FullScreenPlayer() {
|
|||
/>
|
||||
) : (
|
||||
<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>
|
||||
|
|
@ -126,6 +134,17 @@ export function FullScreenPlayer() {
|
|||
|
||||
{/* Progress */}
|
||||
<div className="w-full max-w-sm mb-4">
|
||||
{isRadio ? (
|
||||
<>
|
||||
<div className="h-2 w-full bg-rose-500/20 rounded-full overflow-hidden mb-2">
|
||||
<div className="h-full w-full bg-rose-500 animate-pulse" />
|
||||
</div>
|
||||
<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>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Slider
|
||||
value={[state.progress]}
|
||||
max={state.duration || 1}
|
||||
|
|
@ -137,10 +156,13 @@ export function FullScreenPlayer() {
|
|||
<span>{formatTime(state.progress)}</span>
|
||||
<span>{formatTime(state.duration)}</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<div className="flex items-center gap-6 mb-6">
|
||||
{!isRadio && (
|
||||
<button
|
||||
onClick={toggleShuffle}
|
||||
className={`p-2 rounded-full transition-colors ${state.shuffleEnabled ? 'text-purple-400' : 'hover:bg-muted/50'}`}
|
||||
|
|
@ -148,6 +170,7 @@ export function FullScreenPlayer() {
|
|||
>
|
||||
<Shuffle className="h-5 w-5" />
|
||||
</button>
|
||||
)}
|
||||
<button onClick={prevTrack} className="p-2 hover:bg-muted/50 rounded-full transition-colors">
|
||||
<SkipBack className="h-6 w-6" />
|
||||
</button>
|
||||
|
|
@ -199,19 +222,22 @@ export function FullScreenPlayer() {
|
|||
<ListMusic className="h-4 w-4 mr-1.5" />
|
||||
Queue
|
||||
</Button>
|
||||
<DownloadButton track={track} size="md" />
|
||||
{!isRadio && <DownloadButton track={track} size="md" />}
|
||||
{!isRadio && (
|
||||
<Button variant="outline" size="sm" onClick={() => setPlaylistOpen(true)}>
|
||||
<ListPlus className="h-4 w-4 mr-1.5" />
|
||||
Add to Playlist
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="outline" size="sm" onClick={handleShare}>
|
||||
<Share2 className="h-4 w-4 mr-1.5" />
|
||||
Share
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Lyrics */}
|
||||
{loadingLyrics ? (
|
||||
{/* Lyrics (hidden for radio) */}
|
||||
{!isRadio && (
|
||||
loadingLyrics ? (
|
||||
<p className="text-sm text-muted-foreground animate-pulse">Loading lyrics...</p>
|
||||
) : syncedLyrics ? (
|
||||
<SyncedLyrics
|
||||
|
|
@ -226,7 +252,8 @@ export function FullScreenPlayer() {
|
|||
{lyrics}
|
||||
</pre>
|
||||
</div>
|
||||
) : null}
|
||||
) : null
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Drawer.Content>
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useMusicPlayer } from './music-provider'
|
||||
import { useMusicPlayer, isRadioTrack } from './music-provider'
|
||||
import { FullScreenPlayer } from './full-screen-player'
|
||||
import { QueueView } from './queue-view'
|
||||
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) {
|
||||
if (!secs || !isFinite(secs)) return '0:00'
|
||||
|
|
@ -21,12 +21,18 @@ export function MiniPlayer() {
|
|||
if (!state.currentTrack) return null
|
||||
|
||||
const track = state.currentTrack
|
||||
const isRadio = isRadioTrack(track)
|
||||
|
||||
return (
|
||||
<>
|
||||
<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">
|
||||
{isRadio ? (
|
||||
<div className="h-1 w-full bg-rose-500/40 rounded-full overflow-hidden">
|
||||
<div className="h-full w-full bg-rose-500 animate-pulse" />
|
||||
</div>
|
||||
) : (
|
||||
<Slider
|
||||
value={[state.progress]}
|
||||
max={state.duration || 1}
|
||||
|
|
@ -34,6 +40,7 @@ export function MiniPlayer() {
|
|||
onValueChange={([v]) => seek(v)}
|
||||
className="h-1 [&_[data-slot=slider]]:h-1 [&_span[data-slot=scroll-bar]]:hidden"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<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"
|
||||
/>
|
||||
) : (
|
||||
<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>
|
||||
|
||||
|
|
@ -62,13 +71,20 @@ export function MiniPlayer() {
|
|||
<div className="text-xs text-muted-foreground truncate">{track.artist}</div>
|
||||
</button>
|
||||
|
||||
{/* Time */}
|
||||
{/* Time / LIVE badge */}
|
||||
{isRadio ? (
|
||||
<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">
|
||||
LIVE
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground tabular-nums hidden sm:block">
|
||||
{formatTime(state.progress)} / {formatTime(state.duration)}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Controls */}
|
||||
<div className="flex items-center gap-1">
|
||||
{!isRadio && (
|
||||
<button
|
||||
onClick={toggleShuffle}
|
||||
className={`p-1.5 rounded-full transition-colors ${state.shuffleEnabled ? 'text-purple-400' : 'text-muted-foreground hover:bg-muted/50'}`}
|
||||
|
|
@ -76,6 +92,7 @@ export function MiniPlayer() {
|
|||
>
|
||||
<Shuffle className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={prevTrack}
|
||||
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 { precacheUpcoming } from '@/lib/precache'
|
||||
|
||||
export interface Track {
|
||||
interface TrackBase {
|
||||
id: string
|
||||
title: string
|
||||
artist: string
|
||||
|
|
@ -14,6 +14,21 @@ export interface Track {
|
|||
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 {
|
||||
currentTrack: Track | null
|
||||
queue: Track[]
|
||||
|
|
@ -267,7 +282,15 @@ export function MusicProvider({ children }: { children: React.ReactNode }) {
|
|||
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) => {
|
||||
// Guard: track may have changed while we awaited
|
||||
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' }))
|
||||
}, [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(() => {
|
||||
if (state.queueIndex < 0 || state.queue.length <= state.queueIndex + 1) return
|
||||
if (state.currentTrack && isRadioTrack(state.currentTrack)) return
|
||||
|
||||
const ac = new AbortController()
|
||||
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-day-picker": "9.8.0",
|
||||
"react-dom": "19.2.0",
|
||||
"react-globe.gl": "^2.37.1",
|
||||
"react-hook-form": "^7.60.0",
|
||||
"react-resizable-panels": "^2.1.7",
|
||||
"recharts": "2.15.4",
|
||||
|
|
|
|||
333
pnpm-lock.yaml
333
pnpm-lock.yaml
|
|
@ -134,6 +134,9 @@ importers:
|
|||
react-dom:
|
||||
specifier: 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:
|
||||
specifier: ^7.60.0
|
||||
version: 7.69.0(react@19.2.0)
|
||||
|
|
@ -1180,6 +1183,18 @@ packages:
|
|||
'@tailwindcss/postcss@4.1.18':
|
||||
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':
|
||||
resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==}
|
||||
|
||||
|
|
@ -1207,6 +1222,9 @@ packages:
|
|||
'@types/d3-timer@3.0.2':
|
||||
resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==}
|
||||
|
||||
'@types/geojson@7946.0.16':
|
||||
resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==}
|
||||
|
||||
'@types/node@22.19.3':
|
||||
resolution: {integrity: sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA==}
|
||||
|
||||
|
|
@ -1224,6 +1242,10 @@ packages:
|
|||
'@types/ws@8.18.1':
|
||||
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:
|
||||
resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==}
|
||||
engines: {node: '>=10'}
|
||||
|
|
@ -1274,6 +1296,10 @@ packages:
|
|||
resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-delaunay@6.0.4:
|
||||
resolution: {integrity: sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-ease@3.0.1:
|
||||
resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==}
|
||||
engines: {node: '>=12'}
|
||||
|
|
@ -1282,18 +1308,37 @@ packages:
|
|||
resolution: {integrity: sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==}
|
||||
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:
|
||||
resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-octree@1.1.0:
|
||||
resolution: {integrity: sha512-F8gPlqpP+HwRPMO/8uOu5wjH110+6q4cgJvgJT6vlpy3BEaDIKlTZrgHKZSp/i1InRpVfh4puY/kvL6MxK930A==}
|
||||
|
||||
d3-path@3.1.0:
|
||||
resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-scale-chromatic@3.1.0:
|
||||
resolution: {integrity: sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-scale@4.0.2:
|
||||
resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-selection@3.0.0:
|
||||
resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-shape@3.2.0:
|
||||
resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==}
|
||||
engines: {node: '>=12'}
|
||||
|
|
@ -1310,6 +1355,14 @@ packages:
|
|||
resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==}
|
||||
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:
|
||||
resolution: {integrity: sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg==}
|
||||
|
||||
|
|
@ -1319,6 +1372,9 @@ packages:
|
|||
decimal.js-light@2.5.1:
|
||||
resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==}
|
||||
|
||||
delaunator@5.1.0:
|
||||
resolution: {integrity: sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==}
|
||||
|
||||
detect-libc@2.1.2:
|
||||
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
|
||||
engines: {node: '>=8'}
|
||||
|
|
@ -1329,6 +1385,9 @@ packages:
|
|||
dom-helpers@5.2.1:
|
||||
resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==}
|
||||
|
||||
earcut@3.0.2:
|
||||
resolution: {integrity: sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==}
|
||||
|
||||
electron-to-chromium@1.5.267:
|
||||
resolution: {integrity: sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==}
|
||||
|
||||
|
|
@ -1360,16 +1419,35 @@ packages:
|
|||
resolution: {integrity: sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
|
||||
float-tooltip@1.7.5:
|
||||
resolution: {integrity: sha512-/kXzuDnnBqyyWyhDMH7+PfP8J/oXiAavGzcRxASOMRHFuReDtofizLLJsf7nnDLAfEaMW4pVWaXrAjtnglpEkg==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
fraction.js@5.3.4:
|
||||
resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==}
|
||||
|
||||
frame-ticker@1.0.3:
|
||||
resolution: {integrity: sha512-E0X2u2JIvbEMrqEg5+4BpTqaD22OwojJI63K7MdKHdncjtAhGRbCR8nJCr2vwEt9NWBPCPcu70X9smPviEBy8Q==}
|
||||
|
||||
get-nonce@1.0.1:
|
||||
resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
globe.gl@2.45.3:
|
||||
resolution: {integrity: sha512-oR7iZBjD60ltBcRlHFvlEzKGL8PMfk2Xl19vSfu1SN4SWXAs3NB3kfelanL+wj5XgG2rTQiNtPQncCR2O9oj+w==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
graceful-fs@4.2.11:
|
||||
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:
|
||||
resolution: {integrity: sha512-+yvpmKYKHi9jIGngxagY9oWiiblPB7+nEO75F2l2o4vs+6vpPZZmUl4tBNYuTCvQjhvEIbdNeJu70bhfYP2nbw==}
|
||||
peerDependencies:
|
||||
|
|
@ -1380,6 +1458,10 @@ packages:
|
|||
resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
jerrypick@1.1.2:
|
||||
resolution: {integrity: sha512-YKnxXEekXKzhpf7CLYA0A+oDP8V0OhICNCr5lv96FvSsDEmrb0GKM776JgQvHTMjr7DTTPEVv/1Ciaw0uEWzBA==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
jiti@2.6.1:
|
||||
resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
|
||||
hasBin: true
|
||||
|
|
@ -1387,6 +1469,10 @@ packages:
|
|||
js-tokens@4.0.0:
|
||||
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
|
||||
|
||||
kapsule@1.16.3:
|
||||
resolution: {integrity: sha512-4+5mNNf4vZDSwPhKprKwz3330iisPrb08JyMgbsdFrimBCKNHecua/WBwvVg3n7vwx0C1ARjfhwIpbrbd9n5wg==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
lightningcss-android-arm64@1.30.2:
|
||||
resolution: {integrity: sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
|
|
@ -1457,6 +1543,9 @@ packages:
|
|||
resolution: {integrity: sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
|
||||
lodash-es@4.18.1:
|
||||
resolution: {integrity: sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==}
|
||||
|
||||
lodash@4.17.21:
|
||||
resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==}
|
||||
|
||||
|
|
@ -1518,6 +1607,13 @@ packages:
|
|||
picocolors@1.1.1:
|
||||
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:
|
||||
resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==}
|
||||
|
||||
|
|
@ -1529,6 +1625,9 @@ packages:
|
|||
resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==}
|
||||
engines: {node: ^10 || ^12 || >=14}
|
||||
|
||||
preact@10.29.1:
|
||||
resolution: {integrity: sha512-gQCLc/vWroE8lIpleXtdJhTFDogTdZG9AjMUpVkDf2iTCNwYNWA+u16dL41TqUDJO4gm2IgrcMv3uTpjd4Pwmg==}
|
||||
|
||||
prop-types@15.8.1:
|
||||
resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
|
||||
|
||||
|
|
@ -1543,6 +1642,12 @@ packages:
|
|||
peerDependencies:
|
||||
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:
|
||||
resolution: {integrity: sha512-yt6ZGME9f4F6WHwevrvpAjh42HMvocuSnSIHUGycBqXIJdhqGSPQzTpGF+1NLREk/58IdPxEMfPcFCjlMhclGw==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
|
@ -1555,6 +1660,12 @@ packages:
|
|||
react-is@18.3.1:
|
||||
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:
|
||||
resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==}
|
||||
engines: {node: '>=10'}
|
||||
|
|
@ -1617,6 +1728,9 @@ packages:
|
|||
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
|
||||
|
||||
robust-predicates@3.0.3:
|
||||
resolution: {integrity: sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==}
|
||||
|
||||
scheduler@0.27.0:
|
||||
resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
|
||||
|
||||
|
|
@ -1629,6 +1743,9 @@ packages:
|
|||
resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==}
|
||||
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:
|
||||
resolution: {integrity: sha512-DIS8z4PfJRbIyfVFDVnK9rO3eYDtse4Omcm6bt0oEr5/jtLgysmjuBl1frJ9E/EQZrFmKx2A8m/s5s9CRXIzhw==}
|
||||
peerDependencies:
|
||||
|
|
@ -1667,9 +1784,45 @@ packages:
|
|||
resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==}
|
||||
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:
|
||||
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:
|
||||
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
|
||||
|
||||
|
|
@ -2680,6 +2833,27 @@ snapshots:
|
|||
postcss: 8.5.6
|
||||
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-color@3.1.3': {}
|
||||
|
|
@ -2704,6 +2878,8 @@ snapshots:
|
|||
|
||||
'@types/d3-timer@3.0.2': {}
|
||||
|
||||
'@types/geojson@7946.0.16': {}
|
||||
|
||||
'@types/node@22.19.3':
|
||||
dependencies:
|
||||
undici-types: 6.21.0
|
||||
|
|
@ -2724,6 +2900,8 @@ snapshots:
|
|||
dependencies:
|
||||
'@types/node': 22.19.3
|
||||
|
||||
accessor-fn@1.5.3: {}
|
||||
|
||||
aria-hidden@1.2.6:
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
|
@ -2777,16 +2955,38 @@ snapshots:
|
|||
|
||||
d3-color@3.1.0: {}
|
||||
|
||||
d3-delaunay@6.0.4:
|
||||
dependencies:
|
||||
delaunator: 5.1.0
|
||||
|
||||
d3-ease@3.0.1: {}
|
||||
|
||||
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:
|
||||
dependencies:
|
||||
d3-color: 3.1.0
|
||||
|
||||
d3-octree@1.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:
|
||||
dependencies:
|
||||
d3-array: 3.2.4
|
||||
|
|
@ -2795,6 +2995,8 @@ snapshots:
|
|||
d3-time: 3.1.0
|
||||
d3-time-format: 4.1.0
|
||||
|
||||
d3-selection@3.0.0: {}
|
||||
|
||||
d3-shape@3.2.0:
|
||||
dependencies:
|
||||
d3-path: 3.1.0
|
||||
|
|
@ -2809,12 +3011,25 @@ snapshots:
|
|||
|
||||
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@4.1.0: {}
|
||||
|
||||
decimal.js-light@2.5.1: {}
|
||||
|
||||
delaunator@5.1.0:
|
||||
dependencies:
|
||||
robust-predicates: 3.0.3
|
||||
|
||||
detect-libc@2.1.2: {}
|
||||
|
||||
detect-node-es@1.1.0: {}
|
||||
|
|
@ -2824,6 +3039,8 @@ snapshots:
|
|||
'@babel/runtime': 7.28.4
|
||||
csstype: 3.2.3
|
||||
|
||||
earcut@3.0.2: {}
|
||||
|
||||
electron-to-chromium@1.5.267: {}
|
||||
|
||||
embla-carousel-react@8.5.1(react@19.2.0):
|
||||
|
|
@ -2849,12 +3066,35 @@ snapshots:
|
|||
|
||||
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: {}
|
||||
|
||||
frame-ticker@1.0.3:
|
||||
dependencies:
|
||||
simplesignal: 2.1.7
|
||||
|
||||
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: {}
|
||||
|
||||
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):
|
||||
dependencies:
|
||||
react: 19.2.0
|
||||
|
|
@ -2862,10 +3102,16 @@ snapshots:
|
|||
|
||||
internmap@2.0.3: {}
|
||||
|
||||
jerrypick@1.1.2: {}
|
||||
|
||||
jiti@2.6.1: {}
|
||||
|
||||
js-tokens@4.0.0: {}
|
||||
|
||||
kapsule@1.16.3:
|
||||
dependencies:
|
||||
lodash-es: 4.18.1
|
||||
|
||||
lightningcss-android-arm64@1.30.2:
|
||||
optional: true
|
||||
|
||||
|
|
@ -2915,6 +3161,8 @@ snapshots:
|
|||
lightningcss-win32-arm64-msvc: 1.30.2
|
||||
lightningcss-win32-x64-msvc: 1.30.2
|
||||
|
||||
lodash-es@4.18.1: {}
|
||||
|
||||
lodash@4.17.21: {}
|
||||
|
||||
loose-envify@1.4.0:
|
||||
|
|
@ -2967,6 +3215,14 @@ snapshots:
|
|||
|
||||
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@8.4.31:
|
||||
|
|
@ -2981,6 +3237,8 @@ snapshots:
|
|||
picocolors: 1.1.1
|
||||
source-map-js: 1.2.1
|
||||
|
||||
preact@10.29.1: {}
|
||||
|
||||
prop-types@15.8.1:
|
||||
dependencies:
|
||||
loose-envify: 1.4.0
|
||||
|
|
@ -2999,6 +3257,13 @@ snapshots:
|
|||
react: 19.2.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):
|
||||
dependencies:
|
||||
react: 19.2.0
|
||||
|
|
@ -3007,6 +3272,11 @@ snapshots:
|
|||
|
||||
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):
|
||||
dependencies:
|
||||
react: 19.2.0
|
||||
|
|
@ -3075,6 +3345,8 @@ snapshots:
|
|||
tiny-invariant: 1.3.3
|
||||
victory-vendor: 36.9.2
|
||||
|
||||
robust-predicates@3.0.3: {}
|
||||
|
||||
scheduler@0.27.0: {}
|
||||
|
||||
semver@7.7.3:
|
||||
|
|
@ -3112,6 +3384,8 @@ snapshots:
|
|||
'@img/sharp-win32-x64': 0.34.5
|
||||
optional: true
|
||||
|
||||
simplesignal@2.1.7: {}
|
||||
|
||||
sonner@1.7.4(react-dom@19.2.0(react@19.2.0))(react@19.2.0):
|
||||
dependencies:
|
||||
react: 19.2.0
|
||||
|
|
@ -3134,8 +3408,67 @@ snapshots:
|
|||
|
||||
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: {}
|
||||
|
||||
tinycolor2@1.6.0: {}
|
||||
|
||||
tslib@2.8.1: {}
|
||||
|
||||
tw-animate-css@1.3.3: {}
|
||||
|
|
|
|||
Binary file not shown.
|
After Width: | Height: | Size: 1.4 MiB |
Loading…
Reference in New Issue