Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 15 additions & 2 deletions apps/web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -856,6 +856,20 @@ function App() {
className={isHome ? "text-center mb-6 lg:mb-10" : "text-center mb-6"}
>
<div className="flex items-center justify-center gap-3 mb-1 relative">
{/* Settings gear — left side, home screen only */}
{isHome && (
<div className="absolute left-0 top-1/2 -translate-y-1/2">
<Button
variant="ghost"
size="icon"
className="text-muted-foreground hover:text-primary transition-colors h-8 w-8"
onClick={() => setViewState('settings')}
aria-label={t('navigation.settings')}
>
<Gear size={18} weight="duotone" />
</Button>
</div>
)}
<div
className="flex items-center gap-3 cursor-pointer hover:opacity-80 transition-opacity"
onClick={handleTitleClick}
Expand Down Expand Up @@ -940,7 +954,6 @@ function App() {
onPourOver={() => setViewState('pour-over')}
onDialIn={() => setViewState('dial-in')}
onShotAnalysis={() => setViewState('shot-analysis')}
onSettings={() => setViewState('settings')}
aiConfigured={aiAvailable}
hideAiWhenUnavailable={hideAiWhenUnavailable}
controlCenter={
Expand Down
258 changes: 93 additions & 165 deletions apps/web/src/views/StartView.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof import('react-i18next').useTranslation>['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
Expand All @@ -53,55 +15,92 @@ interface StartViewProps {
onDialIn: () => void
onPourOver: () => void
onShotAnalysis: () => void
onSettings: () => void
aiConfigured?: boolean
hideAiWhenUnavailable?: boolean
controlCenter?: React.ReactNode
lastShotBanner?: React.ReactNode
}

export function StartView({
profileCount,
onGenerateNew,
onViewHistory,
onProfileCatalogue,
onRunShot,
onDialIn,
onPourOver,
onShotAnalysis,
onSettings,
aiConfigured = true,
hideAiWhenUnavailable = false,
controlCenter,
lastShotBanner,
}: StartViewProps) {
const { t } = useTranslation()
const [firstName, setFirstName] = useState<string | undefined>(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 = (
<div className="space-y-2 lg:space-y-3">
{/* Core actions — 2×3 grid on mobile, stacked on desktop */}
<div className="grid grid-cols-2 gap-2 lg:grid-cols-1 lg:gap-3">
<Button
onClick={onProfileCatalogue ?? onViewHistory}
variant="dark-brew"
className="w-full h-12 lg:h-14 text-sm lg:text-base whitespace-normal lg:whitespace-nowrap"
>
<Coffee size={20} className="mr-1.5 lg:mr-2 shrink-0" weight="fill" />
{t('navigation.profileCatalogue')}
</Button>

{(!hideAiWhenUnavailable || aiConfigured) && (
<Button
onClick={onGenerateNew}
disabled={!aiConfigured}
variant="dark-brew"
className="w-full h-12 lg:h-14 text-sm lg:text-base whitespace-normal lg:whitespace-nowrap"
>
<Plus size={20} className="mr-1.5 lg:mr-2 shrink-0" weight="bold" />
{t('navigation.generateNewProfile')}
</Button>
)}

<Button
onClick={onRunShot}
variant="dark-brew"
className="w-full h-12 lg:h-14 text-sm lg:text-base whitespace-normal lg:whitespace-nowrap"
>
<Play size={20} className="mr-1.5 lg:mr-2 shrink-0" weight="fill" />
{t('navigation.runSchedule')}
</Button>

<Button
onClick={onDialIn}
variant="dark-brew"
className="w-full h-12 lg:h-14 text-sm lg:text-base whitespace-normal lg:whitespace-nowrap"
>
<Crosshair size={20} className="mr-1.5 lg:mr-2 shrink-0" weight="bold" />
{t('dialIn.title')}
</Button>

<Button
onClick={onPourOver}
variant="dark-brew"
className="w-full h-12 lg:h-14 text-sm lg:text-base whitespace-normal lg:whitespace-nowrap"
>
<Drop size={20} className="mr-1.5 lg:mr-2 shrink-0" weight="fill" />
{t('pourOver.title')}
</Button>

<Button
onClick={onShotAnalysis}
variant="dark-brew"
className="w-full h-12 lg:h-14 text-sm lg:text-base whitespace-normal lg:whitespace-nowrap"
>
<ChartLine size={20} className="mr-1.5 lg:mr-2 shrink-0" weight="bold" />
{t('navigation.shotAnalysis')}
</Button>
</div>
</div>
)

return (
<motion.div
Expand All @@ -112,103 +111,32 @@ export function StartView({
exit="hidden"
transition={gentleSpring}
>
<Card className="p-6 space-y-4 lg:space-y-6">
<div className="text-center space-y-2">
<h2 className="text-xl font-bold tracking-tight text-foreground">{greeting}</h2>
<p className="text-sm text-muted-foreground">
{profileCount && profileCount > 0
? t('profileGeneration.youHaveProfiles', { count: profileCount })
: t('profileGeneration.getStarted')}
</p>
</div>

{/* Control Center — machine status (mobile only, passed from App) */}
{controlCenter}

{/* Last-shot analysis prompt */}
<AnimatePresence>
{lastShotBanner}
</AnimatePresence>

<div className="space-y-2 lg:space-y-3">
{/* Generate New — primary action, always full width */}
{(!hideAiWhenUnavailable || aiConfigured) && (
<Button
onClick={onGenerateNew}
disabled={!aiConfigured}
variant="dark-brew"
className="w-full h-12 lg:h-14 text-sm lg:text-base"
>
<Plus size={20} className="mr-1.5 lg:mr-2" weight="bold" />
{t('navigation.generateNewProfile')}
</Button>
)}

{!aiConfigured && !hideAiWhenUnavailable && (
<p className="text-xs text-muted-foreground text-center">
{t('navigation.aiUnavailable')}
</p>
)}

{/* Core actions — 2-col grid on mobile, stacked on desktop */}
<div className="grid grid-cols-2 gap-2 lg:grid-cols-1 lg:gap-3">
<Button
onClick={onProfileCatalogue ?? onViewHistory}
variant="dark-brew"
className="w-full h-12 lg:h-14 text-sm lg:text-base whitespace-normal lg:whitespace-nowrap"
>
<Coffee size={20} className="mr-1.5 lg:mr-2 shrink-0" weight="fill" />
{t('navigation.profileCatalogue')}
</Button>

<Button
onClick={onRunShot}
variant="dark-brew"
className="w-full h-12 lg:h-14 text-sm lg:text-base whitespace-normal lg:whitespace-nowrap"
>
<Play size={20} className="mr-1.5 lg:mr-2 shrink-0" weight="fill" />
{t('navigation.runSchedule')}
</Button>

<Button
onClick={onDialIn}
variant="dark-brew"
className="w-full h-12 lg:h-14 text-sm lg:text-base whitespace-normal lg:whitespace-nowrap"
>
<Crosshair size={20} className="mr-1.5 lg:mr-2 shrink-0" weight="bold" />
{t('dialIn.title')}
</Button>

<Button
onClick={onPourOver}
variant="dark-brew"
className="w-full h-12 lg:h-14 text-sm lg:text-base whitespace-normal lg:whitespace-nowrap"
>
<Drop size={20} className="mr-1.5 lg:mr-2 shrink-0" weight="fill" />
{t('pourOver.title')}
</Button>

<Button
onClick={onShotAnalysis}
variant="dark-brew"
className="w-full h-12 lg:h-14 text-sm lg:text-base whitespace-normal lg:whitespace-nowrap"
>
<ChartLine size={20} className="mr-1.5 lg:mr-2 shrink-0" weight="bold" />
{t('navigation.shotAnalysis')}
</Button>

{/* Settings — ember accent, completes the 2×3 grid */}
<Button
onClick={onSettings}
variant="ember"
className="w-full h-12 lg:h-14 text-sm lg:text-base whitespace-normal lg:whitespace-nowrap"
>
<Gear size={20} className="mr-1.5 lg:mr-2 shrink-0" weight="duotone" />
{t('navigation.settings')}
</Button>
</div>
{isMobile ? (
// Mobile: no wrapping card — control centre and buttons as separate cards
<div className="space-y-3">
{controlCenter}

{/* Last-shot analysis prompt */}
<AnimatePresence>
{lastShotBanner}
</AnimatePresence>

<Card className="p-4">
{actionButtons}
</Card>
</div>
</Card>
) : (
// Desktop: single card with all content
<Card className="p-6 space-y-6">
{controlCenter}

<AnimatePresence>
{lastShotBanner}
</AnimatePresence>

{actionButtons}
</Card>
)}
</motion.div>
)
}
Loading