Add mobile floating banners for specialization status and course selection progress
On mobile, the single-column layout makes it easy to lose context when scrolling between the specializations and course selection panels. This adds two floating banners that appear via IntersectionObserver: - Top banner: summarizes specialization statuses (achieved/achievable/missing/unreachable) - Bottom banner: shows course selection progress (N/12 selected) Both slide in/out with CSS transitions and scroll to their respective sections on tap. Only rendered on mobile viewports (max-width: 639px).
This commit is contained in:
+58
-3
@@ -1,4 +1,4 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useMemo, useRef, useState, useEffect, useCallback } from 'react';
|
||||
import { useAppState } from './state/appState';
|
||||
import { useMediaQuery } from './hooks/useMediaQuery';
|
||||
import { SpecializationRanking } from './components/SpecializationRanking';
|
||||
@@ -6,6 +6,8 @@ import { ModeToggle } from './components/ModeToggle';
|
||||
import { CourseSelection } from './components/CourseSelection';
|
||||
import { CreditLegend } from './components/CreditLegend';
|
||||
import { ModeComparison } from './components/Notifications';
|
||||
import { MobileStatusBanner } from './components/MobileStatusBanner';
|
||||
import { MobileCourseBanner } from './components/MobileCourseBanner';
|
||||
import { optimize } from './solver/optimizer';
|
||||
|
||||
function App() {
|
||||
@@ -34,6 +36,44 @@ function App() {
|
||||
|
||||
const isMobile = breakpoint === 'mobile';
|
||||
|
||||
const specSectionRef = useRef<HTMLDivElement>(null);
|
||||
const [bannerVisible, setBannerVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isMobile || !specSectionRef.current) {
|
||||
setBannerVisible(false);
|
||||
return;
|
||||
}
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => setBannerVisible(!entry.isIntersecting),
|
||||
);
|
||||
observer.observe(specSectionRef.current);
|
||||
return () => observer.disconnect();
|
||||
}, [isMobile]);
|
||||
|
||||
const handleBannerTap = useCallback(() => {
|
||||
specSectionRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, []);
|
||||
|
||||
const courseSectionRef = useRef<HTMLDivElement>(null);
|
||||
const [courseBannerVisible, setCourseBannerVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isMobile || !courseSectionRef.current) {
|
||||
setCourseBannerVisible(false);
|
||||
return;
|
||||
}
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => setCourseBannerVisible(!entry.isIntersecting),
|
||||
);
|
||||
observer.observe(courseSectionRef.current);
|
||||
return () => observer.disconnect();
|
||||
}, [isMobile]);
|
||||
|
||||
const handleCourseBannerTap = useCallback(() => {
|
||||
courseSectionRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, []);
|
||||
|
||||
const containerStyle: React.CSSProperties = {
|
||||
maxWidth: '1200px',
|
||||
margin: '0 auto',
|
||||
@@ -47,6 +87,21 @@ function App() {
|
||||
|
||||
return (
|
||||
<div style={containerStyle}>
|
||||
{isMobile && (
|
||||
<>
|
||||
<MobileStatusBanner
|
||||
statuses={optimizationResult.statuses}
|
||||
visible={bannerVisible}
|
||||
onTap={handleBannerTap}
|
||||
/>
|
||||
<MobileCourseBanner
|
||||
selectedCount={Object.keys(state.pinnedCourses).length}
|
||||
totalSets={12}
|
||||
visible={courseBannerVisible}
|
||||
onTap={handleCourseBannerTap}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<h1 style={{ fontSize: '20px', marginBottom: '12px', color: '#111' }}>
|
||||
EMBA Specialization Solver
|
||||
</h1>
|
||||
@@ -60,7 +115,7 @@ function App() {
|
||||
/>
|
||||
|
||||
<div style={panelStyle}>
|
||||
<div style={isMobile ? {} : { maxHeight: '85vh', overflowY: 'auto' }}>
|
||||
<div ref={specSectionRef} style={isMobile ? {} : { maxHeight: '85vh', overflowY: 'auto' }}>
|
||||
<CreditLegend />
|
||||
<SpecializationRanking
|
||||
ranking={state.ranking}
|
||||
@@ -68,7 +123,7 @@ function App() {
|
||||
onReorder={reorder}
|
||||
/>
|
||||
</div>
|
||||
<div style={isMobile ? {} : { maxHeight: '85vh', overflowY: 'auto' }}>
|
||||
<div ref={courseSectionRef} style={isMobile ? {} : { maxHeight: '85vh', overflowY: 'auto' }}>
|
||||
<CourseSelection
|
||||
pinnedCourses={state.pinnedCourses}
|
||||
treeResults={treeResults}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
interface MobileCourseBannerProps {
|
||||
selectedCount: number;
|
||||
totalSets: number;
|
||||
visible: boolean;
|
||||
onTap: () => void;
|
||||
}
|
||||
|
||||
export function MobileCourseBanner({ selectedCount, totalSets, visible, onTap }: MobileCourseBannerProps) {
|
||||
const bannerStyle: React.CSSProperties = {
|
||||
position: 'fixed',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
zIndex: 1000,
|
||||
background: '#fff',
|
||||
borderTop: '1px solid #ddd',
|
||||
padding: '8px 12px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '6px',
|
||||
transform: visible ? 'translateY(0)' : 'translateY(100%)',
|
||||
transition: 'transform 200ms ease-out',
|
||||
cursor: 'pointer',
|
||||
boxSizing: 'border-box',
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={bannerStyle} onClick={onTap}>
|
||||
<span style={{ fontSize: '13px', fontWeight: 600, color: '#333' }}>
|
||||
{selectedCount} / {totalSets}
|
||||
</span>
|
||||
<span style={{ fontSize: '12px', color: '#666' }}>
|
||||
courses selected
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { STATUS_STYLES } from './SpecializationRanking';
|
||||
import type { SpecStatus } from '../data/types';
|
||||
|
||||
const STATUS_ORDER: SpecStatus[] = ['achieved', 'achievable', 'missing_required', 'unreachable'];
|
||||
|
||||
interface MobileStatusBannerProps {
|
||||
statuses: Record<string, SpecStatus>;
|
||||
visible: boolean;
|
||||
onTap: () => void;
|
||||
}
|
||||
|
||||
export function MobileStatusBanner({ statuses, visible, onTap }: MobileStatusBannerProps) {
|
||||
const counts: Record<SpecStatus, number> = { achieved: 0, achievable: 0, missing_required: 0, unreachable: 0 };
|
||||
for (const status of Object.values(statuses)) {
|
||||
counts[status]++;
|
||||
}
|
||||
|
||||
const bannerStyle: React.CSSProperties = {
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
zIndex: 1000,
|
||||
background: '#fff',
|
||||
borderBottom: '1px solid #ddd',
|
||||
padding: '8px 12px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '8px',
|
||||
flexWrap: 'wrap',
|
||||
transform: visible ? 'translateY(0)' : 'translateY(-100%)',
|
||||
transition: 'transform 200ms ease-out',
|
||||
cursor: 'pointer',
|
||||
boxSizing: 'border-box',
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={bannerStyle} onClick={onTap}>
|
||||
{STATUS_ORDER.map((key) => {
|
||||
const style = STATUS_STYLES[key];
|
||||
return (
|
||||
<span
|
||||
key={key}
|
||||
style={{
|
||||
fontSize: '11px',
|
||||
padding: '2px 8px',
|
||||
borderRadius: '10px',
|
||||
background: style.color + '20',
|
||||
color: style.color,
|
||||
fontWeight: 600,
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{counts[key]} {style.label}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -21,7 +21,7 @@ import { SPECIALIZATIONS } from '../data/specializations';
|
||||
import { courseById } from '../data/lookups';
|
||||
import type { SpecStatus, AllocationResult } from '../data/types';
|
||||
|
||||
const STATUS_STYLES: Record<SpecStatus, { bg: string; color: string; label: string }> = {
|
||||
export const STATUS_STYLES: Record<SpecStatus, { bg: string; color: string; label: string }> = {
|
||||
achieved: { bg: '#dcfce7', color: '#16a34a', label: 'Achieved' },
|
||||
achievable: { bg: '#dbeafe', color: '#2563eb', label: 'Achievable' },
|
||||
missing_required: { bg: '#fef3c7', color: '#d97706', label: 'Missing Req.' },
|
||||
|
||||
Reference in New Issue
Block a user