AI Coach messages bookmarking. Top bar refined.

This commit is contained in:
AG
2025-12-16 16:41:50 +02:00
parent cb0bd1a55d
commit dd027e1615
26 changed files with 2496 additions and 270 deletions

View File

@@ -1,13 +1,18 @@
import React, { useState, useRef, useEffect } from 'react';
import { Send, Bot, User, Loader2, AlertTriangle } from 'lucide-react';
import React, { useState, useRef, useEffect, useMemo } from 'react';
import { Send, MessageSquare, Loader2, AlertTriangle, Bookmark, BookmarkCheck, BookmarkIcon } from 'lucide-react';
import { createFitnessChat } from '../services/geminiService';
import { WorkoutSession, Language, UserProfile, WorkoutPlan } from '../types';
import { Chat, GenerateContentResponse } from '@google/genai';
import { Language } from '../types';
import { GenerateContentResponse } from '@google/genai';
import { t } from '../services/i18n';
import { generateId } from '../utils/uuid';
import { useAuth } from '../context/AuthContext';
import { useSession } from '../context/SessionContext';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { TopBar } from './ui/TopBar';
import SavedMessagesSheet from './SavedMessagesSheet';
import { createBookmark, deleteBookmark, SavedMessage, getBookmarks } from '../services/bookmarks';
import Snackbar from './Snackbar';
interface AICoachProps {
lang: Language;
@@ -17,27 +22,83 @@ interface Message {
id: string;
role: 'user' | 'model';
text: string;
isBookmarked?: boolean;
savedMessageId?: string;
timestamp: number;
}
const STORAGE_KEY_PREFIX = 'ai_coach_history_';
const AICoach: React.FC<AICoachProps> = ({ lang }) => {
const { currentUser } = useAuth();
const { sessions: history, plans } = useSession();
const userProfile = currentUser?.profile;
const userId = currentUser?.id;
// Load initial messages from local storage
const [messages, setMessages] = useState<Message[]>(() => {
if (!userId) return [];
const saved = localStorage.getItem(`${STORAGE_KEY_PREFIX}${userId}`);
if (saved) {
try {
return JSON.parse(saved);
} catch (e) {
console.error("Failed to parse saved chat history", e);
}
}
return [{ id: 'intro', role: 'model', text: t('ai_intro', lang), timestamp: Date.now() }];
});
const [messages, setMessages] = useState<Message[]>([
{ id: 'intro', role: 'model', text: t('ai_intro', lang) }
]);
const [input, setInput] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const chatSessionRef = useRef<Chat | null>(null);
const [showSavedMessages, setShowSavedMessages] = useState(false);
const [savedMessages, setSavedMessages] = useState<SavedMessage[]>([]);
const [snackbar, setSnackbar] = useState<{ open: boolean, message: string }>({ open: false, message: '' });
const chatSessionRef = useRef<any>(null); // Type 'Chat' is hard to import perfectly here without errors if not careful
const messagesEndRef = useRef<HTMLDivElement>(null);
// Sync with local storage
useEffect(() => {
if (userId) {
localStorage.setItem(`${STORAGE_KEY_PREFIX}${userId}`, JSON.stringify(messages));
}
}, [messages, userId]);
// Load bookmarks on mount to sync status
useEffect(() => {
if (!userId) return;
const loadBookmarks = async () => {
try {
const bookmarks = await getBookmarks();
setSavedMessages(bookmarks);
// Update bookmarked status in local messages
setMessages(prev => prev.map(msg => {
const found = bookmarks.find(b => b.content === msg.text);
if (found) {
return { ...msg, isBookmarked: true, savedMessageId: found.id };
}
return msg;
}));
} catch (e: any) {
if (e.message !== 'Unauthorized') {
console.warn("Failed to load bookmarks:", e);
}
}
};
loadBookmarks();
}, [userId, showSavedMessages]);
useEffect(() => {
try {
const chat = createFitnessChat(history, lang, userProfile, plans);
if (chat) {
chatSessionRef.current = chat;
// Restore history context
// Note: Gemini SDK doesn't easily allow "restoring" state without re-sending history
// This is a simplification; for full context restoration we'd need to rebuild history
// For now, we start fresh session context but display old UI messages
} else {
setError(t('ai_error', lang));
}
@@ -57,7 +118,7 @@ const AICoach: React.FC<AICoachProps> = ({ lang }) => {
const handleSend = async () => {
if (!input.trim() || !chatSessionRef.current || loading) return;
const userMsg: Message = { id: generateId(), role: 'user', text: input };
const userMsg: Message = { id: generateId(), role: 'user', text: input, timestamp: Date.now() };
setMessages(prev => [...prev, userMsg]);
setInput('');
setLoading(true);
@@ -69,27 +130,42 @@ const AICoach: React.FC<AICoachProps> = ({ lang }) => {
const aiMsg: Message = {
id: generateId(),
role: 'model',
text: text || "Error generating response."
text: text || "Error generating response.",
timestamp: Date.now()
};
setMessages(prev => [...prev, aiMsg]);
} catch (err) {
console.error(err);
let errorText = 'Connection error.';
if (err instanceof Error) {
try {
const json = JSON.parse(err.message);
if (json.error) errorText = json.error;
else errorText = err.message;
} catch {
errorText = err.message;
}
errorText = err.message;
}
setMessages(prev => [...prev, { id: generateId(), role: 'model', text: errorText }]);
setMessages(prev => [...prev, { id: generateId(), role: 'model', text: errorText, timestamp: Date.now() }]);
} finally {
setLoading(false);
}
};
const toggleBookmark = async (msg: Message) => {
if (msg.role !== 'model') return;
if (msg.isBookmarked && msg.savedMessageId) {
// Unbookmark
const success = await deleteBookmark(msg.savedMessageId);
if (success) {
setMessages(prev => prev.map(m => m.id === msg.id ? { ...m, isBookmarked: false, savedMessageId: undefined } : m));
setSnackbar({ open: true, message: 'Bookmark removed' });
}
} else {
// Bookmark
const newBookmark = await createBookmark(msg.text, msg.role);
if (newBookmark) {
setMessages(prev => prev.map(m => m.id === msg.id ? { ...m, isBookmarked: true, savedMessageId: newBookmark.id } : m));
setSnackbar({ open: true, message: 'Message saved' });
}
}
};
if (error) {
return (
<div className="flex flex-col items-center justify-center h-full p-6 text-center text-on-surface-variant">
@@ -101,23 +177,46 @@ const AICoach: React.FC<AICoachProps> = ({ lang }) => {
return (
<div className="flex flex-col h-full bg-surface">
{/* Header */}
<div className="p-4 bg-surface-container shadow-elevation-1 flex items-center gap-3 z-10">
<div className="w-10 h-10 rounded-full bg-secondary-container flex items-center justify-center">
<Bot size={20} className="text-on-secondary-container" />
</div>
<h2 className="text-xl font-normal text-on-surface">{t('ai_expert', lang)}</h2>
</div>
<TopBar
title={t('ai_expert', lang)}
icon={MessageSquare}
actions={
<button
onClick={() => setShowSavedMessages(true)}
className="p-2 text-on-surface hover:bg-surface-container-high rounded-full transition-colors"
title="Saved Messages"
>
<BookmarkIcon size={20} />
</button>
}
/>
{/* Messages */}
<div className="flex-1 overflow-y-auto p-4 space-y-4 pb-4">
{messages.map((msg) => (
<div key={msg.id} className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}>
<div className={`max-w-[85%] p-4 rounded-[20px] text-sm leading-relaxed shadow-sm ${msg.role === 'user'
? 'bg-primary text-on-primary rounded-br-none'
: 'bg-surface-container-high text-on-surface border border-outline-variant/20 rounded-bl-none'
}`}>
{msg.text}
<div className={`max-w-[90%] sm:max-w-[85%] relative group`}>
<div className={`p-4 rounded-[20px] text-sm leading-relaxed shadow-sm ${msg.role === 'user'
? 'bg-primary text-on-primary rounded-br-none'
: 'bg-surface-container-high text-on-surface border border-outline-variant/20 rounded-bl-none'
}`}>
<div className="prose prose-invert prose-sm max-w-none">
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{msg.text}
</ReactMarkdown>
</div>
</div>
{/* Bookmark Action */}
{msg.role === 'model' && (
<button
onClick={() => toggleBookmark(msg)}
className={`absolute -right-8 top-2 p-1.5 rounded-full hover:bg-surface-container-high transition-colors text-on-surface-variant ${msg.isBookmarked ? 'text-primary opacity-100' : 'opacity-0 group-hover:opacity-100'}`}
title={msg.isBookmarked ? "Remove Bookmark" : "Bookmark Message"}
>
{msg.isBookmarked ? <BookmarkCheck size={16} /> : <Bookmark size={16} />}
</button>
)}
</div>
</div>
))}
@@ -152,6 +251,21 @@ const AICoach: React.FC<AICoachProps> = ({ lang }) => {
</button>
</div>
</div>
<SavedMessagesSheet
isOpen={showSavedMessages}
onClose={() => setShowSavedMessages(false)}
onUnbookmark={(id) => {
setMessages(prev => prev.map(m => m.savedMessageId === id ? { ...m, isBookmarked: false, savedMessageId: undefined } : m));
}}
/>
<Snackbar
isOpen={snackbar.open}
message={snackbar.message}
onClose={() => setSnackbar({ ...snackbar, open: false })}
type="success"
/>
</div>
);
};

View File

@@ -1,5 +1,7 @@
import React, { useState } from 'react';
import { Calendar, Clock, TrendingUp, Gauge, Pencil, Trash2, X, Save, ArrowRight, ArrowUp, Timer, Activity, Dumbbell, Percent } from 'lucide-react';
import { Trash2, Calendar, Clock, ChevronDown, ChevronUp, History as HistoryIcon, Dumbbell, Ruler, Timer, Weight, Edit2, Gauge, Pencil, Save } from 'lucide-react';
import { TopBar } from './ui/TopBar';
import { WorkoutSession, ExerciseType, WorkoutSet, Language } from '../types';
import { t } from '../services/i18n';
import { formatSetMetrics } from '../utils/setFormatting';
@@ -84,7 +86,7 @@ const History: React.FC<HistoryProps> = ({ lang }) => {
const formatDateForInput = (timestamp: number) => {
const d = new Date(timestamp);
const pad = (n: number) => n < 10 ? '0' + n : n;
const pad = (n: number) => (n < 10 ? '0' + n : n);
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
};
@@ -170,10 +172,7 @@ const History: React.FC<HistoryProps> = ({ lang }) => {
return (
<div className="h-full flex flex-col bg-surface">
<div className="p-4 bg-surface-container shadow-elevation-1 z-10 shrink-0">
<h2 className="text-2xl font-normal text-on-surface">{t('tab_history', lang)}</h2>
</div>
<TopBar title={t('tab_history', lang)} icon={HistoryIcon} />
<div className="flex-1 overflow-y-auto p-4 pb-20">
<div className="max-w-2xl mx-auto space-y-4">
{/* Regular Workout Sessions */}

View File

@@ -1,6 +1,7 @@
import React, { useState, useEffect } from 'react';
import { useSearchParams } from 'react-router-dom';
import { Plus, Trash2, PlayCircle, Dumbbell, Save, X, List, ArrowUp, Pencil, User, Flame, Timer as TimerIcon, Ruler, Footprints, Percent, CheckCircle, GripVertical, Bot, Loader2 } from 'lucide-react';
import { Plus, Trash2, PlayCircle, Dumbbell, Save, X, List, ArrowUp, Pencil, User, Flame, Timer as TimerIcon, Ruler, Footprints, Percent, CheckCircle, GripVertical, Bot, Loader2, ClipboardList } from 'lucide-react';
import { TopBar } from './ui/TopBar';
import {
DndContext,
closestCenter,
@@ -517,20 +518,24 @@ const Plans: React.FC<PlansProps> = ({ lang }) => {
if (isEditing) {
return (
<div className="h-full flex flex-col bg-surface">
<div className="px-4 py-3 bg-surface-container border-b border-outline-variant flex justify-between items-center shrink-0">
<Button
onClick={() => {
setIsEditing(false);
localStorage.removeItem('gymflow_plan_draft');
}}
variant="ghost" size="icon">
<X size={20} />
</Button>
<h2 className="text-title-medium font-medium text-on-surface">{t('plan_editor', lang)}</h2>
<Button onClick={handleSave} variant="ghost" className="text-primary font-medium hover:bg-primary-container/10">
{t('save', lang)}
</Button>
</div>
<TopBar
title={t('plan_editor', lang)}
actions={
<div className="flex items-center gap-1">
<Button
onClick={() => {
setIsEditing(false);
localStorage.removeItem('gymflow_plan_draft');
}}
variant="ghost" size="icon">
<X size={20} />
</Button>
<Button onClick={handleSave} variant="ghost" className="text-primary font-medium hover:bg-primary-container/10">
{t('save', lang)}
</Button>
</div>
}
/>
<div className="flex-1 overflow-y-auto p-4 space-y-6">
<FilledInput
@@ -669,9 +674,7 @@ const Plans: React.FC<PlansProps> = ({ lang }) => {
return (
<div className="h-full flex flex-col bg-surface relative">
<div className="p-4 bg-surface-container shadow-elevation-1 z-10 shrink-0">
<h2 className="text-2xl font-normal text-on-surface">{t('my_plans', lang)}</h2>
</div>
<TopBar title={t('my_plans', lang)} icon={ClipboardList} />
<div className="flex-1 p-4 overflow-y-auto pb-24">
{plans.length === 0 ? (

View File

@@ -11,6 +11,8 @@ import { t } from '../services/i18n';
import Snackbar from './Snackbar';
import { Button } from './ui/Button';
import { Card } from './ui/Card';
import { TopBar } from './ui/TopBar';
import { Modal } from './ui/Modal';
import { SideSheet } from './ui/SideSheet';
import { Checkbox } from './ui/Checkbox';
@@ -267,15 +269,15 @@ const Profile: React.FC<ProfileProps> = ({ user, onLogout, lang, onLanguageChang
return (
<div className="h-full flex flex-col bg-surface">
<div className="p-4 bg-surface-container shadow-elevation-1 flex items-center justify-between z-10 shrink-0">
<h2 className="text-xl font-normal text-on-surface flex items-center gap-2">
<UserIcon size={20} />
{t('profile_title', lang)}
</h2>
<Button onClick={onLogout} variant="ghost" size="sm" className="text-error hover:bg-error-container/10">
<LogOut size={16} className="mr-1" /> {t('logout', lang)}
</Button>
</div>
<TopBar
title={t('profile_title', lang)}
icon={UserIcon}
actions={
<Button onClick={onLogout} variant="ghost" size="sm" className="text-error hover:bg-error-container/10">
<LogOut size={16} className="mr-1" /> {t('logout', lang)}
</Button>
}
/>
<div className="flex-1 overflow-y-auto p-4 space-y-6 pb-24">
<div className="max-w-2xl mx-auto space-y-6">

View File

@@ -0,0 +1,114 @@
import React, { useEffect, useState } from 'react';
import { SideSheet } from './ui/SideSheet';
import { SavedMessage, getBookmarks, deleteBookmark } from '../services/bookmarks';
import { Trash2, AlertCircle, BookmarkX, Loader2 } from 'lucide-react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
interface SavedMessagesSheetProps {
isOpen: boolean;
onClose: () => void;
onUnbookmark?: (id: string) => void;
}
const SavedMessagesSheet: React.FC<SavedMessagesSheetProps> = ({ isOpen, onClose, onUnbookmark }) => {
const [bookmarks, setBookmarks] = useState<SavedMessage[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const fetchBookmarks = async () => {
setLoading(true);
setError(null);
try {
const data = await getBookmarks();
setBookmarks(data);
} catch (err: any) {
if (err.message !== 'Unauthorized') {
console.warn("Failed to fetch bookmarks:", err);
}
// Fallback to empty if API fails
setBookmarks([]);
} finally {
setLoading(false);
}
};
useEffect(() => {
if (isOpen) {
fetchBookmarks();
}
}, [isOpen]);
const handleDelete = async (id: string, e: React.MouseEvent) => {
e.stopPropagation();
try {
const success = await deleteBookmark(id);
if (success) {
setBookmarks(prev => prev.filter(b => b.id !== id));
if (onUnbookmark) {
onUnbookmark(id);
}
} else {
// Optimistic update fallback
setBookmarks(prev => prev.filter(b => b.id !== id));
if (onUnbookmark) {
onUnbookmark(id);
}
}
} catch (error) {
console.error("Delete failed", error);
}
};
return (
<SideSheet
isOpen={isOpen}
onClose={onClose}
title="Saved Messages"
width="md"
>
<div className="flex flex-col h-full">
{loading ? (
<div className="flex flex-col items-center justify-center p-8 text-on-surface-variant">
<Loader2 size={32} className="animate-spin mb-2" />
<p>Loading...</p>
</div>
) : error ? (
<div className="flex flex-col items-center justify-center p-8 text-error">
<AlertCircle size={32} className="mb-2" />
<p>{error}</p>
</div>
) : bookmarks.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-on-surface-variant opacity-60">
<BookmarkX size={48} className="mb-4" />
<p>No saved messages yet.</p>
</div>
) : (
<div className="space-y-4 p-1">
{bookmarks.map((msg) => (
<div key={msg.id} className="bg-surface-container-high rounded-xl p-4 shadow-sm border border-outline-variant/20 relative group">
<div className="pr-8 prose prose-invert prose-sm max-w-none text-on-surface">
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{msg.content}
</ReactMarkdown>
</div>
<div className="text-xs text-on-surface-variant mt-3 pt-3 border-t border-outline-variant/10 flex justify-between items-center">
<span>{new Date(msg.createdAt).toLocaleDateString()}</span>
<button
onClick={(e) => handleDelete(msg.id, e)}
className="p-2 text-on-surface-variant hover:text-error hover:bg-error/10 rounded-full transition-colors absolute top-2 right-2 opacity-100 sm:opacity-0 sm:group-hover:opacity-100"
title="Remove bookmark"
>
<Trash2 size={16} />
</button>
</div>
</div>
))}
</div>
)}
</div>
</SideSheet>
);
};
export default SavedMessagesSheet;

View File

@@ -4,6 +4,8 @@ import { getWeightHistory } from '../services/weight';
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, BarChart, Bar } from 'recharts';
import { t } from '../services/i18n';
import { useSession } from '../context/SessionContext';
import { TopBar } from './ui/TopBar';
import { BarChart2 } from 'lucide-react';
interface StatsProps {
lang: Language;
@@ -112,89 +114,91 @@ const Stats: React.FC<StatsProps> = ({ lang }) => {
}
return (
<div className="h-full overflow-y-auto p-4 space-y-6 pb-24 bg-surface">
<h2 className="text-3xl font-normal text-on-surface mb-2 pl-2">{t('progress', lang)}</h2>
<div className="h-full flex flex-col bg-surface">
<TopBar title={t('progress', lang)} icon={BarChart2} />
{/* Volume Chart */}
<div className="bg-surface-container p-5 rounded-[24px] shadow-elevation-1 border border-outline-variant/20">
<div className="flex justify-between items-end mb-6">
<div>
<h3 className="text-title-medium font-medium text-on-surface">{t('volume_title', lang)}</h3>
<p className="text-xs text-on-surface-variant mt-1">{t('volume_subtitle', lang)}</p>
<div className="flex-1 overflow-y-auto p-4 space-y-6 pb-24 bg-surface">
{/* Volume Chart */}
<div className="bg-surface-container p-5 rounded-[24px] shadow-elevation-1 border border-outline-variant/20">
<div className="flex justify-between items-end mb-6">
<div>
<h3 className="text-title-medium font-medium text-on-surface">{t('volume_title', lang)}</h3>
<p className="text-xs text-on-surface-variant mt-1">{t('volume_subtitle', lang)}</p>
</div>
</div>
<div className="h-64 min-h-64 w-full">
<ResponsiveContainer width="100%" height={256}>
<LineChart data={volumeData}>
<CartesianGrid strokeDasharray="3 3" stroke="#49454F" vertical={false} opacity={0.5} />
<XAxis dataKey="date" stroke="#CAC4D0" fontSize={12} tickLine={false} axisLine={false} dy={10} />
<YAxis stroke="#CAC4D0" fontSize={12} tickLine={false} axisLine={false} tickFormatter={(val) => `${(val / 1000).toFixed(1)}k`} />
<Tooltip
contentStyle={{ backgroundColor: '#2B2930', borderColor: '#49454F', color: '#E6E0E9', borderRadius: '12px' }}
itemStyle={{ color: '#D0BCFF' }}
formatter={(val: number) => [`${val.toLocaleString()} kg`, t('volume_title', lang)]}
/>
<Line type="monotone" dataKey="work" stroke="#D0BCFF" strokeWidth={3} dot={{ r: 4, fill: '#D0BCFF' }} activeDot={{ r: 6 }} />
</LineChart>
</ResponsiveContainer>
</div>
</div>
<div className="h-64 min-h-64 w-full">
<ResponsiveContainer width="100%" height={256}>
<LineChart data={volumeData}>
<CartesianGrid strokeDasharray="3 3" stroke="#49454F" vertical={false} opacity={0.5} />
<XAxis dataKey="date" stroke="#CAC4D0" fontSize={12} tickLine={false} axisLine={false} dy={10} />
<YAxis stroke="#CAC4D0" fontSize={12} tickLine={false} axisLine={false} tickFormatter={(val) => `${(val / 1000).toFixed(1)}k`} />
<Tooltip
contentStyle={{ backgroundColor: '#2B2930', borderColor: '#49454F', color: '#E6E0E9', borderRadius: '12px' }}
itemStyle={{ color: '#D0BCFF' }}
formatter={(val: number) => [`${val.toLocaleString()} kg`, t('volume_title', lang)]}
/>
<Line type="monotone" dataKey="work" stroke="#D0BCFF" strokeWidth={3} dot={{ r: 4, fill: '#D0BCFF' }} activeDot={{ r: 6 }} />
</LineChart>
</ResponsiveContainer>
</div>
</div>
{/* Sessions Count Chart */}
<div className="bg-surface-container p-5 rounded-[24px] shadow-elevation-1 border border-outline-variant/20">
<h3 className="text-title-medium font-medium text-on-surface mb-6">{t('sessions_count_title', lang)}</h3>
<div className="h-64 min-h-64 w-full">
<ResponsiveContainer width="100%" height={256}>
<BarChart data={sessionsCountData}>
<CartesianGrid strokeDasharray="3 3" stroke="#49454F" vertical={false} opacity={0.5} />
<XAxis dataKey="date" stroke="#CAC4D0" fontSize={12} tickLine={false} axisLine={false} dy={10} />
<YAxis stroke="#CAC4D0" fontSize={12} tickLine={false} axisLine={false} allowDecimals={false} />
<Tooltip
contentStyle={{ backgroundColor: '#2B2930', borderColor: '#49454F', color: '#E6E0E9', borderRadius: '12px' }}
cursor={{ fill: 'rgba(255,255,255,0.05)' }}
/>
<Bar dataKey="sessions" fill="#4FD1C5" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
{/* Sessions Count Chart */}
<div className="bg-surface-container p-5 rounded-[24px] shadow-elevation-1 border border-outline-variant/20">
<h3 className="text-title-medium font-medium text-on-surface mb-6">{t('sessions_count_title', lang)}</h3>
<div className="h-64 min-h-64 w-full">
<ResponsiveContainer width="100%" height={256}>
<BarChart data={sessionsCountData}>
<CartesianGrid strokeDasharray="3 3" stroke="#49454F" vertical={false} opacity={0.5} />
<XAxis dataKey="date" stroke="#CAC4D0" fontSize={12} tickLine={false} axisLine={false} dy={10} />
<YAxis stroke="#CAC4D0" fontSize={12} tickLine={false} axisLine={false} allowDecimals={false} />
<Tooltip
contentStyle={{ backgroundColor: '#2B2930', borderColor: '#49454F', color: '#E6E0E9', borderRadius: '12px' }}
cursor={{ fill: 'rgba(255,255,255,0.05)' }}
/>
<Bar dataKey="sessions" fill="#4FD1C5" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
</div>
</div>
{/* Sets Chart */}
<div className="bg-surface-container p-5 rounded-[24px] shadow-elevation-1 border border-outline-variant/20">
<h3 className="text-title-medium font-medium text-on-surface mb-6">{t('sets_title', lang)}</h3>
<div className="h-64 min-h-64 w-full">
<ResponsiveContainer width="100%" height={256}>
<BarChart data={setsData}>
<CartesianGrid strokeDasharray="3 3" stroke="#49454F" vertical={false} opacity={0.5} />
<XAxis dataKey="date" stroke="#CAC4D0" fontSize={12} tickLine={false} axisLine={false} dy={10} />
<YAxis stroke="#CAC4D0" fontSize={12} tickLine={false} axisLine={false} />
<Tooltip
contentStyle={{ backgroundColor: '#2B2930', borderColor: '#49454F', color: '#E6E0E9', borderRadius: '12px' }}
cursor={{ fill: 'rgba(255,255,255,0.05)' }}
/>
<Bar dataKey="sets" fill="#CCC2DC" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
{/* Sets Chart */}
<div className="bg-surface-container p-5 rounded-[24px] shadow-elevation-1 border border-outline-variant/20">
<h3 className="text-title-medium font-medium text-on-surface mb-6">{t('sets_title', lang)}</h3>
<div className="h-64 min-h-64 w-full">
<ResponsiveContainer width="100%" height={256}>
<BarChart data={setsData}>
<CartesianGrid strokeDasharray="3 3" stroke="#49454F" vertical={false} opacity={0.5} />
<XAxis dataKey="date" stroke="#CAC4D0" fontSize={12} tickLine={false} axisLine={false} dy={10} />
<YAxis stroke="#CAC4D0" fontSize={12} tickLine={false} axisLine={false} />
<Tooltip
contentStyle={{ backgroundColor: '#2B2930', borderColor: '#49454F', color: '#E6E0E9', borderRadius: '12px' }}
cursor={{ fill: 'rgba(255,255,255,0.05)' }}
/>
<Bar dataKey="sets" fill="#CCC2DC" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
</div>
</div>
{/* Body Weight Chart */}
<div className="bg-surface-container p-5 rounded-[24px] shadow-elevation-1 border border-outline-variant/20">
<h3 className="text-title-medium font-medium text-on-surface mb-6">{t('weight_title', lang)}</h3>
<div className="h-64 min-h-64 w-full">
<ResponsiveContainer width="100%" height={256}>
<LineChart data={weightData}>
<CartesianGrid strokeDasharray="3 3" stroke="#49454F" vertical={false} opacity={0.5} />
<XAxis dataKey="date" stroke="#CAC4D0" fontSize={12} tickLine={false} axisLine={false} dy={10} />
<YAxis domain={['auto', 'auto']} stroke="#CAC4D0" fontSize={12} tickLine={false} axisLine={false} />
<Tooltip
contentStyle={{ backgroundColor: '#2B2930', borderColor: '#49454F', color: '#E6E0E9', borderRadius: '12px' }}
itemStyle={{ color: '#6EE7B7' }}
formatter={(val: number) => [`${val} kg`, t('weight_kg', lang)]}
/>
<Line type="monotone" dataKey="weight" stroke="#6EE7B7" strokeWidth={3} dot={{ r: 4, fill: '#6EE7B7' }} activeDot={{ r: 6 }} />
</LineChart>
</ResponsiveContainer>
{/* Body Weight Chart */}
<div className="bg-surface-container p-5 rounded-[24px] shadow-elevation-1 border border-outline-variant/20">
<h3 className="text-title-medium font-medium text-on-surface mb-6">{t('weight_title', lang)}</h3>
<div className="h-64 min-h-64 w-full">
<ResponsiveContainer width="100%" height={256}>
<LineChart data={weightData}>
<CartesianGrid strokeDasharray="3 3" stroke="#49454F" vertical={false} opacity={0.5} />
<XAxis dataKey="date" stroke="#CAC4D0" fontSize={12} tickLine={false} axisLine={false} dy={10} />
<YAxis domain={['auto', 'auto']} stroke="#CAC4D0" fontSize={12} tickLine={false} axisLine={false} />
<Tooltip
contentStyle={{ backgroundColor: '#2B2930', borderColor: '#49454F', color: '#E6E0E9', borderRadius: '12px' }}
itemStyle={{ color: '#6EE7B7' }}
formatter={(val: number) => [`${val} kg`, t('weight_kg', lang)]}
/>
<Line type="monotone" dataKey="weight" stroke="#6EE7B7" strokeWidth={3} dot={{ r: 4, fill: '#6EE7B7' }} activeDot={{ r: 6 }} />
</LineChart>
</ResponsiveContainer>
</div>
</div>
</div>
</div>

View File

@@ -1,5 +1,6 @@
import React from 'react';
import { Dumbbell, User, PlayCircle, Plus, ArrowRight } from 'lucide-react';
import { TopBar } from '../ui/TopBar';
import { Language } from '../../types';
import { t } from '../../services/i18n';
import { useTracker } from './useTracker';
@@ -77,110 +78,113 @@ const IdleView: React.FC<IdleViewProps> = ({ tracker, lang }) => {
const content = getDaysOffContent();
return (
<div className="flex flex-col h-full p-4 md:p-8 overflow-y-auto relative">
<div className="flex-1 flex flex-col items-center justify-center space-y-12">
<div className="flex flex-col items-center gap-4">
<div className="w-24 h-24 rounded-full bg-surface-container-high flex items-center justify-center text-primary shadow-elevation-1">
<Dumbbell size={40} />
</div>
<div className="text-center">
<h1 className={`text-3xl font-normal ${content.colorClass}`}>{content.title}</h1>
<p className="text-on-surface-variant text-sm">{content.subtitle}</p>
</div>
</div>
<div className="w-full max-w-sm bg-surface-container rounded-2xl p-6 flex flex-col items-center gap-4 shadow-elevation-1">
<label className="text-xs text-on-surface-variant font-bold tracking-wide flex items-center gap-2">
<User size={14} />
{t('my_weight', lang)}
</label>
<div className="flex items-center gap-4">
<input
type="number"
step="0.1"
className="text-5xl font-normal text-on-surface tabular-nums bg-transparent text-center w-full focus:outline-none"
value={userBodyWeight}
onChange={(e) => setUserBodyWeight(e.target.value)}
/>
</div>
<p className="text-[10px] text-on-surface-variant">{t('change_in_profile', lang)}</p>
</div>
<div className="w-full max-w-xs space-y-3">
<button
onClick={() => handleStart()}
className="w-full h-16 rounded-full bg-primary text-on-primary font-medium text-lg shadow-elevation-2 hover:shadow-elevation-3 active:shadow-elevation-1 transition-all flex items-center justify-center gap-2"
>
<PlayCircle size={24} />
{t('free_workout', lang)}
</button>
<button
onClick={() => setIsSporadicMode(true)}
className="w-full h-12 rounded-full bg-surface-container-high text-on-surface font-medium text-base hover:bg-surface-container-highest transition-all flex items-center justify-center gap-2"
>
<Plus size={20} />
{t('quick_log', lang)}
</button>
</div>
{plans.length > 0 ? (
<div className="w-full max-w-md mt-8">
<h3 className="text-sm text-on-surface-variant font-medium px-4 mb-3">{t('or_choose_plan', lang)}</h3>
<div className="grid grid-cols-1 gap-3">
{plans.map(plan => (
<button
key={plan.id}
onClick={() => handleStart(plan)}
className="flex items-center justify-between p-4 bg-surface-container rounded-xl hover:bg-surface-container-high transition-colors border border-outline-variant/20"
>
<div className="text-left">
<div className="text-base font-medium text-on-surface">{plan.name}</div>
<div className="text-xs text-on-surface-variant">{plan.steps.length} {t('exercises_count', lang)}</div>
</div>
<div className="w-10 h-10 rounded-full bg-secondary-container text-on-secondary-container flex items-center justify-center">
<ArrowRight size={20} />
</div>
</button>
))}
<div className="flex flex-col h-full bg-surface">
<TopBar title="Tracker" icon={Dumbbell} />
<div className="flex-1 p-4 md:p-8 overflow-y-auto relative">
<div className="flex-1 flex flex-col items-center justify-center space-y-12">
<div className="flex flex-col items-center gap-4">
<div className="w-24 h-24 rounded-full bg-surface-container-high flex items-center justify-center text-primary shadow-elevation-1">
<Dumbbell size={40} />
</div>
<div className="text-center">
<h1 className={`text-3xl font-normal ${content.colorClass}`}>{content.title}</h1>
<p className="text-on-surface-variant text-sm">{content.subtitle}</p>
</div>
</div>
) : (
<div className="w-full max-w-md mt-8 text-center p-6 bg-surface-container rounded-2xl border border-outline-variant/20">
<p className="text-on-surface-variant mb-4">{t('no_plans_yet', lang)}</p>
<div className="flex flex-col gap-2">
<a
href="/plans?aiPrompt=true"
className="text-primary font-medium hover:underline"
>
{t('ask_ai_to_create', lang)}
</a>
<a
href="/plans?create=true"
className="text-primary font-medium hover:underline"
>
{t('create_manually', lang)}
</a>
<div className="w-full max-w-sm bg-surface-container rounded-2xl p-6 flex flex-col items-center gap-4 shadow-elevation-1">
<label className="text-xs text-on-surface-variant font-bold tracking-wide flex items-center gap-2">
<User size={14} />
{t('my_weight', lang)}
</label>
<div className="flex items-center gap-4">
<input
type="number"
step="0.1"
className="text-5xl font-normal text-on-surface tabular-nums bg-transparent text-center w-full focus:outline-none"
value={userBodyWeight}
onChange={(e) => setUserBodyWeight(e.target.value)}
/>
</div>
<p className="text-[10px] text-on-surface-variant">{t('change_in_profile', lang)}</p>
</div>
<div className="w-full max-w-xs space-y-3">
<button
onClick={() => handleStart()}
className="w-full h-16 rounded-full bg-primary text-on-primary font-medium text-lg shadow-elevation-2 hover:shadow-elevation-3 active:shadow-elevation-1 transition-all flex items-center justify-center gap-2"
>
<PlayCircle size={24} />
{t('free_workout', lang)}
</button>
<button
onClick={() => setIsSporadicMode(true)}
className="w-full h-12 rounded-full bg-surface-container-high text-on-surface font-medium text-base hover:bg-surface-container-highest transition-all flex items-center justify-center gap-2"
>
<Plus size={20} />
{t('quick_log', lang)}
</button>
</div>
{plans.length > 0 ? (
<div className="w-full max-w-md mt-8">
<h3 className="text-sm text-on-surface-variant font-medium px-4 mb-3">{t('or_choose_plan', lang)}</h3>
<div className="grid grid-cols-1 gap-3">
{plans.map(plan => (
<button
key={plan.id}
onClick={() => handleStart(plan)}
className="flex items-center justify-between p-4 bg-surface-container rounded-xl hover:bg-surface-container-high transition-colors border border-outline-variant/20"
>
<div className="text-left">
<div className="text-base font-medium text-on-surface">{plan.name}</div>
<div className="text-xs text-on-surface-variant">{plan.steps.length} {t('exercises_count', lang)}</div>
</div>
<div className="w-10 h-10 rounded-full bg-secondary-container text-on-secondary-container flex items-center justify-center">
<ArrowRight size={20} />
</div>
</button>
))}
</div>
</div>
) : (
<div className="w-full max-w-md mt-8 text-center p-6 bg-surface-container rounded-2xl border border-outline-variant/20">
<p className="text-on-surface-variant mb-4">{t('no_plans_yet', lang)}</p>
<div className="flex flex-col gap-2">
<a
href="/plans?aiPrompt=true"
className="text-primary font-medium hover:underline"
>
{t('ask_ai_to_create', lang)}
</a>
<a
href="/plans?create=true"
className="text-primary font-medium hover:underline"
>
{t('create_manually', lang)}
</a>
</div>
</div>
)}
</div>
{showPlanPrep && (
<div className="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-6 backdrop-blur-sm">
<div className="w-full max-w-sm bg-surface-container rounded-[28px] p-6 shadow-elevation-3">
<h3 className="text-2xl font-normal text-on-surface mb-4">{showPlanPrep.name}</h3>
<div className="bg-surface-container-high p-4 rounded-xl text-on-surface-variant text-sm mb-8">
<div className="text-xs font-bold text-primary mb-2">{t('prep_title', lang)}</div>
{showPlanPrep.description || t('prep_no_instructions', lang)}
</div>
<div className="flex justify-end gap-2">
<button onClick={() => setShowPlanPrep(null)} className="px-6 py-2.5 rounded-full text-primary font-medium hover:bg-white/5">{t('cancel', lang)}</button>
<button onClick={confirmPlanStart} className="px-6 py-2.5 rounded-full bg-primary text-on-primary font-medium">{t('start', lang)}</button>
</div>
</div>
</div>
)}
</div>
{showPlanPrep && (
<div className="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-6 backdrop-blur-sm">
<div className="w-full max-w-sm bg-surface-container rounded-[28px] p-6 shadow-elevation-3">
<h3 className="text-2xl font-normal text-on-surface mb-4">{showPlanPrep.name}</h3>
<div className="bg-surface-container-high p-4 rounded-xl text-on-surface-variant text-sm mb-8">
<div className="text-xs font-bold text-primary mb-2">{t('prep_title', lang)}</div>
{showPlanPrep.description || t('prep_no_instructions', lang)}
</div>
<div className="flex justify-end gap-2">
<button onClick={() => setShowPlanPrep(null)} className="px-6 py-2.5 rounded-full text-primary font-medium hover:bg-white/5">{t('cancel', lang)}</button>
<button onClick={confirmPlanStart} className="px-6 py-2.5 rounded-full bg-primary text-on-primary font-medium">{t('start', lang)}</button>
</div>
</div>
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,22 @@
import React from 'react';
import { LucideIcon } from 'lucide-react';
interface TopBarProps {
title: string;
icon?: LucideIcon;
actions?: React.ReactNode;
}
export const TopBar: React.FC<TopBarProps> = ({ title, icon: Icon, actions }) => {
return (
<div className="p-4 bg-surface-container shadow-elevation-1 flex items-center gap-3 z-10 shrink-0">
{Icon && (
<div className="w-10 h-10 rounded-full bg-secondary-container flex items-center justify-center">
<Icon size={20} className="text-on-secondary-container" />
</div>
)}
<h2 className="text-xl font-normal text-on-surface flex-1">{title}</h2>
{actions && <div className="flex items-center gap-2">{actions}</div>}
</div>
);
};