Add responsive design for mobile devices
- DemoMode: slide-out sidebar drawer on mobile with overlay, mobile header with menu/back buttons, dynamic phone frame scaling - Gallery: stacked header layout, smaller buttons/text, hidden zoom control on mobile, fixed 35% thumbnail scale - UserStoriesPage: collapsible filter panel with badge counter, compact priority labels, responsive padding - SpecsPage: responsive header with compact test results, collapsible filter panel with search + filter toggle button - FeatureFilter: mobile-first design with expandable filter sections Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
+117
-16
@@ -1,10 +1,20 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { PhoneFrame } from './sketchy';
|
||||
import { screens, getScreen } from '../screens';
|
||||
import { getStoriesForScreen, categoryLabels, categoryColors, priorityColors } from '../data';
|
||||
import { getStoryUrl } from '../router';
|
||||
import { ThemeToggle } from './ThemeToggle';
|
||||
|
||||
function useIsMobile(breakpoint = 768) {
|
||||
const [isMobile, setIsMobile] = useState(window.innerWidth < breakpoint);
|
||||
useEffect(() => {
|
||||
const handleResize = () => setIsMobile(window.innerWidth < breakpoint);
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
}, [breakpoint]);
|
||||
return isMobile;
|
||||
}
|
||||
|
||||
interface DemoModeProps {
|
||||
initialScreenId: string;
|
||||
onBack: () => void;
|
||||
@@ -15,6 +25,8 @@ export function DemoMode({ initialScreenId, onBack, onNavigateToStory }: DemoMod
|
||||
const [currentScreenId, setCurrentScreenId] = useState(initialScreenId);
|
||||
const [history, setHistory] = useState<string[]>([initialScreenId]);
|
||||
const [historyIndex, setHistoryIndex] = useState(0);
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
const currentScreen = getScreen(currentScreenId);
|
||||
const ScreenComponent = currentScreen?.component;
|
||||
@@ -56,7 +68,21 @@ export function DemoMode({ initialScreenId, onBack, onNavigateToStory }: DemoMod
|
||||
background: 'var(--tool-bg)',
|
||||
overflow: 'hidden',
|
||||
transition: 'background-color 0.2s ease',
|
||||
position: 'relative',
|
||||
}}>
|
||||
{/* Mobile overlay */}
|
||||
{isMobile && sidebarOpen && (
|
||||
<div
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
background: 'rgba(0,0,0,0.5)',
|
||||
zIndex: 40,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Left Sidebar */}
|
||||
<div style={{
|
||||
width: 280,
|
||||
@@ -65,7 +91,15 @@ export function DemoMode({ initialScreenId, onBack, onNavigateToStory }: DemoMod
|
||||
flexDirection: 'column',
|
||||
borderRight: '2px solid var(--tool-border)',
|
||||
background: 'var(--tool-surface)',
|
||||
transition: 'background-color 0.2s ease, border-color 0.2s ease',
|
||||
transition: 'transform 0.3s ease, background-color 0.2s ease, border-color 0.2s ease',
|
||||
...(isMobile ? {
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
bottom: 0,
|
||||
zIndex: 50,
|
||||
transform: sidebarOpen ? 'translateX(0)' : 'translateX(-100%)',
|
||||
} : {}),
|
||||
}}>
|
||||
{/* Back button and theme toggle */}
|
||||
<div style={{ padding: 16, borderBottom: '1px solid var(--tool-border-light)', display: 'flex', gap: 8 }}>
|
||||
@@ -253,24 +287,87 @@ export function DemoMode({ initialScreenId, onBack, onNavigateToStory }: DemoMod
|
||||
<div style={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 24,
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
{/* Mobile header */}
|
||||
{isMobile && (
|
||||
<div style={{
|
||||
padding: '12px 16px',
|
||||
borderBottom: '1px solid var(--tool-border-light)',
|
||||
background: 'var(--tool-surface)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: 12,
|
||||
}}>
|
||||
<button
|
||||
onClick={() => setSidebarOpen(true)}
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
background: 'none',
|
||||
border: '2px solid var(--tool-border)',
|
||||
borderRadius: '255px 15px 225px 15px/15px 225px 15px 255px',
|
||||
fontFamily: 'var(--font-sketch)',
|
||||
cursor: 'pointer',
|
||||
color: 'var(--tool-text)',
|
||||
fontSize: 14,
|
||||
}}
|
||||
>
|
||||
☰ Menu
|
||||
</button>
|
||||
<span style={{
|
||||
fontFamily: 'var(--font-sketch)',
|
||||
fontSize: 14,
|
||||
fontWeight: 'bold',
|
||||
color: 'var(--tool-text)',
|
||||
flex: 1,
|
||||
textAlign: 'center',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}>
|
||||
{currentScreen?.name}
|
||||
</span>
|
||||
<button
|
||||
onClick={onBack}
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
background: 'none',
|
||||
border: '2px solid var(--tool-border)',
|
||||
borderRadius: '255px 15px 225px 15px/15px 225px 15px 255px',
|
||||
fontFamily: 'var(--font-sketch)',
|
||||
cursor: 'pointer',
|
||||
color: 'var(--tool-text)',
|
||||
fontSize: 14,
|
||||
}}
|
||||
>
|
||||
← Retour
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div style={{
|
||||
maxHeight: '100%',
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: isMobile ? 12 : 24,
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
<div style={{
|
||||
transform: 'scale(var(--phone-scale, 1))',
|
||||
transformOrigin: 'center center',
|
||||
maxHeight: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}>
|
||||
<ScaledPhoneFrame>
|
||||
{ScreenComponent && <ScreenComponent navigate={navigate} />}
|
||||
</ScaledPhoneFrame>
|
||||
<div style={{
|
||||
transform: 'scale(var(--phone-scale, 1))',
|
||||
transformOrigin: 'center center',
|
||||
}}>
|
||||
<ScaledPhoneFrame isMobile={isMobile}>
|
||||
{ScreenComponent && <ScreenComponent navigate={navigate} />}
|
||||
</ScaledPhoneFrame>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -278,7 +375,7 @@ export function DemoMode({ initialScreenId, onBack, onNavigateToStory }: DemoMod
|
||||
);
|
||||
}
|
||||
|
||||
function ScaledPhoneFrame({ children }: { children: React.ReactNode }) {
|
||||
function ScaledPhoneFrame({ children, isMobile = false }: { children: React.ReactNode; isMobile?: boolean }) {
|
||||
const phoneWidth = 375;
|
||||
const phoneHeight = 812;
|
||||
|
||||
@@ -287,20 +384,24 @@ function ScaledPhoneFrame({ children }: { children: React.ReactNode }) {
|
||||
|
||||
React.useEffect(() => {
|
||||
const calculateScale = () => {
|
||||
const availableHeight = window.innerHeight - 48; // 24px padding on each side
|
||||
const availableWidth = window.innerWidth - 280 - 48; // sidebar + padding
|
||||
const mobileHeaderHeight = isMobile ? 56 : 0;
|
||||
const padding = isMobile ? 24 : 48;
|
||||
const sidebarWidth = isMobile ? 0 : 280;
|
||||
|
||||
const availableHeight = window.innerHeight - padding - mobileHeaderHeight;
|
||||
const availableWidth = window.innerWidth - sidebarWidth - padding;
|
||||
|
||||
const scaleByHeight = availableHeight / phoneHeight;
|
||||
const scaleByWidth = availableWidth / phoneWidth;
|
||||
|
||||
const newScale = Math.min(scaleByHeight, scaleByWidth, 1);
|
||||
setScale(Math.max(0.5, newScale)); // minimum 50% scale
|
||||
setScale(Math.max(0.4, newScale)); // minimum 40% scale for mobile
|
||||
};
|
||||
|
||||
calculateScale();
|
||||
window.addEventListener('resize', calculateScale);
|
||||
return () => window.removeEventListener('resize', calculateScale);
|
||||
}, []);
|
||||
}, [isMobile]);
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
|
||||
+59
-39
@@ -1,8 +1,18 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { PhoneFrame } from './sketchy';
|
||||
import { screenGroups, type Screen } from '../screens';
|
||||
import { ThemeToggle } from './ThemeToggle';
|
||||
|
||||
function useIsMobile(breakpoint = 768) {
|
||||
const [isMobile, setIsMobile] = useState(window.innerWidth < breakpoint);
|
||||
useEffect(() => {
|
||||
const handleResize = () => setIsMobile(window.innerWidth < breakpoint);
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
}, [breakpoint]);
|
||||
return isMobile;
|
||||
}
|
||||
|
||||
interface GalleryProps {
|
||||
onSelectScreen: (screenId: string) => void;
|
||||
onShowStories: () => void;
|
||||
@@ -15,20 +25,27 @@ const DEFAULT_SCALE = 0.5;
|
||||
|
||||
export function Gallery({ onSelectScreen, onShowStories, onShowSpecs }: GalleryProps) {
|
||||
const [scale, setScale] = useState(DEFAULT_SCALE);
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{
|
||||
padding: '24px 32px',
|
||||
padding: isMobile ? '16px' : '24px 32px',
|
||||
borderBottom: '2px solid var(--tool-border)',
|
||||
background: 'var(--tool-surface)',
|
||||
transition: 'background-color 0.2s ease, border-color 0.2s ease',
|
||||
}}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexDirection: isMobile ? 'column' : 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: isMobile ? 'stretch' : 'flex-start',
|
||||
gap: isMobile ? 16 : 0,
|
||||
}}>
|
||||
<div>
|
||||
<h1 style={{
|
||||
fontFamily: 'var(--font-sketch)',
|
||||
fontSize: 28,
|
||||
fontSize: isMobile ? 24 : 28,
|
||||
margin: 0,
|
||||
color: 'var(--tool-text)',
|
||||
}}>
|
||||
@@ -36,7 +53,7 @@ export function Gallery({ onSelectScreen, onShowStories, onShowSpecs }: GalleryP
|
||||
</h1>
|
||||
<p style={{
|
||||
fontFamily: 'var(--font-sketch)',
|
||||
fontSize: 16,
|
||||
fontSize: 14,
|
||||
color: 'var(--tool-text-muted)',
|
||||
margin: '8px 0 0 0',
|
||||
}}>
|
||||
@@ -47,7 +64,8 @@ export function Gallery({ onSelectScreen, onShowStories, onShowSpecs }: GalleryP
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 24,
|
||||
gap: isMobile ? 8 : 24,
|
||||
flexWrap: 'wrap',
|
||||
}}>
|
||||
{/* User Stories button */}
|
||||
<button
|
||||
@@ -56,9 +74,9 @@ export function Gallery({ onSelectScreen, onShowStories, onShowSpecs }: GalleryP
|
||||
background: 'none',
|
||||
border: '2px solid var(--tool-border)',
|
||||
borderRadius: '255px 15px 225px 15px/15px 225px 15px 255px',
|
||||
padding: '8px 16px',
|
||||
padding: isMobile ? '6px 12px' : '8px 16px',
|
||||
fontFamily: 'var(--font-sketch)',
|
||||
fontSize: 14,
|
||||
fontSize: isMobile ? 12 : 14,
|
||||
cursor: 'pointer',
|
||||
color: 'var(--tool-text)',
|
||||
}}
|
||||
@@ -75,9 +93,9 @@ export function Gallery({ onSelectScreen, onShowStories, onShowSpecs }: GalleryP
|
||||
color: 'var(--tool-bg)',
|
||||
border: '2px solid var(--tool-border)',
|
||||
borderRadius: '255px 15px 225px 15px/15px 225px 15px 255px',
|
||||
padding: '8px 16px',
|
||||
padding: isMobile ? '6px 12px' : '8px 16px',
|
||||
fontFamily: 'var(--font-sketch)',
|
||||
fontSize: 14,
|
||||
fontSize: isMobile ? 12 : 14,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
@@ -85,27 +103,29 @@ export function Gallery({ onSelectScreen, onShowStories, onShowSpecs }: GalleryP
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Zoom control */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
fontFamily: 'var(--font-sketch)',
|
||||
}}>
|
||||
<span style={{ fontSize: 14, color: 'var(--tool-text-muted)' }}>Zoom</span>
|
||||
<input
|
||||
type="range"
|
||||
min={MIN_SCALE * 100}
|
||||
max={MAX_SCALE * 100}
|
||||
value={scale * 100}
|
||||
onChange={(e) => setScale(Number(e.target.value) / 100)}
|
||||
style={{
|
||||
width: 100,
|
||||
accentColor: 'var(--tool-text)',
|
||||
}}
|
||||
/>
|
||||
<span style={{ fontSize: 14, width: 40 }}>{Math.round(scale * 100)}%</span>
|
||||
</div>
|
||||
{/* Zoom control - hide on mobile */}
|
||||
{!isMobile && (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
fontFamily: 'var(--font-sketch)',
|
||||
}}>
|
||||
<span style={{ fontSize: 14, color: 'var(--tool-text-muted)' }}>Zoom</span>
|
||||
<input
|
||||
type="range"
|
||||
min={MIN_SCALE * 100}
|
||||
max={MAX_SCALE * 100}
|
||||
value={scale * 100}
|
||||
onChange={(e) => setScale(Number(e.target.value) / 100)}
|
||||
style={{
|
||||
width: 100,
|
||||
accentColor: 'var(--tool-text)',
|
||||
}}
|
||||
/>
|
||||
<span style={{ fontSize: 14, width: 40 }}>{Math.round(scale * 100)}%</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Theme toggle */}
|
||||
<ThemeToggle />
|
||||
@@ -113,14 +133,14 @@ export function Gallery({ onSelectScreen, onShowStories, onShowSpecs }: GalleryP
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ padding: '24px 0' }}>
|
||||
<div style={{ padding: isMobile ? '16px 0' : '24px 0' }}>
|
||||
{screenGroups.map((group) => (
|
||||
<div key={group.id} style={{ marginBottom: 32 }}>
|
||||
<div key={group.id} style={{ marginBottom: isMobile ? 24 : 32 }}>
|
||||
{/* Group header */}
|
||||
<h2 style={{
|
||||
fontFamily: 'var(--font-sketch)',
|
||||
fontSize: 18,
|
||||
margin: '0 0 16px 32px',
|
||||
fontSize: isMobile ? 16 : 18,
|
||||
margin: isMobile ? '0 0 12px 16px' : '0 0 16px 32px',
|
||||
color: 'var(--tool-text)',
|
||||
}}>
|
||||
{group.name}
|
||||
@@ -129,9 +149,9 @@ export function Gallery({ onSelectScreen, onShowStories, onShowSpecs }: GalleryP
|
||||
{/* Horizontal scrolling row */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
gap: 24,
|
||||
paddingLeft: 32,
|
||||
paddingRight: 32,
|
||||
gap: isMobile ? 12 : 24,
|
||||
paddingLeft: isMobile ? 16 : 32,
|
||||
paddingRight: isMobile ? 16 : 32,
|
||||
overflowX: 'auto',
|
||||
paddingBottom: 8,
|
||||
}}>
|
||||
@@ -139,7 +159,7 @@ export function Gallery({ onSelectScreen, onShowStories, onShowSpecs }: GalleryP
|
||||
<GalleryItem
|
||||
key={screen.id}
|
||||
screen={screen}
|
||||
scale={scale}
|
||||
scale={isMobile ? 0.35 : scale}
|
||||
onClick={() => onSelectScreen(screen.id)}
|
||||
/>
|
||||
))}
|
||||
|
||||
+250
-105
@@ -12,6 +12,16 @@ import {
|
||||
import { getScreen, screens } from '../screens';
|
||||
import { ThemeToggle } from './ThemeToggle';
|
||||
|
||||
function useIsMobile(breakpoint = 768) {
|
||||
const [isMobile, setIsMobile] = useState(window.innerWidth < breakpoint);
|
||||
useEffect(() => {
|
||||
const handleResize = () => setIsMobile(window.innerWidth < breakpoint);
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
}, [breakpoint]);
|
||||
return isMobile;
|
||||
}
|
||||
|
||||
interface UserStoriesPageProps {
|
||||
selectedStoryId?: string;
|
||||
onBack: () => void;
|
||||
@@ -24,7 +34,9 @@ export function UserStoriesPage({ selectedStoryId, onBack, onSelectScreen }: Use
|
||||
const [selectedCategories, setSelectedCategories] = useState<Set<StoryCategory>>(new Set());
|
||||
const [selectedPriorities, setSelectedPriorities] = useState<Set<number>>(new Set());
|
||||
const [selectedScreens, setSelectedScreens] = useState<Set<string>>(new Set());
|
||||
const [filtersExpanded, setFiltersExpanded] = useState(false);
|
||||
const storyRefs = useRef<Map<string, HTMLDivElement>>(new Map());
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
// Scroll to selected story on mount
|
||||
useEffect(() => {
|
||||
@@ -105,34 +117,39 @@ export function UserStoriesPage({ selectedStoryId, onBack, onSelectScreen }: Use
|
||||
<div style={{ minHeight: '100vh', background: 'var(--tool-bg)', transition: 'background-color 0.2s ease' }}>
|
||||
{/* Header */}
|
||||
<div style={{
|
||||
padding: '24px 32px',
|
||||
padding: isMobile ? '16px' : '24px 32px',
|
||||
borderBottom: '2px solid var(--tool-border)',
|
||||
background: 'var(--tool-surface)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
flexDirection: isMobile ? 'column' : 'row',
|
||||
alignItems: isMobile ? 'stretch' : 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: isMobile ? 12 : 0,
|
||||
transition: 'background-color 0.2s ease, border-color 0.2s ease',
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
|
||||
<button
|
||||
onClick={onBack}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: '2px solid var(--tool-border)',
|
||||
borderRadius: '255px 15px 225px 15px/15px 225px 15px 255px',
|
||||
padding: '8px 16px',
|
||||
fontFamily: 'var(--font-sketch)',
|
||||
fontSize: 14,
|
||||
cursor: 'pointer',
|
||||
color: 'var(--tool-text)',
|
||||
}}
|
||||
>
|
||||
← Retour
|
||||
</button>
|
||||
<div style={{ display: 'flex', alignItems: isMobile ? 'flex-start' : 'center', gap: isMobile ? 12 : 16, flexDirection: isMobile ? 'column' : 'row' }}>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center', width: isMobile ? '100%' : 'auto', justifyContent: 'space-between' }}>
|
||||
<button
|
||||
onClick={onBack}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: '2px solid var(--tool-border)',
|
||||
borderRadius: '255px 15px 225px 15px/15px 225px 15px 255px',
|
||||
padding: isMobile ? '6px 12px' : '8px 16px',
|
||||
fontFamily: 'var(--font-sketch)',
|
||||
fontSize: isMobile ? 12 : 14,
|
||||
cursor: 'pointer',
|
||||
color: 'var(--tool-text)',
|
||||
}}
|
||||
>
|
||||
← Retour
|
||||
</button>
|
||||
{isMobile && <ThemeToggle />}
|
||||
</div>
|
||||
<div>
|
||||
<h1 style={{
|
||||
fontFamily: 'var(--font-sketch)',
|
||||
fontSize: 28,
|
||||
fontSize: isMobile ? 22 : 28,
|
||||
margin: 0,
|
||||
color: 'var(--tool-text)',
|
||||
}}>
|
||||
@@ -140,113 +157,241 @@ export function UserStoriesPage({ selectedStoryId, onBack, onSelectScreen }: Use
|
||||
</h1>
|
||||
<p style={{
|
||||
fontFamily: 'var(--font-sketch)',
|
||||
fontSize: 16,
|
||||
fontSize: isMobile ? 13 : 16,
|
||||
color: 'var(--tool-text-muted)',
|
||||
margin: '8px 0 0 0',
|
||||
}}>
|
||||
{filteredStories.length} / {userStories.length} stories · Cliquez sur un écran pour voir le mockup
|
||||
{filteredStories.length} / {userStories.length} stories
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<ThemeToggle />
|
||||
{!isMobile && <ThemeToggle />}
|
||||
</div>
|
||||
|
||||
{/* Filter bar */}
|
||||
<div style={{
|
||||
padding: '16px 32px',
|
||||
borderBottom: '1px solid var(--tool-border-light)',
|
||||
background: 'var(--tool-surface)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 12,
|
||||
transition: 'background-color 0.2s ease, border-color 0.2s ease',
|
||||
}}>
|
||||
{/* Category filters */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||
<span style={{
|
||||
fontFamily: 'var(--font-sketch)',
|
||||
fontSize: 13,
|
||||
color: 'var(--tool-text-muted)',
|
||||
minWidth: 70,
|
||||
}}>
|
||||
Catégorie
|
||||
</span>
|
||||
{categories.map(cat => (
|
||||
<FilterChip
|
||||
key={cat}
|
||||
label={categoryLabels[cat]}
|
||||
color={categoryColors[cat]}
|
||||
selected={selectedCategories.has(cat)}
|
||||
onClick={() => toggleCategory(cat)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Priority filters */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||
<span style={{
|
||||
fontFamily: 'var(--font-sketch)',
|
||||
fontSize: 13,
|
||||
color: 'var(--tool-text-muted)',
|
||||
minWidth: 70,
|
||||
}}>
|
||||
Priorité
|
||||
</span>
|
||||
{[0, 1, 2, 3].map(p => (
|
||||
<FilterChip
|
||||
key={p}
|
||||
label={`P${p} ${priorityLabels[p]}`}
|
||||
color={priorityColors[p] ?? '#888'}
|
||||
selected={selectedPriorities.has(p)}
|
||||
onClick={() => togglePriority(p)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Screen filters */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||
<span style={{
|
||||
fontFamily: 'var(--font-sketch)',
|
||||
fontSize: 13,
|
||||
color: 'var(--tool-text-muted)',
|
||||
minWidth: 70,
|
||||
}}>
|
||||
Écran
|
||||
</span>
|
||||
{screensWithStories.map(screen => (
|
||||
<FilterChip
|
||||
key={screen.id}
|
||||
label={screen.name}
|
||||
color="var(--tool-text)"
|
||||
selected={selectedScreens.has(screen.id)}
|
||||
onClick={() => toggleScreen(screen.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Clear filters */}
|
||||
{hasFilters && (
|
||||
{isMobile ? (
|
||||
/* Mobile: Collapsible filter bar */
|
||||
<div style={{
|
||||
borderBottom: '1px solid var(--tool-border-light)',
|
||||
background: 'var(--tool-surface)',
|
||||
transition: 'background-color 0.2s ease, border-color 0.2s ease',
|
||||
}}>
|
||||
{/* Filter toggle button */}
|
||||
<button
|
||||
onClick={clearFilters}
|
||||
onClick={() => setFiltersExpanded(!filtersExpanded)}
|
||||
style={{
|
||||
alignSelf: 'flex-start',
|
||||
width: '100%',
|
||||
padding: '12px 16px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
fontFamily: 'var(--font-sketch)',
|
||||
fontSize: 13,
|
||||
color: '#c00',
|
||||
cursor: 'pointer',
|
||||
padding: 0,
|
||||
textDecoration: 'underline',
|
||||
color: 'var(--tool-text)',
|
||||
}}
|
||||
>
|
||||
Effacer les filtres
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span>☰ Filtres</span>
|
||||
{hasFilters && (
|
||||
<span style={{
|
||||
background: 'var(--tool-text)',
|
||||
color: 'var(--tool-bg)',
|
||||
borderRadius: '50%',
|
||||
width: 20,
|
||||
height: 20,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: 11,
|
||||
}}>
|
||||
{selectedCategories.size + selectedPriorities.size}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span>{filtersExpanded ? '▲' : '▼'}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Expandable filter panel */}
|
||||
{filtersExpanded && (
|
||||
<div style={{
|
||||
padding: '0 16px 12px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 12,
|
||||
borderTop: '1px solid var(--tool-border-light)',
|
||||
paddingTop: 12,
|
||||
}}>
|
||||
{/* Category filters */}
|
||||
<div>
|
||||
<span style={{
|
||||
fontFamily: 'var(--font-sketch)',
|
||||
fontSize: 11,
|
||||
color: 'var(--tool-text-muted)',
|
||||
display: 'block',
|
||||
marginBottom: 6,
|
||||
}}>
|
||||
Catégorie
|
||||
</span>
|
||||
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
|
||||
{categories.map(cat => (
|
||||
<FilterChip
|
||||
key={cat}
|
||||
label={categoryLabels[cat]}
|
||||
color={categoryColors[cat]}
|
||||
selected={selectedCategories.has(cat)}
|
||||
onClick={() => toggleCategory(cat)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Priority filters */}
|
||||
<div>
|
||||
<span style={{
|
||||
fontFamily: 'var(--font-sketch)',
|
||||
fontSize: 11,
|
||||
color: 'var(--tool-text-muted)',
|
||||
display: 'block',
|
||||
marginBottom: 6,
|
||||
}}>
|
||||
Priorité
|
||||
</span>
|
||||
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
|
||||
{[0, 1, 2, 3].map(p => (
|
||||
<FilterChip
|
||||
key={p}
|
||||
label={`P${p}`}
|
||||
color={priorityColors[p] ?? '#888'}
|
||||
selected={selectedPriorities.has(p)}
|
||||
onClick={() => togglePriority(p)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Clear filters */}
|
||||
{hasFilters && (
|
||||
<button
|
||||
onClick={clearFilters}
|
||||
style={{
|
||||
alignSelf: 'flex-start',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
fontFamily: 'var(--font-sketch)',
|
||||
fontSize: 12,
|
||||
color: '#c00',
|
||||
cursor: 'pointer',
|
||||
padding: 0,
|
||||
textDecoration: 'underline',
|
||||
}}
|
||||
>
|
||||
Effacer les filtres
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
/* Desktop: Full filter bar */
|
||||
<div style={{
|
||||
padding: '16px 32px',
|
||||
borderBottom: '1px solid var(--tool-border-light)',
|
||||
background: 'var(--tool-surface)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 12,
|
||||
transition: 'background-color 0.2s ease, border-color 0.2s ease',
|
||||
}}>
|
||||
{/* Category filters */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||
<span style={{
|
||||
fontFamily: 'var(--font-sketch)',
|
||||
fontSize: 13,
|
||||
color: 'var(--tool-text-muted)',
|
||||
minWidth: 70,
|
||||
}}>
|
||||
Catégorie
|
||||
</span>
|
||||
{categories.map(cat => (
|
||||
<FilterChip
|
||||
key={cat}
|
||||
label={categoryLabels[cat]}
|
||||
color={categoryColors[cat]}
|
||||
selected={selectedCategories.has(cat)}
|
||||
onClick={() => toggleCategory(cat)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Priority filters */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||
<span style={{
|
||||
fontFamily: 'var(--font-sketch)',
|
||||
fontSize: 13,
|
||||
color: 'var(--tool-text-muted)',
|
||||
minWidth: 70,
|
||||
}}>
|
||||
Priorité
|
||||
</span>
|
||||
{[0, 1, 2, 3].map(p => (
|
||||
<FilterChip
|
||||
key={p}
|
||||
label={`P${p} ${priorityLabels[p]}`}
|
||||
color={priorityColors[p] ?? '#888'}
|
||||
selected={selectedPriorities.has(p)}
|
||||
onClick={() => togglePriority(p)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Screen filters */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||
<span style={{
|
||||
fontFamily: 'var(--font-sketch)',
|
||||
fontSize: 13,
|
||||
color: 'var(--tool-text-muted)',
|
||||
minWidth: 70,
|
||||
}}>
|
||||
Écran
|
||||
</span>
|
||||
{screensWithStories.map(screen => (
|
||||
<FilterChip
|
||||
key={screen.id}
|
||||
label={screen.name}
|
||||
color="var(--tool-text)"
|
||||
selected={selectedScreens.has(screen.id)}
|
||||
onClick={() => toggleScreen(screen.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Clear filters */}
|
||||
{hasFilters && (
|
||||
<button
|
||||
onClick={clearFilters}
|
||||
style={{
|
||||
alignSelf: 'flex-start',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
fontFamily: 'var(--font-sketch)',
|
||||
fontSize: 13,
|
||||
color: '#c00',
|
||||
cursor: 'pointer',
|
||||
padding: 0,
|
||||
textDecoration: 'underline',
|
||||
}}
|
||||
>
|
||||
Effacer les filtres
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stories by priority */}
|
||||
<div style={{ padding: 32 }}>
|
||||
<div style={{ padding: isMobile ? 16 : 32 }}>
|
||||
{storiesByPriority.length === 0 ? (
|
||||
<p style={{
|
||||
fontFamily: 'var(--font-sketch)',
|
||||
|
||||
@@ -1,10 +1,21 @@
|
||||
import React from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Input } from '../ui/input';
|
||||
import { Button } from '../ui/button';
|
||||
import { ChevronDown, ChevronUp, Filter } from 'lucide-react';
|
||||
import { categoryLabels, categoryColors, priorityLabels, priorityColors, type StoryCategory } from '../../data';
|
||||
|
||||
const categories: StoryCategory[] = ['WORKSHOP', 'EVENT', 'USER', 'MEETING', 'NOTIF'];
|
||||
|
||||
function useIsMobile(breakpoint = 640) {
|
||||
const [isMobile, setIsMobile] = useState(window.innerWidth < breakpoint);
|
||||
useEffect(() => {
|
||||
const handleResize = () => setIsMobile(window.innerWidth < breakpoint);
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
}, [breakpoint]);
|
||||
return isMobile;
|
||||
}
|
||||
|
||||
interface FeatureFilterProps {
|
||||
selectedCategories: Set<string>;
|
||||
onCategoriesChange: (categories: Set<string>) => void;
|
||||
@@ -22,6 +33,9 @@ export function FeatureFilter({
|
||||
searchQuery,
|
||||
onSearchChange,
|
||||
}: FeatureFilterProps) {
|
||||
const [filtersExpanded, setFiltersExpanded] = useState(false);
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
const toggleCategory = (cat: string) => {
|
||||
const newSet = new Set(selectedCategories);
|
||||
if (newSet.has(cat)) {
|
||||
@@ -49,14 +63,104 @@ export function FeatureFilter({
|
||||
};
|
||||
|
||||
const hasFilters = selectedCategories.size > 0 || selectedPriorities.size > 0 || searchQuery;
|
||||
const activeFilterCount = selectedCategories.size + selectedPriorities.size + (searchQuery ? 1 : 0);
|
||||
|
||||
// On mobile, show compact filter bar with expand button
|
||||
if (isMobile) {
|
||||
return (
|
||||
<div className="border-b border-border bg-muted/30">
|
||||
{/* Compact header with search and filter toggle */}
|
||||
<div className="px-4 py-3 flex items-center gap-2">
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
type="search"
|
||||
placeholder="Rechercher..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
className="bg-background text-sm h-9"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant={activeFilterCount > 0 ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setFiltersExpanded(!filtersExpanded)}
|
||||
className="shrink-0 h-9"
|
||||
>
|
||||
<Filter className="w-4 h-4 mr-1" />
|
||||
{activeFilterCount > 0 ? activeFilterCount : ''}
|
||||
{filtersExpanded ? <ChevronUp className="w-4 h-4 ml-1" /> : <ChevronDown className="w-4 h-4 ml-1" />}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Expandable filter panel */}
|
||||
{filtersExpanded && (
|
||||
<div className="px-4 pb-3 space-y-3 border-t border-border/50 pt-3">
|
||||
{/* Category filters */}
|
||||
<div>
|
||||
<span className="text-xs text-muted-foreground block mb-2">Catégorie</span>
|
||||
<div className="flex gap-1.5 flex-wrap">
|
||||
{categories.map(cat => (
|
||||
<Button
|
||||
key={cat}
|
||||
variant={selectedCategories.has(cat) ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
className="text-xs px-2 h-7"
|
||||
style={{
|
||||
backgroundColor: selectedCategories.has(cat) ? categoryColors[cat] : 'transparent',
|
||||
borderColor: categoryColors[cat],
|
||||
color: selectedCategories.has(cat) ? 'white' : categoryColors[cat],
|
||||
}}
|
||||
onClick={() => toggleCategory(cat)}
|
||||
>
|
||||
{categoryLabels[cat]}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Priority filters */}
|
||||
<div>
|
||||
<span className="text-xs text-muted-foreground block mb-2">Priorité</span>
|
||||
<div className="flex gap-1.5 flex-wrap">
|
||||
{[0, 1, 2, 3].map(p => (
|
||||
<Button
|
||||
key={p}
|
||||
variant={selectedPriorities.has(p) ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
className="text-xs px-2 h-7"
|
||||
style={{
|
||||
backgroundColor: selectedPriorities.has(p) ? priorityColors[p] : 'transparent',
|
||||
borderColor: priorityColors[p],
|
||||
color: selectedPriorities.has(p) ? 'white' : priorityColors[p],
|
||||
}}
|
||||
onClick={() => togglePriority(p)}
|
||||
>
|
||||
P{p}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Clear filters */}
|
||||
{hasFilters && (
|
||||
<Button variant="ghost" size="sm" onClick={clearFilters} className="text-destructive hover:text-destructive text-xs p-0 h-auto">
|
||||
Effacer les filtres
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Desktop layout
|
||||
return (
|
||||
<div className="border-b border-border bg-muted/30 px-8 py-4 space-y-4">
|
||||
{/* Search */}
|
||||
<div className="max-w-md">
|
||||
<Input
|
||||
type="search"
|
||||
placeholder="Rechercher une fonctionnalite..."
|
||||
placeholder="Rechercher une fonctionnalité..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
className="bg-background"
|
||||
@@ -64,8 +168,8 @@ export function FeatureFilter({
|
||||
</div>
|
||||
|
||||
{/* Category filters */}
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<span className="text-sm text-muted-foreground w-20 shrink-0">Categorie</span>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm text-muted-foreground w-20 shrink-0">Catégorie</span>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{categories.map(cat => (
|
||||
<Button
|
||||
@@ -86,8 +190,8 @@ export function FeatureFilter({
|
||||
</div>
|
||||
|
||||
{/* Priority filters */}
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<span className="text-sm text-muted-foreground w-20 shrink-0">Priorite</span>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm text-muted-foreground w-20 shrink-0">Priorité</span>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{[0, 1, 2, 3].map(p => (
|
||||
<Button
|
||||
|
||||
@@ -66,54 +66,54 @@ export function SpecsPage({ selectedFeatureId, onBack, onSelectScreen, onSelectS
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
{/* Header */}
|
||||
<div className="border-b border-border px-8 py-6 bg-card">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="border-b border-border px-4 sm:px-8 py-4 sm:py-6 bg-card">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-3 sm:gap-4">
|
||||
<Button variant="outline" size="sm" onClick={onBack}>
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
Retour
|
||||
<ArrowLeft className="w-4 h-4 sm:mr-2" />
|
||||
<span className="hidden sm:inline">Retour</span>
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">Specifications BDD</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{filteredFeatures.length} / {parsedFeatures.length} fonctionnalites - {filteredFeatures.reduce((acc, f) => acc + f.scenarios.length, 0)} scenarios
|
||||
<h1 className="text-xl sm:text-2xl font-semibold">Specs BDD</h1>
|
||||
<p className="text-xs sm:text-sm text-muted-foreground mt-1">
|
||||
{filteredFeatures.length} / {parsedFeatures.length} fonctionnalités
|
||||
</p>
|
||||
</div>
|
||||
<div className="sm:hidden ml-auto">
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</div>
|
||||
{/* Test Results Summary */}
|
||||
{testSummary.totalScenarios > 0 && (
|
||||
<div className="flex items-center gap-4 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle2 className="w-4 h-4 text-green-500" />
|
||||
<span className="text-green-600 font-medium">{testSummary.passed} passes</span>
|
||||
<div className="flex items-center gap-2 sm:gap-4 text-xs sm:text-sm flex-wrap">
|
||||
<div className="flex items-center gap-1 sm:gap-2">
|
||||
<CheckCircle2 className="w-3 h-3 sm:w-4 sm:h-4 text-green-500" />
|
||||
<span className="text-green-600 font-medium">{testSummary.passed}</span>
|
||||
</div>
|
||||
{testSummary.failed > 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<XCircle className="w-4 h-4 text-red-500" />
|
||||
<span className="text-red-600 font-medium">{testSummary.failed} echecs</span>
|
||||
<div className="flex items-center gap-1 sm:gap-2">
|
||||
<XCircle className="w-3 h-3 sm:w-4 sm:h-4 text-red-500" />
|
||||
<span className="text-red-600 font-medium">{testSummary.failed}</span>
|
||||
</div>
|
||||
)}
|
||||
{testSummary.skipped > 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertCircle className="w-4 h-4 text-yellow-500" />
|
||||
<span className="text-yellow-600 font-medium">{testSummary.skipped} ignores</span>
|
||||
<div className="flex items-center gap-1 sm:gap-2">
|
||||
<AlertCircle className="w-3 h-3 sm:w-4 sm:h-4 text-yellow-500" />
|
||||
<span className="text-yellow-600 font-medium">{testSummary.skipped}</span>
|
||||
</div>
|
||||
)}
|
||||
{testSummary.lastRun && (
|
||||
<span className="text-muted-foreground">
|
||||
{testSummary.lastRun.toLocaleString('fr-FR', { dateStyle: 'short', timeStyle: 'short' })}
|
||||
</span>
|
||||
)}
|
||||
<a href="/reports/cucumber" target="_blank" rel="noopener noreferrer">
|
||||
<a href="/reports/cucumber" target="_blank" rel="noopener noreferrer" className="hidden sm:block">
|
||||
<Button variant="outline" size="sm">
|
||||
<ExternalLink className="w-4 h-4 mr-2" />
|
||||
Rapport HTML
|
||||
Rapport
|
||||
</Button>
|
||||
</a>
|
||||
<ThemeToggle />
|
||||
<div className="hidden sm:block">
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{testSummary.totalScenarios === 0 && <ThemeToggle />}
|
||||
{testSummary.totalScenarios === 0 && <div className="hidden sm:block"><ThemeToggle /></div>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -128,7 +128,7 @@ export function SpecsPage({ selectedFeatureId, onBack, onSelectScreen, onSelectS
|
||||
/>
|
||||
|
||||
{/* Feature list */}
|
||||
<div className="px-8 py-6 space-y-8">
|
||||
<div className="px-4 sm:px-8 py-4 sm:py-6 space-y-6 sm:space-y-8">
|
||||
{featuresByPriority.map(({ priority, features }) => (
|
||||
<div key={priority}>
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
@@ -146,7 +146,7 @@ export function SpecsPage({ selectedFeatureId, onBack, onSelectScreen, onSelectS
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
<div className="grid gap-3 sm:gap-4 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{features.map(feature => (
|
||||
<FeatureCard
|
||||
key={feature.id}
|
||||
|
||||
Reference in New Issue
Block a user