feat: adaptation mobile — drawer bottom + layout responsive
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/push/woodpecker Pipeline was successful
Sur smartphone (< 640px) : panneau stats masqué par défaut, accessible via un bottom drawer animé (bouton ☰). PeriodSelector passe en flex-wrap avec padding tactile 44px. AnimationPlayer s'adapte à la largeur écran. Badge ville focus affiché directement sur la carte en mode mobile. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+61
-15
@@ -11,6 +11,7 @@ import type { TransactionArc } from './data/arcData';
|
|||||||
import { computeStats } from './data/mockData';
|
import { computeStats } from './data/mockData';
|
||||||
import { computeFlowStats } from './data/arcData';
|
import { computeFlowStats } from './data/arcData';
|
||||||
import { useAnimation } from './hooks/useAnimation';
|
import { useAnimation } from './hooks/useAnimation';
|
||||||
|
import { useMediaQuery } from './hooks/useMediaQuery';
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const [periodDays, setPeriodDays] = useState(7);
|
const [periodDays, setPeriodDays] = useState(7);
|
||||||
@@ -25,6 +26,8 @@ export default function App() {
|
|||||||
const [allTimestamps, setAllTimestamps] = useState<number[]>([]);
|
const [allTimestamps, setAllTimestamps] = useState<number[]>([]);
|
||||||
const [viewMode, setViewMode] = useState<'heatmap' | 'flow'>('heatmap');
|
const [viewMode, setViewMode] = useState<'heatmap' | 'flow'>('heatmap');
|
||||||
const [focusCity, setFocusCity] = useState<string | null>(null);
|
const [focusCity, setFocusCity] = useState<string | null>(null);
|
||||||
|
const [panelOpen, setPanelOpen] = useState(false);
|
||||||
|
const isMobile = useMediaQuery('(max-width: 639px)');
|
||||||
|
|
||||||
const animation = useAnimation(transactions, arcs, periodDays, allTimestamps);
|
const animation = useAnimation(transactions, arcs, periodDays, allTimestamps);
|
||||||
|
|
||||||
@@ -86,20 +89,22 @@ export default function App() {
|
|||||||
[arcs, animation.visibleArcs, animation.active],
|
[arcs, animation.visibleArcs, animation.active],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const statsPanelProps = {
|
||||||
|
stats: visibleStats,
|
||||||
|
loading,
|
||||||
|
periodDays,
|
||||||
|
source,
|
||||||
|
currentUD,
|
||||||
|
animationLabel: animation.active ? (animation.currentFrame?.label ?? undefined) : undefined,
|
||||||
|
viewMode,
|
||||||
|
flowStats,
|
||||||
|
focusCity,
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-svh w-full overflow-hidden bg-[#0a0b0f] text-white">
|
<div className="flex h-svh w-full overflow-hidden bg-[#0a0b0f] text-white">
|
||||||
{/* Side panel */}
|
{/* Side panel — desktop uniquement */}
|
||||||
<StatsPanel
|
{!isMobile && <StatsPanel {...statsPanelProps} />}
|
||||||
stats={visibleStats}
|
|
||||||
loading={loading}
|
|
||||||
periodDays={periodDays}
|
|
||||||
source={source}
|
|
||||||
currentUD={currentUD}
|
|
||||||
animationLabel={animation.active ? (animation.currentFrame?.label ?? undefined) : undefined}
|
|
||||||
viewMode={viewMode}
|
|
||||||
flowStats={flowStats}
|
|
||||||
focusCity={focusCity}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Map area */}
|
{/* Map area */}
|
||||||
<div className="relative flex-1 min-w-0">
|
<div className="relative flex-1 min-w-0">
|
||||||
@@ -113,8 +118,19 @@ export default function App() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Bouton menu — mobile uniquement */}
|
||||||
|
{isMobile && (
|
||||||
|
<button
|
||||||
|
onClick={() => setPanelOpen(true)}
|
||||||
|
className="absolute top-4 left-4 z-[1001] w-10 h-10 bg-[#0a0b0f]/90 backdrop-blur-sm border border-[#2e2f3a] rounded-xl flex items-center justify-center text-[#d4a843] text-lg"
|
||||||
|
aria-label="Ouvrir le panneau"
|
||||||
|
>
|
||||||
|
☰
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Period selector — floating over map */}
|
{/* Period selector — floating over map */}
|
||||||
<div className="absolute top-4 left-1/2 -translate-x-1/2 z-[1000]">
|
<div className={`absolute top-4 z-[1000] ${isMobile ? 'left-16 right-4' : 'left-1/2 -translate-x-1/2'}`}>
|
||||||
<PeriodSelector
|
<PeriodSelector
|
||||||
value={periodDays}
|
value={periodDays}
|
||||||
onChange={handlePeriodChange}
|
onChange={handlePeriodChange}
|
||||||
@@ -125,8 +141,16 @@ export default function App() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Transaction count + source badge (masqués en mode animation) */}
|
{/* Badge ville focus — mobile uniquement */}
|
||||||
{!loading && !animation.active && (
|
{isMobile && focusCity && (
|
||||||
|
<div className="absolute top-20 left-1/2 -translate-x-1/2 z-[1001] flex items-center gap-2 bg-[#0a0b0f]/90 backdrop-blur-sm border border-[#d4a843]/40 rounded-full px-3 py-1.5">
|
||||||
|
<span className="text-[#d4a843] text-xs font-medium">{focusCity}</span>
|
||||||
|
<button onClick={() => setFocusCity(null)} className="text-[#4b5563] hover:text-white text-xs leading-none">✕</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Transaction count + source badge (masqués sur mobile et en mode animation) */}
|
||||||
|
{!loading && !animation.active && !isMobile && (
|
||||||
<div className="absolute bottom-8 left-1/2 -translate-x-1/2 z-[1000] flex items-center gap-2">
|
<div className="absolute bottom-8 left-1/2 -translate-x-1/2 z-[1000] flex items-center gap-2">
|
||||||
<div className="bg-[#0a0b0f]/80 backdrop-blur-sm border border-[#2e2f3a] rounded-full px-4 py-1.5 text-xs text-[#6b7280]">
|
<div className="bg-[#0a0b0f]/80 backdrop-blur-sm border border-[#2e2f3a] rounded-full px-4 py-1.5 text-xs text-[#6b7280]">
|
||||||
<span className="text-[#d4a843] font-medium">{transactions.length}</span> transactions affichées
|
<span className="text-[#d4a843] font-medium">{transactions.length}</span> transactions affichées
|
||||||
@@ -168,6 +192,28 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Bottom drawer — mobile uniquement */}
|
||||||
|
{isMobile && (
|
||||||
|
<>
|
||||||
|
{/* Overlay */}
|
||||||
|
{panelOpen && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-[1009] bg-black/50"
|
||||||
|
onClick={() => setPanelOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{/* Drawer */}
|
||||||
|
<div
|
||||||
|
className={`fixed bottom-0 left-0 right-0 z-[1010] h-[85vh] flex flex-col transition-transform duration-300 ${panelOpen ? 'translate-y-0' : 'translate-y-full'}`}
|
||||||
|
>
|
||||||
|
<div className="flex justify-center pt-2 pb-1 bg-[#0a0b0f] rounded-t-2xl border-t border-x border-[#2e2f3a] shrink-0">
|
||||||
|
<div className="w-10 h-1 rounded-full bg-[#2e2f3a]" />
|
||||||
|
</div>
|
||||||
|
<StatsPanel {...statsPanelProps} onClose={() => setPanelOpen(false)} />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,8 +26,8 @@ export function AnimationPlayer({
|
|||||||
const frame = frames[currentIndex];
|
const frame = frames[currentIndex];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 z-[1001] w-[min(640px,90vw)]">
|
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 z-[1001] w-[min(640px,calc(100vw-1rem))]">
|
||||||
<div className="bg-[#0a0b0f]/90 backdrop-blur-sm border border-[#2e2f3a] rounded-2xl px-5 py-3 flex flex-col gap-2.5 shadow-xl">
|
<div className="bg-[#0a0b0f]/90 backdrop-blur-sm border border-[#2e2f3a] rounded-2xl px-4 py-3 flex flex-col gap-2.5 shadow-xl">
|
||||||
|
|
||||||
{/* Frame label + position */}
|
{/* Frame label + position */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@@ -50,7 +50,7 @@ export function AnimationPlayer({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Controls row */}
|
{/* Controls row */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex flex-wrap items-center justify-between gap-y-2">
|
||||||
|
|
||||||
{/* Playback buttons */}
|
{/* Playback buttons */}
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
|
|||||||
@@ -41,13 +41,13 @@ export function PeriodSelector({ value, onChange, animationActive, onAnimate, vi
|
|||||||
const isCustomActive = !PRESET_DAYS.has(value);
|
const isCustomActive = !PRESET_DAYS.has(value);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex gap-1 bg-[#0f1016] border border-[#2e2f3a] rounded-lg p-1 items-center">
|
<div className="flex flex-wrap gap-1 bg-[#0f1016] border border-[#2e2f3a] rounded-lg p-1 items-center max-w-[calc(100vw-2rem)]">
|
||||||
{PERIODS.map(({ label, days }) => (
|
{PERIODS.map(({ label, days }) => (
|
||||||
<button
|
<button
|
||||||
key={days}
|
key={days}
|
||||||
onClick={() => { onChange(days); setCustomOpen(false); }}
|
onClick={() => { onChange(days); setCustomOpen(false); }}
|
||||||
className={`
|
className={`
|
||||||
px-3 py-1.5 rounded-md text-sm font-medium transition-all duration-200 cursor-pointer
|
px-3 py-2.5 sm:py-1.5 rounded-md text-sm font-medium transition-all duration-200 cursor-pointer
|
||||||
${value === days && !customOpen
|
${value === days && !customOpen
|
||||||
? 'bg-[#d4a843] text-[#0a0b0f] shadow-[0_0_12px_rgba(212,168,67,0.4)]'
|
? 'bg-[#d4a843] text-[#0a0b0f] shadow-[0_0_12px_rgba(212,168,67,0.4)]'
|
||||||
: 'text-[#6b7280] hover:text-[#d4a843] hover:bg-[#1a1b23]'
|
: 'text-[#6b7280] hover:text-[#d4a843] hover:bg-[#1a1b23]'
|
||||||
@@ -84,7 +84,7 @@ export function PeriodSelector({ value, onChange, animationActive, onAnimate, vi
|
|||||||
<button
|
<button
|
||||||
onClick={openCustom}
|
onClick={openCustom}
|
||||||
className={`
|
className={`
|
||||||
px-3 py-1.5 rounded-md text-sm font-medium transition-all duration-200 cursor-pointer
|
px-3 py-2.5 sm:py-1.5 rounded-md text-sm font-medium transition-all duration-200 cursor-pointer
|
||||||
${isCustomActive
|
${isCustomActive
|
||||||
? 'bg-[#d4a843] text-[#0a0b0f] shadow-[0_0_12px_rgba(212,168,67,0.4)]'
|
? 'bg-[#d4a843] text-[#0a0b0f] shadow-[0_0_12px_rgba(212,168,67,0.4)]'
|
||||||
: 'text-[#6b7280] hover:text-[#d4a843] hover:bg-[#1a1b23]'
|
: 'text-[#6b7280] hover:text-[#d4a843] hover:bg-[#1a1b23]'
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ interface StatsPanelProps {
|
|||||||
viewMode?: 'heatmap' | 'flow';
|
viewMode?: 'heatmap' | 'flow';
|
||||||
flowStats?: FlowStats | null;
|
flowStats?: FlowStats | null;
|
||||||
focusCity?: string | null;
|
focusCity?: string | null;
|
||||||
|
onClose?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MEDALS = ['🥇', '🥈', '🥉'];
|
const MEDALS = ['🥇', '🥈', '🥉'];
|
||||||
@@ -58,7 +59,7 @@ function CityRow({ city, volume, count, countryCode, accent }: {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function StatsPanel({ stats, loading, periodDays, source, currentUD, animationLabel, viewMode = 'heatmap', flowStats, focusCity }: StatsPanelProps) {
|
export function StatsPanel({ stats, loading, periodDays, source, currentUD, animationLabel, viewMode = 'heatmap', flowStats, focusCity, onClose }: StatsPanelProps) {
|
||||||
const periodLabel = periodDays === 1 ? '24 dernières heures' : `${periodDays} derniers jours`;
|
const periodLabel = periodDays === 1 ? '24 dernières heures' : `${periodDays} derniers jours`;
|
||||||
const prevStats = useRef<PeriodStats | null>(null);
|
const prevStats = useRef<PeriodStats | null>(null);
|
||||||
|
|
||||||
@@ -82,7 +83,7 @@ export function StatsPanel({ stats, loading, periodDays, source, currentUD, anim
|
|||||||
if (stats && !loading) prevStats.current = stats;
|
if (stats && !loading) prevStats.current = stats;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="w-72 shrink-0 flex flex-col gap-4 bg-[#0a0b0f]/95 backdrop-blur-sm border-r border-[#1e1f2a] p-5 overflow-y-auto">
|
<aside className="w-full lg:w-72 shrink-0 flex flex-col gap-4 bg-[#0a0b0f]/95 backdrop-blur-sm border-r border-[#1e1f2a] p-5 overflow-y-auto h-full">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center gap-2.5">
|
<div className="flex items-center gap-2.5">
|
||||||
<div className="w-8 h-8 rounded-full bg-[#d4a843] flex items-center justify-center text-[#0a0b0f] font-bold text-sm shadow-[0_0_16px_rgba(212,168,67,0.5)]">
|
<div className="w-8 h-8 rounded-full bg-[#d4a843] flex items-center justify-center text-[#0a0b0f] font-bold text-sm shadow-[0_0_16px_rgba(212,168,67,0.5)]">
|
||||||
@@ -95,6 +96,15 @@ export function StatsPanel({ stats, loading, periodDays, source, currentUD, anim
|
|||||||
</h1>
|
</h1>
|
||||||
<p className="text-[#4b5563] text-xs">Monnaie libre · Flux géo</p>
|
<p className="text-[#4b5563] text-xs">Monnaie libre · Flux géo</p>
|
||||||
</div>
|
</div>
|
||||||
|
{onClose && (
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="ml-auto text-[#4b5563] hover:text-white transition-colors p-1 text-lg leading-none"
|
||||||
|
aria-label="Fermer"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Description */}
|
{/* Description */}
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
export function useMediaQuery(query: string): boolean {
|
||||||
|
const [matches, setMatches] = useState(() => window.matchMedia(query).matches);
|
||||||
|
useEffect(() => {
|
||||||
|
const mq = window.matchMedia(query);
|
||||||
|
const handler = (e: MediaQueryListEvent) => setMatches(e.matches);
|
||||||
|
mq.addEventListener('change', handler);
|
||||||
|
return () => mq.removeEventListener('change', handler);
|
||||||
|
}, [query]);
|
||||||
|
return matches;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user