ahoy_analytics 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +163 -0
- data/Rakefile +6 -0
- data/app/assets/ahoy_analytics/build/assets/Combination-BpSXUjp9.js +41 -0
- data/app/assets/ahoy_analytics/build/assets/analytics-5KyfCxh6.css +1 -0
- data/app/assets/ahoy_analytics/build/assets/analytics-dashboard-uOXx8zYZ.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/analytics-layout-ClAft5OU.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/analytics-tracker-B3f8P98z.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/analytics-ui-DMSkNqd6.js +90 -0
- data/app/assets/ahoy_analytics/build/assets/behaviors-panel-ChNGYbdH.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/button-JVCrlR4s.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/cable-DO-7y1-E.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/createLucideIcon-BGzacY2v.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/date-range-dialog-DWDp3cLG.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/details-button-NqKfSGEG.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/devices-panel-cXvlmNBY.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/dialog-path-BBPNlB4Z.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/dropdown-menu-Adj3O5fh.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/filter-dialog-BN-rf4lp.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/index-B1K1NTKT.js +3 -0
- data/app/assets/ahoy_analytics/build/assets/index-BcHeb-Rh.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/index-DzpzLoG4.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/index-vX97OY1J.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/input-e4v_v0kE.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/jsx-runtime-u17CrQMm.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/last-load-context-De5uA95L.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/list-table-ChHEzzF9.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/live-Cp2MHECh.js +2 -0
- data/app/assets/ahoy_analytics/build/assets/locations-panel-BaISRmaQ.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/mercator-BnxX5RzL.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/pages-panel-Bh25L8mP.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/panel-tabs-B2kvGFJx.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/query-context-B-PgE00D.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/remote-details-dialog-DDTcKaM5.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/show-CCRicksg.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/simple-tabs-D6G6Bs0k.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/site-context-BNteYRlR.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/sources-panel-DyB21hxD.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/top-bar-FSiLBjq6.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/top-stats-context-DU15P9jS.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/use-debounce-VBpXQRL8.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/user-context-DbYteluY.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/visitor-globe-BWLDihid.js +4789 -0
- data/app/assets/ahoy_analytics/build/assets/visitor-graph-uKXjLvcu.js +1 -0
- data/app/assets/ahoy_analytics/images/icon/browser/brave.svg +1 -0
- data/app/assets/ahoy_analytics/images/icon/browser/chrome.svg +1 -0
- data/app/assets/ahoy_analytics/images/icon/browser/chromium.svg +1 -0
- data/app/assets/ahoy_analytics/images/icon/browser/duckduckgo.svg +2151 -0
- data/app/assets/ahoy_analytics/images/icon/browser/edge.svg +1 -0
- data/app/assets/ahoy_analytics/images/icon/browser/fallback.svg +5 -0
- data/app/assets/ahoy_analytics/images/icon/browser/firefox.svg +1 -0
- data/app/assets/ahoy_analytics/images/icon/browser/opera.svg +1 -0
- data/app/assets/ahoy_analytics/images/icon/browser/safari.png +0 -0
- data/app/assets/ahoy_analytics/images/icon/browser/samsung-internet.svg +1 -0
- data/app/assets/ahoy_analytics/images/icon/browser/uc.svg +1 -0
- data/app/assets/ahoy_analytics/images/icon/browser/vivaldi.svg +1 -0
- data/app/assets/ahoy_analytics/images/icon/browser/yandex.png +0 -0
- data/app/assets/ahoy_analytics/images/icon/os/android.png +0 -0
- data/app/assets/ahoy_analytics/images/icon/os/chrome_os.png +0 -0
- data/app/assets/ahoy_analytics/images/icon/os/fallback.svg +5 -0
- data/app/assets/ahoy_analytics/images/icon/os/fedora.png +0 -0
- data/app/assets/ahoy_analytics/images/icon/os/freebsd.png +0 -0
- data/app/assets/ahoy_analytics/images/icon/os/gnu_linux.png +0 -0
- data/app/assets/ahoy_analytics/images/icon/os/ios.png +0 -0
- data/app/assets/ahoy_analytics/images/icon/os/ipad_os.png +0 -0
- data/app/assets/ahoy_analytics/images/icon/os/mac.png +0 -0
- data/app/assets/ahoy_analytics/images/icon/os/ubuntu.png +0 -0
- data/app/assets/ahoy_analytics/images/icon/os/windows.png +0 -0
- data/app/assets/stylesheets/ahoy_analytics/application.css +15 -0
- data/app/channels/ahoy_analytics/analytics_channel.rb +9 -0
- data/app/controllers/ahoy_analytics/analytics_controller.rb +9 -0
- data/app/controllers/ahoy_analytics/application_controller.rb +8 -0
- data/app/controllers/ahoy_analytics/assets_controller.rb +46 -0
- data/app/controllers/ahoy_analytics/base_controller.rb +285 -0
- data/app/controllers/ahoy_analytics/behaviors_controller.rb +14 -0
- data/app/controllers/ahoy_analytics/devices_controller.rb +14 -0
- data/app/controllers/ahoy_analytics/export_controller.rb +12 -0
- data/app/controllers/ahoy_analytics/live_controller.rb +9 -0
- data/app/controllers/ahoy_analytics/locations_controller.rb +14 -0
- data/app/controllers/ahoy_analytics/main_graph_controller.rb +10 -0
- data/app/controllers/ahoy_analytics/pages_controller.rb +14 -0
- data/app/controllers/ahoy_analytics/referrers_controller.rb +34 -0
- data/app/controllers/ahoy_analytics/search_terms_controller.rb +47 -0
- data/app/controllers/ahoy_analytics/sources_controller.rb +14 -0
- data/app/controllers/ahoy_analytics/top_stats_controller.rb +13 -0
- data/app/controllers/concerns/ahoy_analytics/set_current_request.rb +17 -0
- data/app/frontend/components/analytics/hex-highlights.tsx +165 -0
- data/app/frontend/components/analytics/hex-land-layer.tsx +61 -0
- data/app/frontend/components/analytics/metric-card.tsx +138 -0
- data/app/frontend/components/analytics/sessions-by-location.tsx +62 -0
- data/app/frontend/components/analytics/visitor-globe.tsx +424 -0
- data/app/frontend/components/ui/accordion.tsx +64 -0
- data/app/frontend/components/ui/alert.tsx +66 -0
- data/app/frontend/components/ui/avatar.tsx +53 -0
- data/app/frontend/components/ui/badge.tsx +46 -0
- data/app/frontend/components/ui/button.tsx +62 -0
- data/app/frontend/components/ui/calendar.tsx +212 -0
- data/app/frontend/components/ui/card.tsx +91 -0
- data/app/frontend/components/ui/checkbox.tsx +32 -0
- data/app/frontend/components/ui/dropdown-menu.tsx +255 -0
- data/app/frontend/components/ui/input.tsx +21 -0
- data/app/frontend/components/ui/label.tsx +22 -0
- data/app/frontend/components/ui/popover.tsx +46 -0
- data/app/frontend/components/ui/select.tsx +183 -0
- data/app/frontend/components/ui/separator.tsx +26 -0
- data/app/frontend/components/ui/sheet.tsx +139 -0
- data/app/frontend/components/ui/sidebar.tsx +726 -0
- data/app/frontend/components/ui/skeleton.tsx +13 -0
- data/app/frontend/components/ui/sonner.tsx +33 -0
- data/app/frontend/components/ui/tooltip.tsx +59 -0
- data/app/frontend/data/countries-110m.json +1 -0
- data/app/frontend/data/globe-data.json +1 -0
- data/app/frontend/entrypoints/analytics-tracker.ts +680 -0
- data/app/frontend/entrypoints/analytics-ui.tsx +26 -0
- data/app/frontend/entrypoints/analytics.css +77 -0
- data/app/frontend/layouts/analytics-layout.tsx +28 -0
- data/app/frontend/lib/cable.ts +13 -0
- data/app/frontend/lib/geocode.ts +65 -0
- data/app/frontend/lib/utils.ts +6 -0
- data/app/frontend/pages/admin/analytics/api.ts +221 -0
- data/app/frontend/pages/admin/analytics/hooks/use-debounce.ts +36 -0
- data/app/frontend/pages/admin/analytics/last-load-context.tsx +29 -0
- data/app/frontend/pages/admin/analytics/lib/base-path.ts +28 -0
- data/app/frontend/pages/admin/analytics/lib/dialog-path.ts +242 -0
- data/app/frontend/pages/admin/analytics/lib/number-formatter.ts +100 -0
- data/app/frontend/pages/admin/analytics/live.tsx +608 -0
- data/app/frontend/pages/admin/analytics/query-context.tsx +61 -0
- data/app/frontend/pages/admin/analytics/show.tsx +40 -0
- data/app/frontend/pages/admin/analytics/site-context.tsx +22 -0
- data/app/frontend/pages/admin/analytics/top-stats-context.tsx +37 -0
- data/app/frontend/pages/admin/analytics/types.ts +161 -0
- data/app/frontend/pages/admin/analytics/ui/analytics-dashboard.tsx +60 -0
- data/app/frontend/pages/admin/analytics/ui/behaviors-panel.tsx +456 -0
- data/app/frontend/pages/admin/analytics/ui/date-range-dialog.tsx +173 -0
- data/app/frontend/pages/admin/analytics/ui/details-button.tsx +33 -0
- data/app/frontend/pages/admin/analytics/ui/devices-panel.tsx +474 -0
- data/app/frontend/pages/admin/analytics/ui/filter-dialog.tsx +558 -0
- data/app/frontend/pages/admin/analytics/ui/list-table.tsx +346 -0
- data/app/frontend/pages/admin/analytics/ui/locations-panel.tsx +566 -0
- data/app/frontend/pages/admin/analytics/ui/pages-panel.tsx +207 -0
- data/app/frontend/pages/admin/analytics/ui/panel-tabs.tsx +65 -0
- data/app/frontend/pages/admin/analytics/ui/remote-details-dialog.tsx +356 -0
- data/app/frontend/pages/admin/analytics/ui/simple-tabs.tsx +54 -0
- data/app/frontend/pages/admin/analytics/ui/sources-panel.tsx +771 -0
- data/app/frontend/pages/admin/analytics/ui/top-bar.tsx +793 -0
- data/app/frontend/pages/admin/analytics/ui/visitor-graph.tsx +891 -0
- data/app/frontend/pages/admin/analytics/user-context.tsx +22 -0
- data/app/frontend/styles/shared.css +156 -0
- data/app/helpers/ahoy_analytics/application_helper.rb +96 -0
- data/app/jobs/ahoy_analytics/application_job.rb +4 -0
- data/app/jobs/ahoy_analytics/update_job.rb +12 -0
- data/app/mailers/ahoy_analytics/application_mailer.rb +6 -0
- data/app/models/ahoy/event/filters.rb +7 -0
- data/app/models/ahoy/event.rb +9 -0
- data/app/models/ahoy/visit/cache_key.rb +15 -0
- data/app/models/ahoy/visit/constants.rb +11 -0
- data/app/models/ahoy/visit/devices.rb +144 -0
- data/app/models/ahoy/visit/export.rb +24 -0
- data/app/models/ahoy/visit/filters.rb +286 -0
- data/app/models/ahoy/visit/imports.rb +36 -0
- data/app/models/ahoy/visit/locations.rb +276 -0
- data/app/models/ahoy/visit/metrics.rb +473 -0
- data/app/models/ahoy/visit/ordering.rb +110 -0
- data/app/models/ahoy/visit/pages.rb +533 -0
- data/app/models/ahoy/visit/pagination.rb +17 -0
- data/app/models/ahoy/visit/ranges.rb +227 -0
- data/app/models/ahoy/visit/series.rb +177 -0
- data/app/models/ahoy/visit/sources.rb +418 -0
- data/app/models/ahoy/visit/url_labels.rb +32 -0
- data/app/models/ahoy/visit.rb +143 -0
- data/app/models/ahoy_analytics/application_record.rb +5 -0
- data/app/models/ahoy_analytics/current.rb +8 -0
- data/app/models/ahoy_analytics/funnel.rb +16 -0
- data/app/models/ahoy_analytics/imported_entry_page.rb +5 -0
- data/app/models/ahoy_analytics/imported_exit_page.rb +5 -0
- data/app/models/ahoy_analytics/imported_page.rb +5 -0
- data/app/models/ahoy_analytics/live_stats.rb +152 -0
- data/app/models/ahoy_analytics/setting.rb +19 -0
- data/app/models/analytics/source_catalog.rb +48 -0
- data/app/views/layouts/ahoy_analytics/application.html.erb +15 -0
- data/config/routes.rb +21 -0
- data/config/vite.json +22 -0
- data/db/migrate/20251006104056_create_ahoy_visits_and_events.rb +62 -0
- data/db/migrate/20251006105012_add_analytics_fields_to_ahoy_visits.rb +11 -0
- data/db/migrate/20251012090000_create_analytics_funnels_and_imports.rb +52 -0
- data/db/migrate/20251013021500_add_analytics_indexes.rb +14 -0
- data/lib/ahoy_analytics/ahoy_store.rb +429 -0
- data/lib/ahoy_analytics/asset_manifest.rb +56 -0
- data/lib/ahoy_analytics/device_bucket.rb +39 -0
- data/lib/ahoy_analytics/engine.rb +55 -0
- data/lib/ahoy_analytics/maxmind_geo.rb +77 -0
- data/lib/ahoy_analytics/version.rb +3 -0
- data/lib/ahoy_analytics.rb +52 -0
- data/lib/generators/ahoy_analytics/install/install_generator.rb +111 -0
- data/lib/generators/ahoy_analytics/install/templates/initializer.rb +28 -0
- data/lib/tasks/ahoy_analytics_tasks.rake +4 -0
- metadata +352 -0
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
import { Canvas } from '@react-three/fiber'
|
|
2
|
+
import { OrbitControls } from '@react-three/drei'
|
|
3
|
+
import HexLandLayer from '@/components/analytics/hex-land-layer'
|
|
4
|
+
import HexHighlights from '@/components/analytics/hex-highlights'
|
|
5
|
+
import { forwardRef, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'
|
|
6
|
+
import type React from 'react'
|
|
7
|
+
import * as THREE from 'three'
|
|
8
|
+
import type { OrbitControls as OrbitControlsImpl } from 'three-stdlib'
|
|
9
|
+
|
|
10
|
+
export const VISITOR_GLOBE_MIN_DISTANCE = 1.8
|
|
11
|
+
export const VISITOR_GLOBE_MAX_DISTANCE = 3.2
|
|
12
|
+
const ZOOM_STEP = 0.35
|
|
13
|
+
const INITIAL_LAT = 39 // continental US
|
|
14
|
+
const INITIAL_LNG = -98
|
|
15
|
+
const INITIAL_DISTANCE = VISITOR_GLOBE_MAX_DISTANCE // start at farthest zoom
|
|
16
|
+
|
|
17
|
+
type VisitorDot = {
|
|
18
|
+
lat: number
|
|
19
|
+
lng: number
|
|
20
|
+
type: 'visitor'
|
|
21
|
+
ts?: number // epoch ms; used for fading
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
type VisitorGlobeProps = {
|
|
25
|
+
visitors: VisitorDot[]
|
|
26
|
+
autoRotate?: boolean
|
|
27
|
+
onZoomChange?: (state: VisitorGlobeZoomState) => void
|
|
28
|
+
onViewChange?: (view: { lat: number; lng: number; distance: number }) => void
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export type VisitorGlobeZoomState = {
|
|
32
|
+
distance: number
|
|
33
|
+
minDistance: number
|
|
34
|
+
maxDistance: number
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export type VisitorGlobeHandle = {
|
|
38
|
+
zoomIn: () => void
|
|
39
|
+
zoomOut: () => void
|
|
40
|
+
getDistance: () => number
|
|
41
|
+
focusOn: (lat: number, lng: number, distance?: number) => void
|
|
42
|
+
flyTo: (lat: number, lng: number, distance?: number, durationMs?: number) => void
|
|
43
|
+
getView: () => { lat: number; lng: number; distance: number }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export const VisitorGlobe = forwardRef(function VisitorGlobe(
|
|
47
|
+
{ visitors, autoRotate = false, onZoomChange, onViewChange }: VisitorGlobeProps,
|
|
48
|
+
ref: React.Ref<VisitorGlobeHandle>
|
|
49
|
+
) {
|
|
50
|
+
// Tooltip removed for hex highlight approach; can be re-added later
|
|
51
|
+
const controlsRef = useRef<OrbitControlsImpl>(null)
|
|
52
|
+
const directionRef = useRef(new THREE.Vector3())
|
|
53
|
+
const tempPositionRef = useRef(new THREE.Vector3())
|
|
54
|
+
const animationFrameRef = useRef<number | null>(null)
|
|
55
|
+
const animationStateRef = useRef<
|
|
56
|
+
| { kind: 'distance'; from: number; to: number; start: number | null; duration: number }
|
|
57
|
+
| { kind: 'fly'; fromDir: THREE.Vector3; toDir: THREE.Vector3; distance: number; start: number | null; duration: number }
|
|
58
|
+
| null
|
|
59
|
+
>(null)
|
|
60
|
+
|
|
61
|
+
const getDistance = () => {
|
|
62
|
+
const controls = controlsRef.current
|
|
63
|
+
if (!controls) return VISITOR_GLOBE_MAX_DISTANCE
|
|
64
|
+
return (controls.object as THREE.PerspectiveCamera).position.distanceTo(controls.target)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const cancelAnimation = () => {
|
|
68
|
+
if (animationFrameRef.current !== null) {
|
|
69
|
+
cancelAnimationFrame(animationFrameRef.current)
|
|
70
|
+
animationFrameRef.current = null
|
|
71
|
+
}
|
|
72
|
+
animationStateRef.current = null
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const applyDistance = (distance: number, emit = true) => {
|
|
76
|
+
const controls = controlsRef.current
|
|
77
|
+
if (!controls) return
|
|
78
|
+
|
|
79
|
+
const camera = controls.object as THREE.PerspectiveCamera
|
|
80
|
+
directionRef.current
|
|
81
|
+
.copy(camera.position)
|
|
82
|
+
.sub(controls.target)
|
|
83
|
+
.normalize()
|
|
84
|
+
|
|
85
|
+
tempPositionRef.current
|
|
86
|
+
.copy(directionRef.current)
|
|
87
|
+
.multiplyScalar(distance)
|
|
88
|
+
.add(controls.target)
|
|
89
|
+
|
|
90
|
+
camera.position.copy(tempPositionRef.current)
|
|
91
|
+
camera.updateProjectionMatrix()
|
|
92
|
+
controls.update()
|
|
93
|
+
if (emit) emitZoomChange(distance)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const emitZoomChange = (distance?: number) => {
|
|
97
|
+
if (!onZoomChange) return
|
|
98
|
+
const currentDistance = distance ?? getDistance()
|
|
99
|
+
onZoomChange({
|
|
100
|
+
distance: currentDistance,
|
|
101
|
+
minDistance: VISITOR_GLOBE_MIN_DISTANCE,
|
|
102
|
+
maxDistance: VISITOR_GLOBE_MAX_DISTANCE
|
|
103
|
+
})
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const emitViewChange = () => {
|
|
107
|
+
if (!onViewChange || !controlsRef.current) return
|
|
108
|
+
const controls = controlsRef.current
|
|
109
|
+
const distance = getDistance()
|
|
110
|
+
const camera = controls.object as THREE.PerspectiveCamera
|
|
111
|
+
const { lat, lng } = vector3ToLatLng(camera.position)
|
|
112
|
+
onViewChange({ lat, lng, distance })
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const animateToDistance = (targetDistance: number) => {
|
|
116
|
+
const controls = controlsRef.current
|
|
117
|
+
if (!controls) return
|
|
118
|
+
|
|
119
|
+
const from = getDistance()
|
|
120
|
+
const to = THREE.MathUtils.clamp(
|
|
121
|
+
targetDistance,
|
|
122
|
+
VISITOR_GLOBE_MIN_DISTANCE,
|
|
123
|
+
VISITOR_GLOBE_MAX_DISTANCE
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
if (Math.abs(to - from) < 0.001) {
|
|
127
|
+
applyDistance(to)
|
|
128
|
+
return
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
cancelAnimation()
|
|
132
|
+
|
|
133
|
+
const duration = 280
|
|
134
|
+
animationStateRef.current = { kind: 'distance', from, to, start: null, duration }
|
|
135
|
+
|
|
136
|
+
const step = (timestamp: number) => {
|
|
137
|
+
const state = animationStateRef.current
|
|
138
|
+
if (!state) return
|
|
139
|
+
|
|
140
|
+
if (state.kind !== 'distance') return
|
|
141
|
+
if (state.start === null) state.start = timestamp
|
|
142
|
+
const progress = Math.min((timestamp - state.start) / state.duration, 1)
|
|
143
|
+
const eased = progress * progress * (3 - 2 * progress) // smoothstep easing
|
|
144
|
+
const distance = THREE.MathUtils.lerp(state.from, state.to, eased)
|
|
145
|
+
|
|
146
|
+
// Emit on each frame so UI stays in sync.
|
|
147
|
+
applyDistance(distance)
|
|
148
|
+
|
|
149
|
+
if (progress < 1) {
|
|
150
|
+
animationFrameRef.current = requestAnimationFrame(step)
|
|
151
|
+
} else {
|
|
152
|
+
animationFrameRef.current = null
|
|
153
|
+
animationStateRef.current = null
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
animationFrameRef.current = requestAnimationFrame(step)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const zoomBy = (delta: number) => {
|
|
161
|
+
animateToDistance(getDistance() + delta)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const focusOn = (lat: number, lng: number, distance?: number) => {
|
|
165
|
+
const controls = controlsRef.current
|
|
166
|
+
if (!controls) return
|
|
167
|
+
const camera = controls.object as THREE.PerspectiveCamera
|
|
168
|
+
// Keep target at origin so we orbit the globe, not a surface point
|
|
169
|
+
controls.target.set(0, 0, 0)
|
|
170
|
+
const d = distance ?? getDistance()
|
|
171
|
+
camera.position.copy(latLngToVector3(lat, lng, d))
|
|
172
|
+
camera.lookAt(0, 0, 0)
|
|
173
|
+
camera.updateProjectionMatrix()
|
|
174
|
+
controls.update()
|
|
175
|
+
emitZoomChange(d)
|
|
176
|
+
emitViewChange()
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const flyTo = (lat: number, lng: number, distance?: number, durationMs?: number) => {
|
|
180
|
+
const controls = controlsRef.current
|
|
181
|
+
if (!controls) return
|
|
182
|
+
const camera = controls.object as THREE.PerspectiveCamera
|
|
183
|
+
controls.target.set(0, 0, 0)
|
|
184
|
+
const fromDir = camera.position.clone().normalize()
|
|
185
|
+
const toDir = latLngToVector3(lat, lng, 1).normalize()
|
|
186
|
+
const targetDistance = distance ?? getDistance() // equals camera radius when target at origin
|
|
187
|
+
// If duration not provided, scale it with angular distance (slower for long spins)
|
|
188
|
+
const angle = THREE.MathUtils.clamp(fromDir.dot(toDir), -1, 1)
|
|
189
|
+
const theta = Math.acos(angle) // [0..PI]
|
|
190
|
+
const minMs = 600
|
|
191
|
+
const maxMs = 1800
|
|
192
|
+
const autoDuration = minMs + (theta / Math.PI) * (maxMs - minMs)
|
|
193
|
+
const duration = Math.max(300, Math.min(maxMs, durationMs ?? autoDuration))
|
|
194
|
+
|
|
195
|
+
cancelAnimation()
|
|
196
|
+
animationStateRef.current = { kind: 'fly', fromDir, toDir, distance: targetDistance, start: null, duration }
|
|
197
|
+
const tmp = new THREE.Vector3()
|
|
198
|
+
const identityQ = new THREE.Quaternion()
|
|
199
|
+
const rotQ = new THREE.Quaternion().setFromUnitVectors(fromDir.clone().normalize(), toDir.clone().normalize())
|
|
200
|
+
const qTmp = new THREE.Quaternion()
|
|
201
|
+
|
|
202
|
+
const step = (timestamp: number) => {
|
|
203
|
+
const state = animationStateRef.current
|
|
204
|
+
if (!state) return
|
|
205
|
+
if (state.kind !== 'fly') return
|
|
206
|
+
if (state.start === null) state.start = timestamp
|
|
207
|
+
const t = Math.min((timestamp - state.start) / state.duration, 1)
|
|
208
|
+
// ease-in-out quad
|
|
209
|
+
const eased = t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t
|
|
210
|
+
qTmp.copy(identityQ).slerp(rotQ, eased)
|
|
211
|
+
tmp.copy(state.fromDir).applyQuaternion(qTmp).normalize()
|
|
212
|
+
const cameraRadius = state.distance
|
|
213
|
+
camera.position.copy(tmp.clone().multiplyScalar(cameraRadius))
|
|
214
|
+
camera.updateProjectionMatrix()
|
|
215
|
+
controls.update()
|
|
216
|
+
emitZoomChange(state.distance)
|
|
217
|
+
emitViewChange()
|
|
218
|
+
if (t < 1) {
|
|
219
|
+
animationFrameRef.current = requestAnimationFrame(step)
|
|
220
|
+
} else {
|
|
221
|
+
animationFrameRef.current = null
|
|
222
|
+
animationStateRef.current = null
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
animationFrameRef.current = requestAnimationFrame(step)
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
useImperativeHandle(ref, () => ({
|
|
230
|
+
zoomIn: () => zoomBy(-ZOOM_STEP),
|
|
231
|
+
zoomOut: () => zoomBy(ZOOM_STEP),
|
|
232
|
+
getDistance,
|
|
233
|
+
focusOn,
|
|
234
|
+
flyTo,
|
|
235
|
+
getView: () => {
|
|
236
|
+
const controls = controlsRef.current
|
|
237
|
+
if (!controls) return { lat: INITIAL_LAT, lng: INITIAL_LNG, distance: INITIAL_DISTANCE }
|
|
238
|
+
const { lat, lng } = vector3ToLatLng(controls.target)
|
|
239
|
+
return { lat, lng, distance: getDistance() }
|
|
240
|
+
}
|
|
241
|
+
}))
|
|
242
|
+
|
|
243
|
+
useEffect(() => {
|
|
244
|
+
const controls = controlsRef.current
|
|
245
|
+
if (!controls) return
|
|
246
|
+
|
|
247
|
+
const handleStart = () => cancelAnimation()
|
|
248
|
+
const handleChange = () => { emitZoomChange(); emitViewChange() }
|
|
249
|
+
controls.addEventListener('change', handleChange)
|
|
250
|
+
controls.addEventListener('start', handleStart)
|
|
251
|
+
|
|
252
|
+
// Emit once on mount so parent syncs initial state
|
|
253
|
+
emitZoomChange()
|
|
254
|
+
emitViewChange()
|
|
255
|
+
|
|
256
|
+
return () => {
|
|
257
|
+
controls.removeEventListener('start', handleStart)
|
|
258
|
+
controls.removeEventListener('change', handleChange)
|
|
259
|
+
}
|
|
260
|
+
}, [onZoomChange])
|
|
261
|
+
|
|
262
|
+
useEffect(() => () => cancelAnimation(), [])
|
|
263
|
+
|
|
264
|
+
// Initial focus on the Americas, slightly closer, no auto-rotate by default
|
|
265
|
+
useEffect(() => {
|
|
266
|
+
if (controlsRef.current) {
|
|
267
|
+
focusOn(INITIAL_LAT, INITIAL_LNG, INITIAL_DISTANCE)
|
|
268
|
+
} else {
|
|
269
|
+
// Defer one frame in case controls ref isn't ready yet
|
|
270
|
+
const raf = requestAnimationFrame(() => focusOn(INITIAL_LAT, INITIAL_LNG, INITIAL_DISTANCE))
|
|
271
|
+
return () => cancelAnimationFrame(raf)
|
|
272
|
+
}
|
|
273
|
+
}, [])
|
|
274
|
+
|
|
275
|
+
const [tooltip, setTooltip] = useState<{ x: number; y: number; label: string } | null>(null as any)
|
|
276
|
+
|
|
277
|
+
return (
|
|
278
|
+
<div className="relative h-full w-full overflow-hidden rounded-xl bg-[radial-gradient(circle_at_top_left,rgba(226,252,255,0.3),transparent_60%),radial-gradient(circle_at_bottom_right,rgba(191,219,254,0.28),transparent_55%),#0f1118]">
|
|
279
|
+
<Canvas
|
|
280
|
+
className="relative z-10"
|
|
281
|
+
camera={{ position: [0, 0, VISITOR_GLOBE_MAX_DISTANCE], fov: 45 }}
|
|
282
|
+
gl={{ alpha: true, antialias: true }}
|
|
283
|
+
onCreated={({ gl, camera }) => {
|
|
284
|
+
gl.setClearColor(0x000000, 0)
|
|
285
|
+
// Pre-orient the camera so the US faces forward even before OrbitControls attaches
|
|
286
|
+
const dir = latLngToVector3(INITIAL_LAT, INITIAL_LNG, INITIAL_DISTANCE)
|
|
287
|
+
;(camera as THREE.PerspectiveCamera).position.copy(dir)
|
|
288
|
+
;(camera as THREE.PerspectiveCamera).lookAt(0, 0, 0)
|
|
289
|
+
}}
|
|
290
|
+
>
|
|
291
|
+
<ambientLight intensity={1.05} color={new THREE.Color('#f1faff')} />
|
|
292
|
+
<hemisphereLight args={["#c4f1f9", "#dbeafe", 0.9]} />
|
|
293
|
+
|
|
294
|
+
<Globe />
|
|
295
|
+
<Halo />
|
|
296
|
+
<HexLandLayer />
|
|
297
|
+
<HexHighlights data={visitors} onHover={setTooltip} />
|
|
298
|
+
|
|
299
|
+
<OrbitControls
|
|
300
|
+
ref={controlsRef}
|
|
301
|
+
enableZoom
|
|
302
|
+
enablePan={false}
|
|
303
|
+
autoRotate={autoRotate}
|
|
304
|
+
autoRotateSpeed={0.3}
|
|
305
|
+
minDistance={VISITOR_GLOBE_MIN_DISTANCE}
|
|
306
|
+
maxDistance={VISITOR_GLOBE_MAX_DISTANCE}
|
|
307
|
+
zoomSpeed={0.45}
|
|
308
|
+
/>
|
|
309
|
+
</Canvas>
|
|
310
|
+
{tooltip && (
|
|
311
|
+
<div className="pointer-events-none absolute z-20 rounded bg-black/70 px-2 py-1 text-xs text-white" style={{ left: tooltip.x, top: tooltip.y }}>
|
|
312
|
+
{tooltip.label}
|
|
313
|
+
</div>
|
|
314
|
+
)}
|
|
315
|
+
</div>
|
|
316
|
+
)
|
|
317
|
+
})
|
|
318
|
+
|
|
319
|
+
function Globe() {
|
|
320
|
+
const uniforms = useMemo(() => ({
|
|
321
|
+
uColorTop: { value: new THREE.Color('#f3fbff') },
|
|
322
|
+
uColorBottom: { value: new THREE.Color('#bde5ff') },
|
|
323
|
+
uRimColor: { value: new THREE.Color('#e0faff') },
|
|
324
|
+
uRimStrength: { value: 0.36 }
|
|
325
|
+
}), [])
|
|
326
|
+
|
|
327
|
+
return (
|
|
328
|
+
<mesh>
|
|
329
|
+
<sphereGeometry args={[1, 96, 96]} />
|
|
330
|
+
<shaderMaterial
|
|
331
|
+
transparent={false}
|
|
332
|
+
uniforms={uniforms}
|
|
333
|
+
vertexShader={`
|
|
334
|
+
varying vec3 vNormal;
|
|
335
|
+
varying vec3 vWorldPos;
|
|
336
|
+
void main(){
|
|
337
|
+
vNormal = normalize(normalMatrix * normal);
|
|
338
|
+
vec4 wp = modelMatrix * vec4(position,1.0);
|
|
339
|
+
vWorldPos = wp.xyz;
|
|
340
|
+
gl_Position = projectionMatrix * viewMatrix * wp;
|
|
341
|
+
}
|
|
342
|
+
`}
|
|
343
|
+
fragmentShader={`
|
|
344
|
+
uniform vec3 uColorTop;
|
|
345
|
+
uniform vec3 uColorBottom;
|
|
346
|
+
uniform vec3 uRimColor;
|
|
347
|
+
uniform float uRimStrength;
|
|
348
|
+
varying vec3 vNormal;
|
|
349
|
+
varying vec3 vWorldPos;
|
|
350
|
+
void main(){
|
|
351
|
+
float t = smoothstep(-0.25, 0.65, vNormal.y);
|
|
352
|
+
vec3 base = mix(uColorBottom, uColorTop, t);
|
|
353
|
+
float rim = pow(1.0 - max(dot(normalize(vNormal), vec3(0.0,0.0,1.0)), 0.0), 1.1);
|
|
354
|
+
vec3 color = mix(base, uRimColor, rim * uRimStrength);
|
|
355
|
+
gl_FragColor = vec4(color, 1.0);
|
|
356
|
+
}
|
|
357
|
+
`}
|
|
358
|
+
/>
|
|
359
|
+
</mesh>
|
|
360
|
+
)
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// Subtle rim lighting to hide the aliased edge (GitHub-style halo)
|
|
364
|
+
function Halo() {
|
|
365
|
+
const materialRef = useRef<THREE.ShaderMaterial>(null)
|
|
366
|
+
return (
|
|
367
|
+
<mesh>
|
|
368
|
+
<sphereGeometry args={[1.01, 64, 64]} />
|
|
369
|
+
<shaderMaterial
|
|
370
|
+
ref={materialRef}
|
|
371
|
+
transparent
|
|
372
|
+
depthWrite={false}
|
|
373
|
+
blending={THREE.AdditiveBlending}
|
|
374
|
+
vertexShader={`
|
|
375
|
+
varying vec3 vNormal;
|
|
376
|
+
void main() {
|
|
377
|
+
vNormal = normalize(normalMatrix * normal);
|
|
378
|
+
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
|
|
379
|
+
}
|
|
380
|
+
`}
|
|
381
|
+
fragmentShader={`
|
|
382
|
+
varying vec3 vNormal;
|
|
383
|
+
void main() {
|
|
384
|
+
float intensity = pow(1.0 - max(dot(vNormal, vec3(0.0, 0.0, 1.0)), 0.0), 1.05);
|
|
385
|
+
gl_FragColor = vec4(0.93, 0.99, 1.0, intensity * 0.32);
|
|
386
|
+
}
|
|
387
|
+
`}
|
|
388
|
+
/>
|
|
389
|
+
</mesh>
|
|
390
|
+
)
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// Removed sprite-based VisitorsPoints in favor of hex highlights
|
|
394
|
+
|
|
395
|
+
// Match three-globe's polar2Cartesian so our points align with its hex grid
|
|
396
|
+
// three-globe formula (before scaling):
|
|
397
|
+
// phi = (90 - lat) * DEG2RAD
|
|
398
|
+
// theta = (90 - lng) * DEG2RAD
|
|
399
|
+
// x = r * sin(phi) * cos(theta)
|
|
400
|
+
// y = r * cos(phi)
|
|
401
|
+
// z = r * sin(phi) * sin(theta)
|
|
402
|
+
function latLngToVector3(lat: number, lng: number, radius: number): THREE.Vector3 {
|
|
403
|
+
const phi = (90 - lat) * (Math.PI / 180)
|
|
404
|
+
const theta = (90 - lng) * (Math.PI / 180)
|
|
405
|
+
const sinPhi = Math.sin(phi)
|
|
406
|
+
|
|
407
|
+
return new THREE.Vector3(
|
|
408
|
+
radius * sinPhi * Math.cos(theta),
|
|
409
|
+
radius * Math.cos(phi),
|
|
410
|
+
radius * sinPhi * Math.sin(theta)
|
|
411
|
+
)
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function vector3ToLatLng(v: THREE.Vector3): { lat: number; lng: number } {
|
|
415
|
+
const r = v.length()
|
|
416
|
+
if (r === 0) return { lat: 0, lng: 0 }
|
|
417
|
+
const y = v.y / r
|
|
418
|
+
const phi = Math.acos(THREE.MathUtils.clamp(y, -1, 1))
|
|
419
|
+
const theta = Math.atan2(v.z, v.x)
|
|
420
|
+
const lat = 90 - (phi * 180) / Math.PI
|
|
421
|
+
const lng = 90 - (theta * 180) / Math.PI
|
|
422
|
+
const normLng = ((lng + 180) % 360) - 180
|
|
423
|
+
return { lat, lng: normLng }
|
|
424
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import * as React from "react"
|
|
2
|
+
import * as AccordionPrimitive from "@radix-ui/react-accordion"
|
|
3
|
+
import { ChevronDownIcon } from "lucide-react"
|
|
4
|
+
|
|
5
|
+
import { cn } from "@/lib/utils"
|
|
6
|
+
|
|
7
|
+
function Accordion({
|
|
8
|
+
...props
|
|
9
|
+
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
|
|
10
|
+
return <AccordionPrimitive.Root data-slot="accordion" {...props} />
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function AccordionItem({
|
|
14
|
+
className,
|
|
15
|
+
...props
|
|
16
|
+
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
|
|
17
|
+
return (
|
|
18
|
+
<AccordionPrimitive.Item
|
|
19
|
+
data-slot="accordion-item"
|
|
20
|
+
className={cn("border-b last:border-b-0", className)}
|
|
21
|
+
{...props}
|
|
22
|
+
/>
|
|
23
|
+
)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function AccordionTrigger({
|
|
27
|
+
className,
|
|
28
|
+
children,
|
|
29
|
+
...props
|
|
30
|
+
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
|
|
31
|
+
return (
|
|
32
|
+
<AccordionPrimitive.Header className="flex">
|
|
33
|
+
<AccordionPrimitive.Trigger
|
|
34
|
+
data-slot="accordion-trigger"
|
|
35
|
+
className={cn(
|
|
36
|
+
"focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
|
|
37
|
+
className
|
|
38
|
+
)}
|
|
39
|
+
{...props}
|
|
40
|
+
>
|
|
41
|
+
{children}
|
|
42
|
+
<ChevronDownIcon className="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200" />
|
|
43
|
+
</AccordionPrimitive.Trigger>
|
|
44
|
+
</AccordionPrimitive.Header>
|
|
45
|
+
)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function AccordionContent({
|
|
49
|
+
className,
|
|
50
|
+
children,
|
|
51
|
+
...props
|
|
52
|
+
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
|
|
53
|
+
return (
|
|
54
|
+
<AccordionPrimitive.Content
|
|
55
|
+
data-slot="accordion-content"
|
|
56
|
+
className="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
|
|
57
|
+
{...props}
|
|
58
|
+
>
|
|
59
|
+
<div className={cn("pt-0 pb-4", className)}>{children}</div>
|
|
60
|
+
</AccordionPrimitive.Content>
|
|
61
|
+
)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import * as React from "react"
|
|
2
|
+
import { cva, type VariantProps } from "class-variance-authority"
|
|
3
|
+
|
|
4
|
+
import { cn } from "@/lib/utils"
|
|
5
|
+
|
|
6
|
+
const alertVariants = cva(
|
|
7
|
+
"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
|
|
8
|
+
{
|
|
9
|
+
variants: {
|
|
10
|
+
variant: {
|
|
11
|
+
default: "bg-card text-card-foreground",
|
|
12
|
+
destructive:
|
|
13
|
+
"text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
|
|
14
|
+
},
|
|
15
|
+
},
|
|
16
|
+
defaultVariants: {
|
|
17
|
+
variant: "default",
|
|
18
|
+
},
|
|
19
|
+
}
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
function Alert({
|
|
23
|
+
className,
|
|
24
|
+
variant,
|
|
25
|
+
...props
|
|
26
|
+
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
|
|
27
|
+
return (
|
|
28
|
+
<div
|
|
29
|
+
data-slot="alert"
|
|
30
|
+
role="alert"
|
|
31
|
+
className={cn(alertVariants({ variant }), className)}
|
|
32
|
+
{...props}
|
|
33
|
+
/>
|
|
34
|
+
)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
|
|
38
|
+
return (
|
|
39
|
+
<div
|
|
40
|
+
data-slot="alert-title"
|
|
41
|
+
className={cn(
|
|
42
|
+
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
|
|
43
|
+
className
|
|
44
|
+
)}
|
|
45
|
+
{...props}
|
|
46
|
+
/>
|
|
47
|
+
)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function AlertDescription({
|
|
51
|
+
className,
|
|
52
|
+
...props
|
|
53
|
+
}: React.ComponentProps<"div">) {
|
|
54
|
+
return (
|
|
55
|
+
<div
|
|
56
|
+
data-slot="alert-description"
|
|
57
|
+
className={cn(
|
|
58
|
+
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
|
|
59
|
+
className
|
|
60
|
+
)}
|
|
61
|
+
{...props}
|
|
62
|
+
/>
|
|
63
|
+
)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export { Alert, AlertTitle, AlertDescription }
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from "react"
|
|
4
|
+
import * as AvatarPrimitive from "@radix-ui/react-avatar"
|
|
5
|
+
|
|
6
|
+
import { cn } from "@/lib/utils"
|
|
7
|
+
|
|
8
|
+
function Avatar({
|
|
9
|
+
className,
|
|
10
|
+
...props
|
|
11
|
+
}: React.ComponentProps<typeof AvatarPrimitive.Root>) {
|
|
12
|
+
return (
|
|
13
|
+
<AvatarPrimitive.Root
|
|
14
|
+
data-slot="avatar"
|
|
15
|
+
className={cn(
|
|
16
|
+
"relative flex size-8 shrink-0 overflow-hidden rounded-full",
|
|
17
|
+
className
|
|
18
|
+
)}
|
|
19
|
+
{...props}
|
|
20
|
+
/>
|
|
21
|
+
)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function AvatarImage({
|
|
25
|
+
className,
|
|
26
|
+
...props
|
|
27
|
+
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
|
|
28
|
+
return (
|
|
29
|
+
<AvatarPrimitive.Image
|
|
30
|
+
data-slot="avatar-image"
|
|
31
|
+
className={cn("aspect-square size-full", className)}
|
|
32
|
+
{...props}
|
|
33
|
+
/>
|
|
34
|
+
)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function AvatarFallback({
|
|
38
|
+
className,
|
|
39
|
+
...props
|
|
40
|
+
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
|
|
41
|
+
return (
|
|
42
|
+
<AvatarPrimitive.Fallback
|
|
43
|
+
data-slot="avatar-fallback"
|
|
44
|
+
className={cn(
|
|
45
|
+
"bg-muted flex size-full items-center justify-center rounded-full",
|
|
46
|
+
className
|
|
47
|
+
)}
|
|
48
|
+
{...props}
|
|
49
|
+
/>
|
|
50
|
+
)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export { Avatar, AvatarImage, AvatarFallback }
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import * as React from "react"
|
|
2
|
+
import { Slot } from "@radix-ui/react-slot"
|
|
3
|
+
import { cva, type VariantProps } from "class-variance-authority"
|
|
4
|
+
|
|
5
|
+
import { cn } from "@/lib/utils"
|
|
6
|
+
|
|
7
|
+
const badgeVariants = cva(
|
|
8
|
+
"inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
|
|
9
|
+
{
|
|
10
|
+
variants: {
|
|
11
|
+
variant: {
|
|
12
|
+
default:
|
|
13
|
+
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
|
|
14
|
+
secondary:
|
|
15
|
+
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
|
|
16
|
+
destructive:
|
|
17
|
+
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
|
18
|
+
outline:
|
|
19
|
+
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
defaultVariants: {
|
|
23
|
+
variant: "default",
|
|
24
|
+
},
|
|
25
|
+
}
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
function Badge({
|
|
29
|
+
className,
|
|
30
|
+
variant,
|
|
31
|
+
asChild = false,
|
|
32
|
+
...props
|
|
33
|
+
}: React.ComponentProps<"span"> &
|
|
34
|
+
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
|
35
|
+
const Comp = asChild ? Slot : "span"
|
|
36
|
+
|
|
37
|
+
return (
|
|
38
|
+
<Comp
|
|
39
|
+
data-slot="badge"
|
|
40
|
+
className={cn(badgeVariants({ variant }), className)}
|
|
41
|
+
{...props}
|
|
42
|
+
/>
|
|
43
|
+
)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export { Badge, badgeVariants }
|