AI Coach messages bookmarking. Top bar refined.
This commit is contained in:
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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">
|
||||
|
||||
114
src/components/SavedMessagesSheet.tsx
Normal file
114
src/components/SavedMessagesSheet.tsx
Normal 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;
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
22
src/components/ui/TopBar.tsx
Normal file
22
src/components/ui/TopBar.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user