diff --git a/.github/workflows/jan-server-web-cicd-prod.yml b/.github/workflows/jan-server-web-cicd-prod.yml index 54b776adf..de1a07697 100644 --- a/.github/workflows/jan-server-web-cicd-prod.yml +++ b/.github/workflows/jan-server-web-cicd-prod.yml @@ -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 diff --git a/web-app/src/lib/analytics.ts b/web-app/src/lib/analytics.ts new file mode 100644 index 000000000..b7e35dc60 --- /dev/null +++ b/web-app/src/lib/analytics.ts @@ -0,0 +1,17 @@ +/** + * Google Analytics utility functions + */ + +/** + * Track custom events with Google Analytics + */ +export function trackEvent( + eventName: string, + parameters?: Record +) { + if (!window.gtag) { + return + } + + window.gtag('event', eventName, parameters) +} \ No newline at end of file diff --git a/web-app/src/lib/platform/const.ts b/web-app/src/lib/platform/const.ts index b4b1ac089..ac623bd79 100644 --- a/web-app/src/lib/platform/const.ts +++ b/web-app/src/lib/platform/const.ts @@ -55,4 +55,10 @@ export const PlatformFeatures: Record = { // 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(), } \ No newline at end of file diff --git a/web-app/src/lib/platform/types.ts b/web-app/src/lib/platform/types.ts index 15a45405a..a644c09bd 100644 --- a/web-app/src/lib/platform/types.ts +++ b/web-app/src/lib/platform/types.ts @@ -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', } diff --git a/web-app/src/lib/shortcuts/const.ts b/web-app/src/lib/shortcuts/const.ts new file mode 100644 index 000000000..8cac19536 --- /dev/null +++ b/web-app/src/lib/shortcuts/const.ts @@ -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, + }, +} \ No newline at end of file diff --git a/web-app/src/lib/shortcuts/index.ts b/web-app/src/lib/shortcuts/index.ts new file mode 100644 index 000000000..ceefa40a3 --- /dev/null +++ b/web-app/src/lib/shortcuts/index.ts @@ -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' \ No newline at end of file diff --git a/web-app/src/lib/shortcuts/types.ts b/web-app/src/lib/shortcuts/types.ts new file mode 100644 index 000000000..8ac96ae0e --- /dev/null +++ b/web-app/src/lib/shortcuts/types.ts @@ -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 \ No newline at end of file diff --git a/web-app/src/providers/GoogleAnalyticsProvider.tsx b/web-app/src/providers/GoogleAnalyticsProvider.tsx new file mode 100644 index 000000000..a459edcb2 --- /dev/null +++ b/web-app/src/providers/GoogleAnalyticsProvider.tsx @@ -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 +} + diff --git a/web-app/src/providers/KeyboardShortcuts.tsx b/web-app/src/providers/KeyboardShortcuts.tsx index 861c057cb..99eeb988c 100644 --- a/web-app/src/providers/KeyboardShortcuts.tsx +++ b/web-app/src/providers/KeyboardShortcuts.tsx @@ -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 }) }, diff --git a/web-app/src/routes/__root.tsx b/web-app/src/routes/__root.tsx index 60df44035..9989851a1 100644 --- a/web-app/src/routes/__root.tsx +++ b/web-app/src/routes/__root.tsx @@ -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 ( + {PlatformFeatures[PlatformFeature.GOOGLE_ANALYTICS] && }
{/* Fake absolute panel top to enable window drag */} diff --git a/web-app/src/routes/settings/shortcuts.tsx b/web-app/src/routes/settings/shortcuts.tsx index 106670be8..1bad0cb2c 100644 --- a/web-app/src/routes/settings/shortcuts.tsx +++ b/web-app/src/routes/settings/shortcuts.tsx @@ -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 ( +
+ + + +
+ ) +} + +/** + * 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() + } + 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) => ( + + {part} + {index < parts.length - 1 && ' '} + + ))} + + ) +} + function Shortcuts() { const { t } = useTranslation() @@ -28,46 +98,22 @@ function Shortcuts() { - - N - - - } + actions={} /> - - B - - - } + actions={} /> - - + - - - } + actions={} /> - - - - - - } + actions={} /> @@ -102,13 +148,7 @@ function Shortcuts() { - - , - - - } + actions={} /> diff --git a/web-app/src/types/global.d.ts b/web-app/src/types/global.d.ts index 3497eabcf..9f0f30dff 100644 --- a/web-app/src/types/global.d.ts +++ b/web-app/src/types/global.d.ts @@ -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[] } } diff --git a/web-app/vite.config.ts b/web-app/vite.config.ts index a9637b0d7..efd3f8647 100644 --- a/web-app/vite.config.ts +++ b/web-app/vite.config.ts @@ -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' ), diff --git a/web-app/vite.config.web.ts b/web-app/vite.config.web.ts index 6f4e271be..3da738ae2 100644 --- a/web-app/vite.config.web.ts +++ b/web-app/vite.config.web.ts @@ -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: {