Merge pull request #6514 from menloresearch/feat/web-gtag
feat: Add GA Measurement and change keyboard bindings on web
This commit is contained in:
parent
645548e931
commit
359dd8f41e
@ -14,6 +14,7 @@ jobs:
|
||||
pull-requests: write
|
||||
env:
|
||||
JAN_API_BASE: "https://api.jan.ai/jan/v1"
|
||||
GA_MEASUREMENT_ID: "G-YK53MX8M8M"
|
||||
CLOUDFLARE_PROJECT_NAME: "jan-server-web"
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
17
web-app/src/lib/analytics.ts
Normal file
17
web-app/src/lib/analytics.ts
Normal file
@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Google Analytics utility functions
|
||||
*/
|
||||
|
||||
/**
|
||||
* Track custom events with Google Analytics
|
||||
*/
|
||||
export function trackEvent(
|
||||
eventName: string,
|
||||
parameters?: Record<string, unknown>
|
||||
) {
|
||||
if (!window.gtag) {
|
||||
return
|
||||
}
|
||||
|
||||
window.gtag('event', eventName, parameters)
|
||||
}
|
||||
@ -55,4 +55,10 @@ export const PlatformFeatures: Record<PlatformFeature, boolean> = {
|
||||
|
||||
// Authentication (Google OAuth) - enabled for web only
|
||||
[PlatformFeature.AUTHENTICATION]: !isPlatformTauri(),
|
||||
|
||||
// Google Analytics - enabled for web only
|
||||
[PlatformFeature.GOOGLE_ANALYTICS]: !isPlatformTauri(),
|
||||
|
||||
// Alternate shortcut bindings - enabled for web only (to avoid browser conflicts)
|
||||
[PlatformFeature.ALTERNATE_SHORTCUT_BINDINGS]: !isPlatformTauri(),
|
||||
}
|
||||
@ -57,4 +57,10 @@ export enum PlatformFeature {
|
||||
|
||||
// Authentication (Google OAuth, user profiles)
|
||||
AUTHENTICATION = 'authentication',
|
||||
|
||||
// Google Analytics tracking (web-only)
|
||||
GOOGLE_ANALYTICS = 'googleAnalytics',
|
||||
|
||||
// Alternate keyboard shortcut bindings (web-only, to avoid browser conflicts)
|
||||
ALTERNATE_SHORTCUT_BINDINGS = 'alternateShortcutBindings',
|
||||
}
|
||||
|
||||
41
web-app/src/lib/shortcuts/const.ts
Normal file
41
web-app/src/lib/shortcuts/const.ts
Normal file
@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Shortcuts Configuration
|
||||
* Centralized shortcut definitions based on platform capabilities
|
||||
*/
|
||||
|
||||
import { PlatformFeatures } from '../platform/const'
|
||||
import { PlatformFeature } from '../platform/types'
|
||||
import { ShortcutAction, type ShortcutMap } from './types'
|
||||
|
||||
/**
|
||||
* Platform-specific shortcut mappings
|
||||
* Uses alternate bindings for web to avoid browser conflicts
|
||||
*/
|
||||
export const PlatformShortcuts: ShortcutMap = {
|
||||
// Toggle sidebar - same on both platforms (no browser conflict)
|
||||
[ShortcutAction.TOGGLE_SIDEBAR]: {
|
||||
key: 'b',
|
||||
usePlatformMetaKey: true,
|
||||
},
|
||||
|
||||
// New chat - different per platform to avoid browser "new window" conflict
|
||||
[ShortcutAction.NEW_CHAT]: PlatformFeatures[PlatformFeature.ALTERNATE_SHORTCUT_BINDINGS]
|
||||
? { key: 'Enter', usePlatformMetaKey: true }
|
||||
: { key: 'n', usePlatformMetaKey: true },
|
||||
|
||||
// Go to settings - different per platform to avoid browser "preferences" conflict
|
||||
[ShortcutAction.GO_TO_SETTINGS]: PlatformFeatures[PlatformFeature.ALTERNATE_SHORTCUT_BINDINGS]
|
||||
? { key: '.', usePlatformMetaKey: true }
|
||||
: { key: ',', usePlatformMetaKey: true },
|
||||
|
||||
// Zoom shortcuts - same on both platforms (standard shortcuts)
|
||||
[ShortcutAction.ZOOM_IN]: {
|
||||
key: '+',
|
||||
usePlatformMetaKey: true,
|
||||
},
|
||||
|
||||
[ShortcutAction.ZOOM_OUT]: {
|
||||
key: '-',
|
||||
usePlatformMetaKey: true,
|
||||
},
|
||||
}
|
||||
9
web-app/src/lib/shortcuts/index.ts
Normal file
9
web-app/src/lib/shortcuts/index.ts
Normal file
@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Shortcuts - Centralized keyboard shortcut system
|
||||
*
|
||||
* Provides platform-aware keyboard shortcuts that avoid browser conflicts
|
||||
* on web while maintaining familiar shortcuts on desktop.
|
||||
*/
|
||||
|
||||
export * from './types'
|
||||
export * from './const'
|
||||
23
web-app/src/lib/shortcuts/types.ts
Normal file
23
web-app/src/lib/shortcuts/types.ts
Normal file
@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Keyboard Shortcut Types
|
||||
* Defines semantic actions and shortcut specifications
|
||||
*/
|
||||
|
||||
export enum ShortcutAction {
|
||||
NEW_CHAT = 'newChat',
|
||||
TOGGLE_SIDEBAR = 'toggleSidebar',
|
||||
GO_TO_SETTINGS = 'goSettings',
|
||||
ZOOM_IN = 'zoomIn',
|
||||
ZOOM_OUT = 'zoomOut',
|
||||
}
|
||||
|
||||
export interface ShortcutSpec {
|
||||
key: string
|
||||
usePlatformMetaKey?: boolean
|
||||
altKey?: boolean
|
||||
shiftKey?: boolean
|
||||
ctrlKey?: boolean
|
||||
metaKey?: boolean
|
||||
}
|
||||
|
||||
export type ShortcutMap = Record<ShortcutAction, ShortcutSpec>
|
||||
62
web-app/src/providers/GoogleAnalyticsProvider.tsx
Normal file
62
web-app/src/providers/GoogleAnalyticsProvider.tsx
Normal file
@ -0,0 +1,62 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useLocation } from '@tanstack/react-router'
|
||||
|
||||
|
||||
export function GoogleAnalyticsProvider() {
|
||||
const location = useLocation()
|
||||
|
||||
useEffect(() => {
|
||||
// Check if GA ID is properly configured
|
||||
if (!GA_MEASUREMENT_ID || GA_MEASUREMENT_ID === 'G-XXXXXXXXXX') {
|
||||
console.warn(
|
||||
'Google Analytics not initialized: Invalid GA_MEASUREMENT_ID'
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// Load Google Analytics script
|
||||
const script = document.createElement('script')
|
||||
script.async = true
|
||||
script.src = `https://www.googletagmanager.com/gtag/js?id=${GA_MEASUREMENT_ID}`
|
||||
|
||||
// Handle loading errors
|
||||
script.onerror = () => {
|
||||
console.warn('Failed to load Google Analytics script')
|
||||
}
|
||||
|
||||
document.head.appendChild(script)
|
||||
|
||||
// Initialize gtag
|
||||
window.dataLayer = window.dataLayer || []
|
||||
window.gtag = function (...args: unknown[]) {
|
||||
window.dataLayer?.push(args)
|
||||
}
|
||||
window.gtag('js', new Date())
|
||||
window.gtag('config', GA_MEASUREMENT_ID, {
|
||||
send_page_view: false, // We'll manually track page views
|
||||
})
|
||||
|
||||
return () => {
|
||||
// Cleanup: Remove script on unmount
|
||||
if (script.parentNode) {
|
||||
script.parentNode.removeChild(script)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Track page views on route change
|
||||
useEffect(() => {
|
||||
if (!window.gtag) {
|
||||
return
|
||||
}
|
||||
|
||||
window.gtag('event', 'page_view', {
|
||||
page_path: location.pathname + location.search,
|
||||
page_location: window.location.href,
|
||||
page_title: document.title,
|
||||
})
|
||||
}, [location])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
@ -2,34 +2,37 @@ import { useKeyboardShortcut } from '@/hooks/useHotkeys'
|
||||
import { useLeftPanel } from '@/hooks/useLeftPanel'
|
||||
import { useRouter } from '@tanstack/react-router'
|
||||
import { route } from '@/constants/routes'
|
||||
import { PlatformShortcuts, ShortcutAction } from '@/lib/shortcuts'
|
||||
|
||||
export function KeyboardShortcutsProvider() {
|
||||
const { open, setLeftPanel } = useLeftPanel()
|
||||
const router = useRouter()
|
||||
|
||||
// Toggle Sidebar (⌘/Ctrl B)
|
||||
// Get shortcut specs from centralized configuration
|
||||
const sidebarShortcut = PlatformShortcuts[ShortcutAction.TOGGLE_SIDEBAR]
|
||||
const newChatShortcut = PlatformShortcuts[ShortcutAction.NEW_CHAT]
|
||||
const settingsShortcut = PlatformShortcuts[ShortcutAction.GO_TO_SETTINGS]
|
||||
|
||||
// Toggle Sidebar
|
||||
useKeyboardShortcut({
|
||||
key: 'b',
|
||||
usePlatformMetaKey: true,
|
||||
...sidebarShortcut,
|
||||
callback: () => {
|
||||
setLeftPanel(!open)
|
||||
},
|
||||
})
|
||||
|
||||
// New Chat (⌘/Ctrl N)
|
||||
// New Chat
|
||||
useKeyboardShortcut({
|
||||
key: 'n',
|
||||
usePlatformMetaKey: true,
|
||||
...newChatShortcut,
|
||||
excludeRoutes: [route.home],
|
||||
callback: () => {
|
||||
router.navigate({ to: route.home })
|
||||
},
|
||||
})
|
||||
|
||||
// Go to Settings (⌘/Ctrl ,)
|
||||
// Go to Settings
|
||||
useKeyboardShortcut({
|
||||
key: ',',
|
||||
usePlatformMetaKey: true,
|
||||
...settingsShortcut,
|
||||
callback: () => {
|
||||
router.navigate({ to: route.settings.general })
|
||||
},
|
||||
|
||||
@ -15,6 +15,7 @@ import { ToasterProvider } from '@/providers/ToasterProvider'
|
||||
import { useAnalytic } from '@/hooks/useAnalytic'
|
||||
import { PromptAnalytic } from '@/containers/analytics/PromptAnalytic'
|
||||
import { AnalyticProvider } from '@/providers/AnalyticProvider'
|
||||
import { GoogleAnalyticsProvider } from '@/providers/GoogleAnalyticsProvider'
|
||||
import { useLeftPanel } from '@/hooks/useLeftPanel'
|
||||
import { cn } from '@/lib/utils'
|
||||
import ToolApproval from '@/containers/dialogs/ToolApproval'
|
||||
@ -110,6 +111,7 @@ const AppLayout = () => {
|
||||
return (
|
||||
<Fragment>
|
||||
<AnalyticProvider />
|
||||
{PlatformFeatures[PlatformFeature.GOOGLE_ANALYTICS] && <GoogleAnalyticsProvider />}
|
||||
<KeyboardShortcutsProvider />
|
||||
<main className="relative h-svh text-sm antialiased select-none bg-app">
|
||||
{/* Fake absolute panel top to enable window drag */}
|
||||
|
||||
@ -4,6 +4,7 @@ import SettingsMenu from '@/containers/SettingsMenu'
|
||||
import HeaderPage from '@/containers/HeaderPage'
|
||||
import { Card, CardItem } from '@/containers/Card'
|
||||
import { useTranslation } from '@/i18n/react-i18next-compat'
|
||||
import { ShortcutAction, PlatformShortcuts, type ShortcutSpec } from '@/lib/shortcuts'
|
||||
import { PlatformMetaKey } from '@/containers/PlatformMetaKey'
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
@ -11,6 +12,75 @@ export const Route = createFileRoute(route.settings.shortcuts as any)({
|
||||
component: Shortcuts,
|
||||
})
|
||||
|
||||
interface ShortcutLabelProps {
|
||||
action: ShortcutAction
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a keyboard shortcut label consistently across platforms
|
||||
*/
|
||||
function ShortcutLabel({ action, className = '' }: ShortcutLabelProps) {
|
||||
const spec = PlatformShortcuts[action]
|
||||
|
||||
return (
|
||||
<div className={`flex items-center justify-center px-3 py-1 bg-main-view-fg/5 rounded-md ${className}`}>
|
||||
<span className="font-medium">
|
||||
<ShortcutKeys spec={spec} />
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the key combination for a shortcut spec
|
||||
*/
|
||||
function ShortcutKeys({ spec }: { spec: ShortcutSpec }) {
|
||||
const parts: React.ReactNode[] = []
|
||||
|
||||
// Helper function to format key names consistently
|
||||
const formatKey = (key: string) => {
|
||||
const lowerKey = key.toLowerCase()
|
||||
if (lowerKey === 'enter') return 'Enter'
|
||||
if (lowerKey === 'shift') return 'Shift'
|
||||
if (lowerKey === 'ctrl') return 'Ctrl'
|
||||
if (lowerKey === 'alt') return 'Alt'
|
||||
return key.toUpperCase()
|
||||
}
|
||||
|
||||
// Add modifier keys
|
||||
if (spec.usePlatformMetaKey) {
|
||||
parts.push(<PlatformMetaKey key="meta" />)
|
||||
}
|
||||
if (spec.ctrlKey) {
|
||||
parts.push('Ctrl')
|
||||
}
|
||||
if (spec.metaKey) {
|
||||
parts.push('⌘')
|
||||
}
|
||||
if (spec.altKey) {
|
||||
parts.push('Alt')
|
||||
}
|
||||
if (spec.shiftKey) {
|
||||
parts.push('Shift')
|
||||
}
|
||||
|
||||
// Add the main key with proper formatting
|
||||
parts.push(formatKey(spec.key))
|
||||
|
||||
// Join with spaces
|
||||
return (
|
||||
<>
|
||||
{parts.map((part, index) => (
|
||||
<span key={index}>
|
||||
{part}
|
||||
{index < parts.length - 1 && ' '}
|
||||
</span>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function Shortcuts() {
|
||||
const { t } = useTranslation()
|
||||
|
||||
@ -28,46 +98,22 @@ function Shortcuts() {
|
||||
<CardItem
|
||||
title={t('settings:shortcuts.newChat')}
|
||||
description={t('settings:shortcuts.newChatDesc')}
|
||||
actions={
|
||||
<div className="flex items-center justify-center px-3 py-1 bg-main-view-fg/5 rounded-md">
|
||||
<span className="font-medium">
|
||||
<PlatformMetaKey /> N
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
actions={<ShortcutLabel action={ShortcutAction.NEW_CHAT} />}
|
||||
/>
|
||||
<CardItem
|
||||
title={t('settings:shortcuts.toggleSidebar')}
|
||||
description={t('settings:shortcuts.toggleSidebarDesc')}
|
||||
actions={
|
||||
<div className="flex items-center justify-center px-3 py-1 bg-main-view-fg/5 rounded-md">
|
||||
<span className="font-medium">
|
||||
<PlatformMetaKey /> B
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
actions={<ShortcutLabel action={ShortcutAction.TOGGLE_SIDEBAR} />}
|
||||
/>
|
||||
<CardItem
|
||||
title={t('settings:shortcuts.zoomIn')}
|
||||
description={t('settings:shortcuts.zoomInDesc')}
|
||||
actions={
|
||||
<div className="flex items-center justify-center px-3 py-1 bg-main-view-fg/5 rounded-md">
|
||||
<span className="font-medium">
|
||||
<PlatformMetaKey /> +
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
actions={<ShortcutLabel action={ShortcutAction.ZOOM_IN} />}
|
||||
/>
|
||||
<CardItem
|
||||
title={t('settings:shortcuts.zoomOut')}
|
||||
description={t('settings:shortcuts.zoomOutDesc')}
|
||||
actions={
|
||||
<div className="flex items-center justify-center px-3 py-1 bg-main-view-fg/5 rounded-md">
|
||||
<span className="font-medium">
|
||||
<PlatformMetaKey /> -
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
actions={<ShortcutLabel action={ShortcutAction.ZOOM_OUT} />}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
@ -102,13 +148,7 @@ function Shortcuts() {
|
||||
<CardItem
|
||||
title={t('settings:shortcuts.goToSettings')}
|
||||
description={t('settings:shortcuts.goToSettingsDesc')}
|
||||
actions={
|
||||
<div className="flex items-center justify-center px-3 py-1 bg-main-view-fg/5 rounded-md">
|
||||
<span className="font-medium">
|
||||
<PlatformMetaKey /> ,
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
actions={<ShortcutLabel action={ShortcutAction.GO_TO_SETTINGS} />}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
3
web-app/src/types/global.d.ts
vendored
3
web-app/src/types/global.d.ts
vendored
@ -21,7 +21,10 @@ declare global {
|
||||
declare const POSTHOG_HOST: string
|
||||
declare const MODEL_CATALOG_URL: string
|
||||
declare const AUTO_UPDATER_DISABLED: boolean
|
||||
declare const GA_MEASUREMENT_ID: string
|
||||
interface Window {
|
||||
core: AppCore | undefined
|
||||
gtag?: (...args: unknown[]) => void
|
||||
dataLayer?: unknown[]
|
||||
}
|
||||
}
|
||||
|
||||
@ -62,6 +62,7 @@ export default defineConfig(({ mode }) => {
|
||||
|
||||
POSTHOG_KEY: JSON.stringify(env.POSTHOG_KEY),
|
||||
POSTHOG_HOST: JSON.stringify(env.POSTHOG_HOST),
|
||||
GA_MEASUREMENT_ID: JSON.stringify(env.GA_MEASUREMENT_ID),
|
||||
MODEL_CATALOG_URL: JSON.stringify(
|
||||
'https://raw.githubusercontent.com/menloresearch/model-catalog/main/model_catalog.json'
|
||||
),
|
||||
|
||||
@ -58,6 +58,7 @@ export default defineConfig({
|
||||
VERSION: JSON.stringify(process.env.npm_package_version || '1.0.0'),
|
||||
POSTHOG_KEY: JSON.stringify(process.env.POSTHOG_KEY || ''),
|
||||
POSTHOG_HOST: JSON.stringify(process.env.POSTHOG_HOST || ''),
|
||||
GA_MEASUREMENT_ID: JSON.stringify(process.env.GA_MEASUREMENT_ID),
|
||||
MODEL_CATALOG_URL: JSON.stringify(process.env.MODEL_CATALOG_URL || ''),
|
||||
},
|
||||
server: {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user