diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 9d711ac5..355e580d 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -38,7 +38,7 @@ import { AmbientBackground } from '@/components/AmbientBackground' import { useBackgroundBlobs } from '@/hooks/useBackgroundBlobs' import { useThemePreference } from '@/hooks/useThemePreference' import { usePlatformTheme } from '@/hooks/usePlatformTheme' -import { Sun, Moon } from '@phosphor-icons/react' +import { Sun, Moon, Gear } from '@phosphor-icons/react' import { AI_PREFS_CHANGED_EVENT, getAiEnabled, getHideAiWhenUnavailable, getAutoSync, getAutoSyncAiDescription, syncAutoSyncFromServer } from '@/lib/aiPreferences' // Phase 3 — Control Center & live telemetry @@ -856,6 +856,20 @@ function App() { className={isHome ? "text-center mb-6 lg:mb-10" : "text-center mb-6"} >
+ {/* Settings gear — left side, home screen only */} + {isHome && ( +
+ +
+ )}
setViewState('pour-over')} onDialIn={() => setViewState('dial-in')} onShotAnalysis={() => setViewState('shot-analysis')} - onSettings={() => setViewState('settings')} aiConfigured={aiAvailable} hideAiWhenUnavailable={hideAiWhenUnavailable} controlCenter={ diff --git a/apps/web/src/views/StartView.tsx b/apps/web/src/views/StartView.tsx index d643b1aa..a786f70c 100644 --- a/apps/web/src/views/StartView.tsx +++ b/apps/web/src/views/StartView.tsx @@ -1,48 +1,10 @@ -import { useState, useEffect, useMemo } from 'react' import { motion, AnimatePresence } from 'framer-motion' import { scaleIn, gentleSpring } from '@/lib/animations' import { useTranslation } from 'react-i18next' import { Button } from '@/components/ui/button' import { Card } from '@/components/ui/card' -import { Plus, Coffee, Play, Gear, Drop, ChartLine, Crosshair } from '@phosphor-icons/react' -import { getServerUrl } from '@/lib/config' -import { isDirectMode } from '@/lib/machineMode' - -const IGNORED_NAMES = ['meticai', 'metic ai', 'gemini', 'admin', 'user', 'default'] - -function isValidAuthorName(name: string | undefined): name is string { - if (!name) return false - const trimmed = name.trim() - if (!trimmed) return false - return !IGNORED_NAMES.some(ignored => trimmed.toLowerCase().includes(ignored)) -} - -function pickGreeting( - t: ReturnType['t'], -): string { - const hour = new Date().getHours() - let period: string - - if (hour >= 5 && hour < 12) { - period = 'morning' - } else if (hour >= 12 && hour < 17) { - period = 'afternoon' - } else { - period = 'evening' - } - - const result = t(`greetings.${period}`, { returnObjects: true }) - const greetings = Array.isArray(result) ? result as string[] : null - if (!greetings || greetings.length === 0) { - return 'Hello!' - } - return greetings[Math.floor(Math.random() * greetings.length)] -} - -function applyName(greeting: string, firstName?: string): string { - if (!firstName) return greeting - return greeting.replace(/!$/, `, ${firstName}!`) -} +import { Plus, Coffee, Play, Drop, ChartLine, Crosshair } from '@phosphor-icons/react' +import { useIsMobile } from '@/hooks/use-mobile' interface StartViewProps { profileCount: number | null @@ -53,7 +15,6 @@ interface StartViewProps { onDialIn: () => void onPourOver: () => void onShotAnalysis: () => void - onSettings: () => void aiConfigured?: boolean hideAiWhenUnavailable?: boolean controlCenter?: React.ReactNode @@ -61,7 +22,6 @@ interface StartViewProps { } export function StartView({ - profileCount, onGenerateNew, onViewHistory, onProfileCatalogue, @@ -69,39 +29,78 @@ export function StartView({ onDialIn, onPourOver, onShotAnalysis, - onSettings, aiConfigured = true, hideAiWhenUnavailable = false, controlCenter, lastShotBanner, }: StartViewProps) { const { t } = useTranslation() - const [firstName, setFirstName] = useState(undefined) - - // Pick greeting once per language — stable across re-renders, updates on locale change - const greetingBase = useMemo(() => pickGreeting(t), [t]) - - useEffect(() => { - const fetchAuthorName = async () => { - if (isDirectMode()) return // No MeticAI backend - try { - const serverUrl = await getServerUrl() - const response = await fetch(`${serverUrl}/api/settings`) - if (response.ok) { - const data = await response.json() - const name = data.authorName?.trim() - if (isValidAuthorName(name)) { - setFirstName(name.split(/\s+/)[0]) - } - } - } catch { - // Silently ignore — greeting will just omit the name - } - } - fetchAuthorName() - }, []) - - const greeting = applyName(greetingBase, firstName) + const isMobile = useIsMobile() + + // Action buttons content (shared between mobile and desktop layouts) + const actionButtons = ( +
+ {/* Core actions — 2×3 grid on mobile, stacked on desktop */} +
+ + + {(!hideAiWhenUnavailable || aiConfigured) && ( + + )} + + + + + + + + +
+
+ ) return ( - -
-

{greeting}

-

- {profileCount && profileCount > 0 - ? t('profileGeneration.youHaveProfiles', { count: profileCount }) - : t('profileGeneration.getStarted')} -

-
- - {/* Control Center — machine status (mobile only, passed from App) */} - {controlCenter} - - {/* Last-shot analysis prompt */} - - {lastShotBanner} - - -
- {/* Generate New — primary action, always full width */} - {(!hideAiWhenUnavailable || aiConfigured) && ( - - )} - - {!aiConfigured && !hideAiWhenUnavailable && ( -

- {t('navigation.aiUnavailable')} -

- )} - - {/* Core actions — 2-col grid on mobile, stacked on desktop */} -
- - - - - - - - - - - {/* Settings — ember accent, completes the 2×3 grid */} - -
+ {isMobile ? ( + // Mobile: no wrapping card — control centre and buttons as separate cards +
+ {controlCenter} + + {/* Last-shot analysis prompt */} + + {lastShotBanner} + + + + {actionButtons} +
- + ) : ( + // Desktop: single card with all content + + {controlCenter} + + + {lastShotBanner} + + + {actionButtons} + + )} ) }