Side Sheets instead of Modals
This commit is contained in:
@@ -3,7 +3,7 @@ import { Dumbbell, Activity, Percent, Timer, ArrowRight, ArrowUp, Save, Calendar
|
|||||||
import { WorkoutSet, ExerciseType, ExerciseDef, Language } from '../types';
|
import { WorkoutSet, ExerciseType, ExerciseDef, Language } from '../types';
|
||||||
import { t } from '../services/i18n';
|
import { t } from '../services/i18n';
|
||||||
import { formatSetMetrics } from '../utils/setFormatting';
|
import { formatSetMetrics } from '../utils/setFormatting';
|
||||||
import { Modal } from './ui/Modal';
|
import { SideSheet } from './ui/SideSheet';
|
||||||
import { Button } from './ui/Button';
|
import { Button } from './ui/Button';
|
||||||
|
|
||||||
interface EditSetModalProps {
|
interface EditSetModalProps {
|
||||||
@@ -84,11 +84,11 @@ const EditSetModal: React.FC<EditSetModalProps> = ({
|
|||||||
const hasHeight = ['HIGH_JUMP', 'High Jump'].includes(type) || set.height !== null;
|
const hasHeight = ['HIGH_JUMP', 'High Jump'].includes(type) || set.height !== null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<SideSheet
|
||||||
isOpen={isOpen}
|
isOpen={isOpen}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
title={t('edit_set', lang) || 'Edit Set'}
|
title={t('edit_set', lang) || 'Edit Set'}
|
||||||
maxWidth="sm"
|
width="md"
|
||||||
>
|
>
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
@@ -210,7 +210,7 @@ const EditSetModal: React.FC<EditSetModalProps> = ({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</SideSheet>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { ExerciseDef, ExerciseType, Language } from '../types';
|
|||||||
import { t } from '../services/i18n';
|
import { t } from '../services/i18n';
|
||||||
import { generateId } from '../utils/uuid';
|
import { generateId } from '../utils/uuid';
|
||||||
import FilledInput from './FilledInput';
|
import FilledInput from './FilledInput';
|
||||||
import { Modal } from './ui/Modal';
|
import { SideSheet } from './ui/SideSheet';
|
||||||
import { Button } from './ui/Button';
|
import { Button } from './ui/Button';
|
||||||
|
|
||||||
interface ExerciseModalProps {
|
interface ExerciseModalProps {
|
||||||
@@ -64,19 +64,19 @@ const ExerciseModal: React.FC<ExerciseModalProps> = ({ isOpen, onClose, onSave,
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<SideSheet
|
||||||
isOpen={isOpen}
|
isOpen={isOpen}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
console.log('ExerciseModal onClose');
|
console.log('ExerciseModal onClose');
|
||||||
onClose();
|
onClose();
|
||||||
}}
|
}}
|
||||||
title={t('create_exercise', lang)}
|
title={t('create_exercise', lang)}
|
||||||
maxWidth="sm"
|
width="md"
|
||||||
>
|
>
|
||||||
{console.log('ExerciseModal Rendering. isOpen:', isOpen)}
|
{console.log('ExerciseModal Rendering. isOpen:', isOpen)}
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<FilledInput
|
<GymFilledInput
|
||||||
label={t('ex_name', lang)}
|
label={t('ex_name', lang)}
|
||||||
value={newName}
|
value={newName}
|
||||||
onChange={(e: any) => {
|
onChange={(e: any) => {
|
||||||
@@ -120,7 +120,7 @@ const ExerciseModal: React.FC<ExerciseModalProps> = ({ isOpen, onClose, onSave,
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{newType === ExerciseType.BODYWEIGHT && (
|
{newType === ExerciseType.BODYWEIGHT && (
|
||||||
<FilledInput
|
<GymFilledInput
|
||||||
label={t('body_weight_percent', lang)}
|
label={t('body_weight_percent', lang)}
|
||||||
value={newBwPercentage}
|
value={newBwPercentage}
|
||||||
onChange={(e: any) => setNewBwPercentage(e.target.value)}
|
onChange={(e: any) => setNewBwPercentage(e.target.value)}
|
||||||
@@ -150,7 +150,7 @@ const ExerciseModal: React.FC<ExerciseModalProps> = ({ isOpen, onClose, onSave,
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</SideSheet>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { getExercises } from '../services/storage';
|
|||||||
import { Button } from './ui/Button';
|
import { Button } from './ui/Button';
|
||||||
import { Card } from './ui/Card';
|
import { Card } from './ui/Card';
|
||||||
import { Modal } from './ui/Modal';
|
import { Modal } from './ui/Modal';
|
||||||
|
import { SideSheet } from './ui/SideSheet';
|
||||||
import EditSetModal from './EditSetModal';
|
import EditSetModal from './EditSetModal';
|
||||||
import FilledInput from './FilledInput';
|
import FilledInput from './FilledInput';
|
||||||
|
|
||||||
@@ -366,11 +367,11 @@ const History: React.FC<HistoryProps> = ({ lang }) => {
|
|||||||
|
|
||||||
{/* EDIT SESSION MODAL */}
|
{/* EDIT SESSION MODAL */}
|
||||||
{editingSession && (
|
{editingSession && (
|
||||||
<Modal
|
<SideSheet
|
||||||
isOpen={!!editingSession}
|
isOpen={!!editingSession}
|
||||||
onClose={() => setEditingSession(null)}
|
onClose={() => setEditingSession(null)}
|
||||||
title={t('edit', lang)}
|
title={t('edit', lang)}
|
||||||
maxWidth="lg"
|
width="lg"
|
||||||
>
|
>
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Meta Info */}
|
{/* Meta Info */}
|
||||||
@@ -449,7 +450,7 @@ const History: React.FC<HistoryProps> = ({ lang }) => {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</SideSheet>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
{editingSetInfo && (
|
{editingSetInfo && (
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { toTitleCase } from '../utils/text';
|
|||||||
import { Button } from './ui/Button';
|
import { Button } from './ui/Button';
|
||||||
import { Card } from './ui/Card';
|
import { Card } from './ui/Card';
|
||||||
import { Modal } from './ui/Modal';
|
import { Modal } from './ui/Modal';
|
||||||
|
import { SideSheet } from './ui/SideSheet';
|
||||||
|
|
||||||
interface PlansProps {
|
interface PlansProps {
|
||||||
lang: Language;
|
lang: Language;
|
||||||
@@ -297,11 +298,11 @@ const Plans: React.FC<PlansProps> = ({ lang }) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Modal
|
<SideSheet
|
||||||
isOpen={showExerciseSelector}
|
isOpen={showExerciseSelector}
|
||||||
onClose={() => setShowExerciseSelector(false)}
|
onClose={() => setShowExerciseSelector(false)}
|
||||||
title={t('select_exercise', lang)}
|
title={t('select_exercise', lang)}
|
||||||
maxWidth="md"
|
width="lg"
|
||||||
>
|
>
|
||||||
<div className="flex flex-col h-[60vh]">
|
<div className="flex flex-col h-[60vh]">
|
||||||
<div className="flex justify-end mb-2">
|
<div className="flex justify-end mb-2">
|
||||||
@@ -325,13 +326,13 @@ const Plans: React.FC<PlansProps> = ({ lang }) => {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</SideSheet>
|
||||||
|
|
||||||
<Modal
|
<SideSheet
|
||||||
isOpen={isCreatingExercise}
|
isOpen={isCreatingExercise}
|
||||||
onClose={() => setIsCreatingExercise(false)}
|
onClose={() => setIsCreatingExercise(false)}
|
||||||
title={t('create_exercise', lang)}
|
title={t('create_exercise', lang)}
|
||||||
maxWidth="md"
|
width="md"
|
||||||
>
|
>
|
||||||
<div className="space-y-6 pt-2">
|
<div className="space-y-6 pt-2">
|
||||||
<FilledInput
|
<FilledInput
|
||||||
@@ -390,7 +391,7 @@ const Plans: React.FC<PlansProps> = ({ lang }) => {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</SideSheet>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -468,19 +469,23 @@ const Plans: React.FC<PlansProps> = ({ lang }) => {
|
|||||||
|
|
||||||
{/* Preparation Modal */}
|
{/* Preparation Modal */}
|
||||||
{showPlanPrep && (
|
{showPlanPrep && (
|
||||||
<div className="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-6 backdrop-blur-sm">
|
<SideSheet
|
||||||
<div className="w-full max-w-sm bg-surface-container rounded-[28px] p-6 shadow-elevation-3">
|
isOpen={!!showPlanPrep}
|
||||||
<h3 className="text-2xl font-normal text-on-surface mb-4">{showPlanPrep.name}</h3>
|
onClose={() => setShowPlanPrep(null)}
|
||||||
<div className="bg-surface-container-high p-4 rounded-xl text-on-surface-variant text-sm mb-8">
|
title={showPlanPrep.name}
|
||||||
|
width="md"
|
||||||
|
>
|
||||||
|
<div className="space-y-8">
|
||||||
|
<div className="bg-surface-container-high p-4 rounded-xl text-on-surface-variant text-sm">
|
||||||
<div className="text-xs font-bold text-primary mb-2">{t('prep_title', lang)}</div>
|
<div className="text-xs font-bold text-primary mb-2">{t('prep_title', lang)}</div>
|
||||||
{showPlanPrep.description || t('prep_no_instructions', lang)}
|
{showPlanPrep.description || t('prep_no_instructions', lang)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-end gap-2">
|
<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={() => setShowPlanPrep(null)} variant="ghost">{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>
|
<Button onClick={confirmPlanStart}>{t('start', lang)}</Button>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</SideSheet>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import Snackbar from './Snackbar';
|
|||||||
import { Button } from './ui/Button';
|
import { Button } from './ui/Button';
|
||||||
import { Card } from './ui/Card';
|
import { Card } from './ui/Card';
|
||||||
import { Modal } from './ui/Modal';
|
import { Modal } from './ui/Modal';
|
||||||
|
import { SideSheet } from './ui/SideSheet';
|
||||||
|
|
||||||
interface ProfileProps {
|
interface ProfileProps {
|
||||||
user: User;
|
user: User;
|
||||||
@@ -601,11 +602,11 @@ const Profile: React.FC<ProfileProps> = ({ user, onLogout, lang, onLanguageChang
|
|||||||
|
|
||||||
{/* Edit Exercise Modal */}
|
{/* Edit Exercise Modal */}
|
||||||
{editingExercise && (
|
{editingExercise && (
|
||||||
<Modal
|
<SideSheet
|
||||||
isOpen={!!editingExercise}
|
isOpen={!!editingExercise}
|
||||||
onClose={() => setEditingExercise(null)}
|
onClose={() => setEditingExercise(null)}
|
||||||
title={t('edit', lang)}
|
title={t('edit', lang)}
|
||||||
maxWidth="sm"
|
width="md"
|
||||||
>
|
>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="bg-surface-container-high rounded-t-lg border-b border-outline-variant px-3 py-2">
|
<div className="bg-surface-container-high rounded-t-lg border-b border-outline-variant px-3 py-2">
|
||||||
@@ -632,7 +633,7 @@ const Profile: React.FC<ProfileProps> = ({ user, onLogout, lang, onLanguageChang
|
|||||||
<Button onClick={handleSaveExerciseEdit}>{t('save', lang)}</Button>
|
<Button onClick={handleSaveExerciseEdit}>{t('save', lang)}</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</SideSheet>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Create Exercise Modal */}
|
{/* Create Exercise Modal */}
|
||||||
|
|||||||
107
src/components/ui/SideSheet.tsx
Normal file
107
src/components/ui/SideSheet.tsx
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
|
||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { X } from 'lucide-react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
|
||||||
|
interface SideSheetProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
title?: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
width?: 'sm' | 'md' | 'lg' | 'xl' | 'full';
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SideSheet: React.FC<SideSheetProps> = ({ isOpen, onClose, title, children, width = 'md' }) => {
|
||||||
|
const [isMounted, setIsMounted] = useState(false);
|
||||||
|
const [isVisible, setIsVisible] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setIsMounted(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
setIsVisible(true);
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
} else {
|
||||||
|
const timer = setTimeout(() => setIsVisible(false), 300); // Allow exit animation
|
||||||
|
document.body.style.overflow = 'unset';
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
document.body.style.overflow = 'unset';
|
||||||
|
};
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
if (!isMounted || (!isOpen && !isVisible)) return null;
|
||||||
|
|
||||||
|
// Width classes for Desktop side sheet
|
||||||
|
const widthClasses = {
|
||||||
|
sm: 'sm:max-w-sm',
|
||||||
|
md: 'sm:max-w-md',
|
||||||
|
lg: 'sm:max-w-lg',
|
||||||
|
xl: 'sm:max-w-xl',
|
||||||
|
full: 'sm:max-w-full'
|
||||||
|
};
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div
|
||||||
|
className={`fixed inset-0 z-50 flex flex-col sm:flex-row justify-end items-end sm:items-stretch transition-opacity duration-300 ${isOpen ? 'opacity-100' : 'opacity-0 pointer-events-none'}`}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
>
|
||||||
|
{/* Backdrop */}
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 bg-black/40 backdrop-blur-sm"
|
||||||
|
onClick={onClose}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Sheet */}
|
||||||
|
<div
|
||||||
|
className={`
|
||||||
|
relative
|
||||||
|
bg-surface-container
|
||||||
|
w-full
|
||||||
|
${widthClasses[width]}
|
||||||
|
|
||||||
|
/* Mobile Styles (Bottom Sheet) */
|
||||||
|
rounded-t-[28px] sm:rounded-none sm:rounded-l-[28px]
|
||||||
|
max-h-[85vh] sm:max-h-full h-full sm:h-full
|
||||||
|
flex flex-col
|
||||||
|
shadow-elevation-3
|
||||||
|
|
||||||
|
/* Animations */
|
||||||
|
transition-transform duration-300 ease-out
|
||||||
|
${isOpen
|
||||||
|
? 'translate-y-0 sm:translate-y-0 sm:translate-x-0'
|
||||||
|
: 'translate-y-full sm:translate-y-0 sm:translate-x-full'
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{/* Mobile Drag Handle */}
|
||||||
|
<div className="sm:hidden flex justify-center pt-4 pb-2 shrink-0">
|
||||||
|
<div className="w-8 h-1 bg-outline-variant rounded-full opacity-40" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between px-6 pb-6 pt-2 sm:pt-6 shrink-0 border-b border-transparent">
|
||||||
|
<h3 className="text-[22px] leading-[28px] font-normal text-on-surface">{title}</h3>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-2 -mr-2 text-on-surface-variant hover:text-on-surface hover:bg-surface-container-high rounded-full transition-colors"
|
||||||
|
aria-label="Close"
|
||||||
|
>
|
||||||
|
<X size={24} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="flex-1 overflow-y-auto px-6 pb-6 pt-0">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -97,6 +97,7 @@ const translations = {
|
|||||||
delete_set_confirm: 'Are you sure you want to delete this set?',
|
delete_set_confirm: 'Are you sure you want to delete this set?',
|
||||||
delete: 'Delete',
|
delete: 'Delete',
|
||||||
edit: 'Edit',
|
edit: 'Edit',
|
||||||
|
edit_set: 'Edit Set',
|
||||||
save: 'Save',
|
save: 'Save',
|
||||||
start_time: 'Start',
|
start_time: 'Start',
|
||||||
end_time: 'End',
|
end_time: 'End',
|
||||||
@@ -266,6 +267,7 @@ const translations = {
|
|||||||
delete_set_confirm: 'Вы уверены, что хотите удалить этот подход?',
|
delete_set_confirm: 'Вы уверены, что хотите удалить этот подход?',
|
||||||
delete: 'Удалить',
|
delete: 'Удалить',
|
||||||
edit: 'Редактировать',
|
edit: 'Редактировать',
|
||||||
|
edit_set: 'Редактировать подход',
|
||||||
save: 'Сохранить',
|
save: 'Сохранить',
|
||||||
start_time: 'Начало',
|
start_time: 'Начало',
|
||||||
end_time: 'Конец',
|
end_time: 'Конец',
|
||||||
|
|||||||
Reference in New Issue
Block a user