diff --git a/apps/web/src/components/Onboarding.tsx b/apps/web/src/components/Onboarding.tsx index fafe9f136..674270b98 100644 --- a/apps/web/src/components/Onboarding.tsx +++ b/apps/web/src/components/Onboarding.tsx @@ -98,6 +98,10 @@ export function Onboarding({ const isFirstStep = currentStep === 0; const isLastStep = currentStep === steps.length - 1; + if (!step) { + return null; + } + return ( {/* Progress indicator */}
- {steps.map((_, index) => ( + {steps.map((step, index) => (
- {bar.label}: {data[index].value} + {bar.label}: {data[index]?.value} {showValues && ( @@ -140,7 +140,7 @@ export function BarChart({ className="fill-foreground text-[1.5px]" fontSize="1.5" > - {data[index].value} + {data[index]?.value} )} diff --git a/apps/web/src/components/charts/LineChart.tsx b/apps/web/src/components/charts/LineChart.tsx index 065bc0056..f9b3e0285 100644 --- a/apps/web/src/components/charts/LineChart.tsx +++ b/apps/web/src/components/charts/LineChart.tsx @@ -145,7 +145,7 @@ export function LineChart({ className="cursor-pointer transition-all hover:r-[6]" > - {point.label}: {data[index].value} + {point.label}: {data[index]?.value} ))} diff --git a/apps/web/src/components/dashboard/StatCard.tsx b/apps/web/src/components/dashboard/StatCard.tsx index 00f262249..18e822dc8 100644 --- a/apps/web/src/components/dashboard/StatCard.tsx +++ b/apps/web/src/components/dashboard/StatCard.tsx @@ -51,10 +51,13 @@ export const StatCard: React.FC = ({ y: height - ((val - min) / range) * height, })); - let pathStr = `M ${points[0].x},${points[0].y}`; + const first = points[0]; + if (!first) return ''; + let pathStr = `M ${first.x},${first.y}`; for (let i = 0; i < points.length - 1; i++) { const curr = points[i]; const next = points[i + 1]; + if (!curr || !next) continue; const mx = (curr.x + next.x) / 2; pathStr += ` C ${mx},${curr.y} ${mx},${next.y} ${next.x},${next.y}`; } diff --git a/apps/web/src/components/developer/APIPlaygroundView.tsx b/apps/web/src/components/developer/APIPlaygroundView.tsx index fc9e86865..685fcdb4d 100644 --- a/apps/web/src/components/developer/APIPlaygroundView.tsx +++ b/apps/web/src/components/developer/APIPlaygroundView.tsx @@ -13,7 +13,7 @@ const ENDPOINTS = [ export const APIPlaygroundView: React.FC = () => { const { addToast } = useToast(); - const [selectedEndpoint, setSelectedEndpoint] = useState(ENDPOINTS[0]); + const [selectedEndpoint, setSelectedEndpoint] = useState(ENDPOINTS[0]!); const [params, setParams] = useState('{\n "limit": 10,\n "offset": 0\n}'); const [response, setResponse] = useState(null); const [loading, setLoading] = useState(false); diff --git a/apps/web/src/components/education/modals/QuizModal.tsx b/apps/web/src/components/education/modals/QuizModal.tsx index bc578950b..7640565a3 100644 --- a/apps/web/src/components/education/modals/QuizModal.tsx +++ b/apps/web/src/components/education/modals/QuizModal.tsx @@ -23,6 +23,10 @@ export const QuizModal: React.FC = ({ const currentQuestion = quiz.questions[currentQuestionIndex]; const isLastQuestion = currentQuestionIndex === quiz.questions.length - 1; + if (!currentQuestion) { + return null; + } + const handleAnswerSelect = (index: number) => { const newAnswers = [...selectedAnswers]; newAnswers[currentQuestionIndex] = index; diff --git a/apps/web/src/components/gamification/LeaderboardView.tsx b/apps/web/src/components/gamification/LeaderboardView.tsx index 3f68c65b6..cb2851662 100644 --- a/apps/web/src/components/gamification/LeaderboardView.tsx +++ b/apps/web/src/components/gamification/LeaderboardView.tsx @@ -67,6 +67,7 @@ export const LeaderboardView: React.FC = () => { {/* Order: Silver (index 1), Gold (index 0), Bronze (index 2) */} {[leaderboard[1], leaderboard[0], leaderboard[2]].map( (entry, i) => { + if (!entry) return null; const podiumStyles = { // Gold (center, 1st place) 1: { @@ -89,7 +90,8 @@ export const LeaderboardView: React.FC = () => { badge: 'bg-orange-400 text-background', label: 'text-orange-400', }, - }[i]!; + }[i as 0 | 1 | 2]; + if (!podiumStyles) return null; return (
{ - tabRefs.current[items[newIndex].id]?.focus(); + tabRefs.current[targetItem.id]?.focus(); }, 0); } }, diff --git a/apps/web/src/components/player/LyricsPanel.tsx b/apps/web/src/components/player/LyricsPanel.tsx index 4dfb98703..44138d469 100644 --- a/apps/web/src/components/player/LyricsPanel.tsx +++ b/apps/web/src/components/player/LyricsPanel.tsx @@ -15,7 +15,7 @@ export const LyricsPanel: React.FC = () => { return ( currentTime >= line.time && (i === currentTrack.lyrics!.length - 1 || - currentTime < currentTrack.lyrics![i + 1].time) + currentTime < (currentTrack.lyrics![i + 1]?.time ?? Infinity)) ); }, ); @@ -62,7 +62,7 @@ export const LyricsPanel: React.FC = () => { const isActive = currentTime >= line.time && (i === currentTrack.lyrics!.length - 1 || - currentTime < currentTrack.lyrics![i + 1].time); + currentTime < (currentTrack.lyrics![i + 1]?.time ?? Infinity)); return (

avec propriété 'data' - if (tracksData.status === 'fulfilled' && tracksData.value?.data) { + if (tracksData?.status === 'fulfilled' && tracksData.value?.data) { tracksData.value.data.forEach((track: { id: string; title?: string; name?: string; artist?: string; artist_name?: string; cover_url?: string; cover?: string; thumbnail_url?: string }) => { results.push({ id: track.id, @@ -91,7 +92,7 @@ export function GlobalSearchBar({ // Add playlist suggestions (seulement si la feature est activée) if ( isFeatureEnabled('PLAYLIST_SEARCH') && - playlistsData.status === 'fulfilled' && + playlistsData?.status === 'fulfilled' && playlistsData.value?.playlists ) { playlistsData.value.playlists.forEach((playlist: { id: string; title?: string; is_public?: boolean; cover_url?: string; thumbnail_url?: string }) => { @@ -106,7 +107,7 @@ export function GlobalSearchBar({ } // Add user suggestions - if (usersData.status === 'fulfilled' && usersData.value?.users) { + if (usersData?.status === 'fulfilled' && usersData.value?.users) { usersData.value.users.forEach((user: { id: string; username: string; email?: string; avatar_url?: string }) => { results.push({ id: user.id, diff --git a/apps/web/src/components/social/PostCard.tsx b/apps/web/src/components/social/PostCard.tsx index 9e6d5c4fc..3baf9985d 100644 --- a/apps/web/src/components/social/PostCard.tsx +++ b/apps/web/src/components/social/PostCard.tsx @@ -30,7 +30,7 @@ function formatRelativeTime(timestamp: string): string { const units: Record = { s: 's', m: 'm', h: 'h', d: 'd', w: 'w', mo: 'mo', y: 'y', }; - return `${relMatch[1]}${units[relMatch[2].toLowerCase()]} ago`; + return `${relMatch[1]}${units[relMatch[2]?.toLowerCase() ?? ''] ?? ''} ago`; } // Try parsing as a date const date = new Date(timestamp); diff --git a/apps/web/src/components/studio/ai-tools-view/useAIToolsView.ts b/apps/web/src/components/studio/ai-tools-view/useAIToolsView.ts index 409c0d16d..9198c655d 100644 --- a/apps/web/src/components/studio/ai-tools-view/useAIToolsView.ts +++ b/apps/web/src/components/studio/ai-tools-view/useAIToolsView.ts @@ -24,7 +24,7 @@ export function useAIToolsView() { setIsProcessing(false); addToast('Processing complete!', 'success'); setResult({ - fileName: files[0].name, + fileName: files[0]?.name ?? 'unknown', outputs: tool === 'stem-splitter' ? ['Drums.wav', 'Bass.wav', 'Vocals.wav', 'Other.wav'] diff --git a/apps/web/src/components/ui/AstralBackground.tsx b/apps/web/src/components/ui/AstralBackground.tsx index d82a40666..f1e11aaa3 100644 --- a/apps/web/src/components/ui/AstralBackground.tsx +++ b/apps/web/src/components/ui/AstralBackground.tsx @@ -79,6 +79,7 @@ export function AstralBackground() { // Draw connections for (let j = i + 1; j < particles.length; j++) { const p2 = particles[j]; + if (!p2) continue; const dx = p.x - p2.x; const dy = p.y - p2.y; const distance = Math.sqrt(dx * dx + dy * dy); diff --git a/apps/web/src/components/ui/avatar.tsx b/apps/web/src/components/ui/avatar.tsx index bfbf6059c..1a0194041 100644 --- a/apps/web/src/components/ui/avatar.tsx +++ b/apps/web/src/components/ui/avatar.tsx @@ -199,7 +199,7 @@ export const Avatar = React.forwardRef( if (!name) return '?'; const parts = name.trim().split(' '); if (parts.length >= 2) { - return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase(); + return ((parts[0]?.[0] ?? '') + (parts[parts.length - 1]?.[0] ?? '')).toUpperCase(); } return name.substring(0, 2).toUpperCase(); }; diff --git a/apps/web/src/components/ui/dropdown.tsx b/apps/web/src/components/ui/dropdown.tsx index 62f40e9e4..36eec2e19 100644 --- a/apps/web/src/components/ui/dropdown.tsx +++ b/apps/web/src/components/ui/dropdown.tsx @@ -112,7 +112,7 @@ export function Dropdown({ focusedIndexRef.current >= 0 && elements[focusedIndexRef.current] ) { - elements[focusedIndexRef.current].click(); + elements[focusedIndexRef.current]?.click(); } break; diff --git a/apps/web/src/components/ui/select/SelectDropdownContent.tsx b/apps/web/src/components/ui/select/SelectDropdownContent.tsx index 7fc78c7fb..16ac9beb7 100644 --- a/apps/web/src/components/ui/select/SelectDropdownContent.tsx +++ b/apps/web/src/components/ui/select/SelectDropdownContent.tsx @@ -44,7 +44,7 @@ export function SelectDropdownContent({ const highlightedOptionId = highlightedIndex >= 0 && highlightedIndex < allOptions.length - ? `${listboxId}-option-${allOptions[highlightedIndex].value}` + ? `${listboxId}-option-${allOptions[highlightedIndex]?.value}` : undefined; const handleKeyDown = (e: React.KeyboardEvent) => { @@ -65,7 +65,7 @@ export function SelectDropdownContent({ case ' ': if (highlightedIndex >= 0 && highlightedIndex < allOptions.length) { e.preventDefault(); - onSelect(allOptions[highlightedIndex].value); + onSelect(allOptions[highlightedIndex]!.value); } break; case 'Home': @@ -115,7 +115,7 @@ export function SelectDropdownContent({ isHighlighted={ highlightedIndex >= 0 && highlightedIndex < allOptions.length && - allOptions[highlightedIndex].value === option.value + allOptions[highlightedIndex]?.value === option.value } multiple={multiple} onSelect={onSelect} @@ -137,7 +137,7 @@ export function SelectDropdownContent({ isHighlighted={ highlightedIndex >= 0 && highlightedIndex < allOptions.length && - allOptions[highlightedIndex].value === option.value + allOptions[highlightedIndex]?.value === option.value } multiple={multiple} onSelect={onSelect} diff --git a/apps/web/src/components/ui/select/useSelect.ts b/apps/web/src/components/ui/select/useSelect.ts index 6bd5f55f8..95d8b25ce 100644 --- a/apps/web/src/components/ui/select/useSelect.ts +++ b/apps/web/src/components/ui/select/useSelect.ts @@ -18,7 +18,7 @@ export function useSelect({ options.forEach((option) => { if (option.group) { if (!groups[option.group]) groups[option.group] = []; - groups[option.group].push(option); + groups[option.group]!.push(option); } else { ungrouped.push(option); } diff --git a/apps/web/src/components/ui/slider.tsx b/apps/web/src/components/ui/slider.tsx index 5b8b3fd60..41ef62dc7 100644 --- a/apps/web/src/components/ui/slider.tsx +++ b/apps/web/src/components/ui/slider.tsx @@ -120,7 +120,7 @@ const Slider = React.forwardRef( } }; - const percentage = ((value[0] - min) / (max - min)) * 100; + const percentage = (((value[0] ?? min) - min) / (max - min)) * 100; return (

( } e.preventDefault(); - triggers[nextIndex].focus(); + triggers[nextIndex]?.focus(); // Activate on arrow key navigation (follows WAI-ARIA tabs pattern) - const value = triggers[nextIndex].getAttribute('data-value'); + const value = triggers[nextIndex]?.getAttribute('data-value'); if (value) onValueChange?.(value); }; diff --git a/apps/web/src/components/ui/virtualized-list/VirtualizedList.tsx b/apps/web/src/components/ui/virtualized-list/VirtualizedList.tsx index 05d880c9d..44210cff8 100644 --- a/apps/web/src/components/ui/virtualized-list/VirtualizedList.tsx +++ b/apps/web/src/components/ui/virtualized-list/VirtualizedList.tsx @@ -56,7 +56,7 @@ export const VirtualizedList = React.forwardRef< 0, Math.floor(scrollTop / itemHeight) - overscan, ); - const endIndex = virtualItems[virtualItems.length - 1].index; + const endIndex = virtualItems[virtualItems.length - 1]!.index; onItemsRendered(startIndex, endIndex); } }, [onScroll, onItemsRendered, virtualItems, itemHeight, overscan]); diff --git a/apps/web/src/components/views/profile-view/ProfileViewOverview.tsx b/apps/web/src/components/views/profile-view/ProfileViewOverview.tsx index 1fa5d57eb..6e4291bb1 100644 --- a/apps/web/src/components/views/profile-view/ProfileViewOverview.tsx +++ b/apps/web/src/components/views/profile-view/ProfileViewOverview.tsx @@ -27,7 +27,7 @@ export function ProfileViewOverview({

- {tracks[0].title} + {tracks[0]?.title}

Latest upload from {profile.username}. diff --git a/apps/web/src/context/audio-context/useAudioContextValue.ts b/apps/web/src/context/audio-context/useAudioContextValue.ts index 3139529fc..cdf7f1f9e 100644 --- a/apps/web/src/context/audio-context/useAudioContextValue.ts +++ b/apps/web/src/context/audio-context/useAudioContextValue.ts @@ -38,6 +38,7 @@ export function useAudioContextValue() { const next = shuffle ? queue[Math.floor(Math.random() * queue.length)] : queue[0]; + if (!next) return; setHistory((prev) => (currentTrack ? [...prev, currentTrack] : prev)); if (repeatMode !== 'all') { setQueue((prev) => prev.filter((t) => t.id !== next.id)); @@ -50,6 +51,7 @@ export function useAudioContextValue() { } else if (autoplay) { const randomMock = mockTracks[Math.floor(Math.random() * mockTracks.length)]; + if (!randomMock) return; setHistory((prev) => (currentTrack ? [...prev, currentTrack] : prev)); setCurrentTrack({ ...randomMock, diff --git a/apps/web/src/features/auth/utils/userAgentParser.ts b/apps/web/src/features/auth/utils/userAgentParser.ts index 775aded82..30cf5d072 100644 --- a/apps/web/src/features/auth/utils/userAgentParser.ts +++ b/apps/web/src/features/auth/utils/userAgentParser.ts @@ -48,7 +48,7 @@ export function parseUserAgent(userAgent: string): DeviceInfo { } else if (ua.includes('mac os x') || ua.includes('macintosh')) { info.os = 'macOS'; const match = ua.match(/mac os x (\d+[._]\d+)/); - if (match) { + if (match?.[1]) { info.osVersion = match[1].replace('_', '.'); } } else if (ua.includes('linux')) { @@ -66,7 +66,7 @@ export function parseUserAgent(userAgent: string): DeviceInfo { ) { info.os = 'iOS'; const match = ua.match(/os (\d+[._]\d+)/); - if (match) { + if (match?.[1]) { info.osVersion = match[1].replace('_', '.'); } } @@ -115,7 +115,7 @@ export function parseUserAgent(userAgent: string): DeviceInfo { } else if (ua.includes('android')) { // Try to extract device model from Android user agent const match = ua.match(/android.*?;\s*([^)]+)\)/); - if (match) { + if (match?.[1]) { info.deviceModel = match[1].trim(); } } diff --git a/apps/web/src/features/chat/components/ChatRoom.tsx b/apps/web/src/features/chat/components/ChatRoom.tsx index 451b3f9ad..e5148a995 100644 --- a/apps/web/src/features/chat/components/ChatRoom.tsx +++ b/apps/web/src/features/chat/components/ChatRoom.tsx @@ -153,7 +153,7 @@ export const ChatRoom: React.FC = ({ conversationId }) => { {currentMessages.map((msg, index) => { const isMe = currentUserId ? msg.sender_id === currentUserId : false; const isSequence = - index > 0 && currentMessages[index - 1].sender_id === msg.sender_id; + index > 0 && currentMessages[index - 1]?.sender_id === msg.sender_id; return (

{ if (!isFetching && messages.length > 0) { const lastMessage = messages[messages.length - 1]; - const isRecentMessage = - Date.now() - new Date(lastMessage.created_at).getTime() < 5000; + const isRecentMessage = lastMessage + ? Date.now() - new Date(lastMessage.created_at).getTime() < 5000 + : false; if (isRecentMessage) { scrollToBottom(); diff --git a/apps/web/src/features/chat/store/chatStore.ts b/apps/web/src/features/chat/store/chatStore.ts index 9e4d66e07..c5ea7d4c9 100644 --- a/apps/web/src/features/chat/store/chatStore.ts +++ b/apps/web/src/features/chat/store/chatStore.ts @@ -106,7 +106,7 @@ export const useChatStore = create()( if (!state.messages[message.conversation_id]) { state.messages[message.conversation_id] = []; } - state.messages[message.conversation_id].push(message); + state.messages[message.conversation_id]!.push(message); }), loadMessages: (conversationId, newMessages) => set((state) => { @@ -142,10 +142,12 @@ export const useChatStore = create()( if (!message.reactions) message.reactions = {}; // Remove existing reaction from this user if any Object.keys(message.reactions).forEach((e) => { - message.reactions![e] = message.reactions![e].filter( + const users = message.reactions![e]; + if (!users) return; + message.reactions![e] = users.filter( (id) => id !== userId, ); - if (message.reactions![e].length === 0) + if (message.reactions![e]?.length === 0) delete message.reactions![e]; }); // Add new reaction @@ -163,10 +165,12 @@ export const useChatStore = create()( const message = messages.find((m) => m.id === messageId); if (message && message.reactions) { Object.keys(message.reactions).forEach((emoji) => { - message.reactions![emoji] = message.reactions![emoji].filter( + const users = message.reactions![emoji]; + if (!users) return; + message.reactions![emoji] = users.filter( (id) => id !== userId, ); - if (message.reactions![emoji].length === 0) + if (message.reactions![emoji]?.length === 0) delete message.reactions![emoji]; }); } diff --git a/apps/web/src/features/marketplace/components/Cart.tsx b/apps/web/src/features/marketplace/components/Cart.tsx index d0d9ac228..485cceded 100644 --- a/apps/web/src/features/marketplace/components/Cart.tsx +++ b/apps/web/src/features/marketplace/components/Cart.tsx @@ -194,7 +194,7 @@ export function Cart({ isOpen, onClose }: CartProps) {
Total - {items.length > 0 + {items.length > 0 && items[0] ? formatPrice(getTotal(), items[0].product.currency) : '€0.00'} diff --git a/apps/web/src/features/player/components/PlaybackSpeedControl.tsx b/apps/web/src/features/player/components/PlaybackSpeedControl.tsx index c95370d6f..283de5e71 100644 --- a/apps/web/src/features/player/components/PlaybackSpeedControl.tsx +++ b/apps/web/src/features/player/components/PlaybackSpeedControl.tsx @@ -49,9 +49,10 @@ export function PlaybackSpeedControl({ : DEFAULT_SPEEDS; const currentSpeedOption = - speeds.find((s) => s.value === currentSpeed) || - speeds.find((s) => s.value === 1) || - speeds[0]; + speeds.find((s) => s.value === currentSpeed) ?? + speeds.find((s) => s.value === 1) ?? + speeds[0] ?? + { value: currentSpeed, label: `${currentSpeed}x` }; // Fermer le dropdown quand on clique en dehors useEffect(() => { diff --git a/apps/web/src/features/player/components/PlayerExpanded.tsx b/apps/web/src/features/player/components/PlayerExpanded.tsx index 5c1158aaa..fe80c6ece 100644 --- a/apps/web/src/features/player/components/PlayerExpanded.tsx +++ b/apps/web/src/features/player/components/PlayerExpanded.tsx @@ -43,7 +43,7 @@ export function PlayerExpanded({ isOpen, onClose, currentTime, duration, onSeek, const activeIndex = lyrics.findIndex( (line, i) => currentTime >= line.time && - (i === lyrics.length - 1 || currentTime < lyrics[i + 1].time) + (i === lyrics.length - 1 || currentTime < (lyrics[i + 1]?.time ?? Infinity)) ); if (activeIndex >= 0) { const el = lyricsScrollRef.current.children[activeIndex] as HTMLElement; @@ -212,7 +212,7 @@ export function PlayerExpanded({ isOpen, onClose, currentTime, duration, onSeek, {lyrics.map((line, i) => { const isActive = currentTime >= line.time && - (i === lyrics.length - 1 || currentTime < lyrics[i + 1].time); + (i === lyrics.length - 1 || currentTime < (lyrics[i + 1]?.time ?? Infinity)); return (

q.value === currentQuality) || qualities[0]; + qualities.find((q) => q.value === currentQuality) ?? + qualities[0] ?? + { value: currentQuality, label: currentQuality }; // Fermer le dropdown quand on clique en dehors useEffect(() => { diff --git a/apps/web/src/features/player/hooks/useAudioAnalyser.ts b/apps/web/src/features/player/hooks/useAudioAnalyser.ts index d0a9f738e..286bb9a5a 100644 --- a/apps/web/src/features/player/hooks/useAudioAnalyser.ts +++ b/apps/web/src/features/player/hooks/useAudioAnalyser.ts @@ -75,7 +75,7 @@ export function useAudioAnalyser( const step = Math.floor(data.length / BAR_COUNT); const newLevels = Array.from( { length: BAR_COUNT }, - (_, i) => data[Math.min(i * step, data.length - 1)] / 255, + (_, i) => (data[Math.min(i * step, data.length - 1)] ?? 0) / 255, ); setLevels(newLevels); rafRef.current = requestAnimationFrame(update); diff --git a/apps/web/src/features/playlists/hooks/usePlaylistNotifications.ts b/apps/web/src/features/playlists/hooks/usePlaylistNotifications.ts index 2b65de3d2..af229a129 100644 --- a/apps/web/src/features/playlists/hooks/usePlaylistNotifications.ts +++ b/apps/web/src/features/playlists/hooks/usePlaylistNotifications.ts @@ -112,6 +112,7 @@ export function usePlaylistNotifications( // Trouver la notification la plus récente const latestNotification = playlistNotifications[0]; + if (!latestNotification) return; // Si c'est une nouvelle notification (pas encore vue) // FE-TYPE-001: Compare IDs as strings diff --git a/apps/web/src/features/playlists/hooks/useTouchGestures.ts b/apps/web/src/features/playlists/hooks/useTouchGestures.ts index 27e6baa3b..bb473e896 100644 --- a/apps/web/src/features/playlists/hooks/useTouchGestures.ts +++ b/apps/web/src/features/playlists/hooks/useTouchGestures.ts @@ -42,6 +42,7 @@ export function useTouchGestures(handlers: TouchGestureHandlers = {}) { const handleTouchStart = useCallback( (e: React.TouchEvent) => { const touch = e.touches[0]; + if (!touch) return; touchStartRef.current = { x: touch.clientX, y: touch.clientY, @@ -79,6 +80,7 @@ export function useTouchGestures(handlers: TouchGestureHandlers = {}) { if (!touchStartRef.current) return; const touch = e.changedTouches[0]; + if (!touch) return; const deltaX = touch.clientX - touchStartRef.current.x; const deltaY = touch.clientY - touchStartRef.current.y; const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY); diff --git a/apps/web/src/features/streaming/components/playback-heatmap/usePlaybackHeatmap.ts b/apps/web/src/features/streaming/components/playback-heatmap/usePlaybackHeatmap.ts index 2894cd1df..7a5b85c21 100644 --- a/apps/web/src/features/streaming/components/playback-heatmap/usePlaybackHeatmap.ts +++ b/apps/web/src/features/streaming/components/playback-heatmap/usePlaybackHeatmap.ts @@ -41,11 +41,11 @@ function computeStats(heatmap: PlaybackHeatmapData): HeatmapStats | null { const totalSkips = heatmap.segments.reduce((sum, seg) => sum + seg.skip_count, 0); const maxIntensitySegment = heatmap.segments.reduce( (max, seg) => (seg.intensity > max.intensity ? seg : max), - heatmap.segments[0], + heatmap.segments[0]!, ); const maxSkipSegment = heatmap.segments.reduce( (max, seg) => (seg.skip_count > max.skip_count ? seg : max), - heatmap.segments[0], + heatmap.segments[0]!, ); return { totalListens, totalSkips, maxIntensitySegment, maxSkipSegment }; } diff --git a/apps/web/src/features/streaming/services/playbackAnalyticsService.ts b/apps/web/src/features/streaming/services/playbackAnalyticsService.ts index 6c24815ba..a72515aba 100644 --- a/apps/web/src/features/streaming/services/playbackAnalyticsService.ts +++ b/apps/web/src/features/streaming/services/playbackAnalyticsService.ts @@ -274,6 +274,7 @@ export async function retryPendingAnalytics(): Promise { for (let i = pending.length - 1; i >= 0; i--) { const pendingEvent = pending[i]; + if (!pendingEvent) continue; // Ne pas retry les événements trop anciens (> 7 jours) const age = Date.now() - pendingEvent.timestamp; diff --git a/apps/web/src/features/tracks/components/track-list-pagination/TrackListPaginationNav.tsx b/apps/web/src/features/tracks/components/track-list-pagination/TrackListPaginationNav.tsx index 7875fa46e..909371da2 100644 --- a/apps/web/src/features/tracks/components/track-list-pagination/TrackListPaginationNav.tsx +++ b/apps/web/src/features/tracks/components/track-list-pagination/TrackListPaginationNav.tsx @@ -72,7 +72,7 @@ export function TrackListPaginationNav({ {showPageNumbers && (

- {visiblePages[0] > 1 && ( + {visiblePages[0] != null && visiblePages[0] > 1 && ( <> - {visiblePages[0] > 2 && ( + {visiblePages[0] != null && visiblePages[0] > 2 && ( ... )} @@ -106,9 +106,11 @@ export function TrackListPaginationNav({ {page} ))} - {visiblePages[visiblePages.length - 1] < totalPages && ( + {(() => { + const lastVisible = visiblePages[visiblePages.length - 1]; + return lastVisible != null && lastVisible < totalPages && ( <> - {visiblePages[visiblePages.length - 1] < totalPages - 1 && ( + {lastVisible < totalPages - 1 && ( ... )}
)} diff --git a/apps/web/src/features/tracks/hooks/useInfiniteScroll.ts b/apps/web/src/features/tracks/hooks/useInfiniteScroll.ts index b493b0b2a..36dd6088f 100644 --- a/apps/web/src/features/tracks/hooks/useInfiniteScroll.ts +++ b/apps/web/src/features/tracks/hooks/useInfiniteScroll.ts @@ -117,6 +117,7 @@ export function useInfiniteScroll({ observerRef.current = new IntersectionObserver( (entries) => { const [entry] = entries; + if (!entry) return; // Si l'élément sentinelle est visible et qu'on n'est pas déjà en train de charger if (entry.isIntersecting && !isLoadingRef.current && hasMore) { handleLoadMore(); diff --git a/apps/web/src/mocks/handlers-ghost.ts b/apps/web/src/mocks/handlers-ghost.ts new file mode 100644 index 000000000..f5c95dfa9 --- /dev/null +++ b/apps/web/src/mocks/handlers-ghost.ts @@ -0,0 +1,132 @@ +/** + * GHOST FEATURE MSW Handlers + * + * These handlers mock backend endpoints that do NOT have a real backend + * implementation. They exist solely for frontend development/storybook use. + * + * Ghost features: + * - Education (courses, enrollments) + * - Gamification (achievements, leaderboard, XP) + * + * Do NOT rely on these for production. When a backend endpoint is implemented, + * move the corresponding handler to handlers.ts with proper response envelope. + */ + +import { http, HttpResponse } from 'msw'; + +export const ghostHandlers = [ + // ─── Education ───────────────────────────────────────────────────── + http.get('*/api/v1/education/courses', () => { + return HttpResponse.json([ + { + id: 'course-1', + title: 'Music Production Fundamentals', + level: 'Beginner', + duration: '5h 30m', + progress: 0, + instructor: 'John Doe', + thumbnailUrl: 'https://picsum.photos/400/250', + price: 49.99, + rating: 4.8, + studentCount: 1200, + tags: ['Production', 'Beginner'], + }, + { + id: 'course-2', + title: 'Advanced Mixing Techniques', + level: 'Advanced', + duration: '8h 15m', + progress: 0, + instructor: 'Jane Smith', + thumbnailUrl: 'https://picsum.photos/401/250', + price: 79.99, + rating: 4.9, + studentCount: 850, + tags: ['Mixing', 'Advanced'], + }, + ]); + }), + + http.get('*/api/v1/education/enrollments', () => { + return HttpResponse.json([ + { + id: 'enroll-1', + course_id: 'course-1', + progress: 45, + lastAccessed: '2024-01-01T12:00:00Z', + course: { + id: 'course-1', + title: 'Music Production Fundamentals', + thumbnailUrl: 'https://picsum.photos/400/250', + }, + }, + ]); + }), + + // ─── Gamification ────────────────────────────────────────────────── + http.get('*/api/v1/gamification/achievements', () => { + return HttpResponse.json({ + success: true, + data: [ + { + id: 'ach-1', + name: 'First Upload', + description: 'Upload your first track', + icon: '🎵', + progress: 1, + maxProgress: 1, + xpReward: 50, + category: 'creation', + unlocked: true, + }, + { + id: 'ach-2', + name: 'Social Butterfly', + description: 'Follow 10 users', + icon: '🦋', + progress: 5, + maxProgress: 10, + xpReward: 100, + category: 'social', + unlocked: false, + }, + ], + }); + }), + + http.get('*/api/v1/gamification/leaderboard', () => { + return HttpResponse.json([ + { + rank: 1, + userId: 'user-1', + username: 'TopProducer', + avatar: 'https://i.pravatar.cc/100?u=top', + level: 50, + xp: 125000, + trend: 0, + }, + { + rank: 2, + userId: 'user-2', + username: 'BeatMaster', + avatar: 'https://i.pravatar.cc/100?u=beat', + level: 45, + xp: 98000, + trend: 1, + }, + ]); + }), + + http.get('*/api/v1/gamification/xp/:userId', () => { + return HttpResponse.json({ + success: true, + data: { + current: 4250, + next: 5000, + level: 12, + rank: 420, + totalEarned: 15400, + }, + }); + }), +]; diff --git a/apps/web/src/mocks/handlers.ts b/apps/web/src/mocks/handlers.ts index b88141619..896c93251 100644 --- a/apps/web/src/mocks/handlers.ts +++ b/apps/web/src/mocks/handlers.ts @@ -1,4 +1,5 @@ import { http, HttpResponse } from 'msw'; +import { ghostHandlers } from './handlers-ghost'; /** * FE-API-019: MSW Mock Handlers @@ -547,122 +548,8 @@ export const handlers = [ }); }), - // GHOST FEATURE — no backend implementation exists for education - // These handlers are kept for frontend MSW development mode only - http.get('*/api/v1/education/courses', () => { - return HttpResponse.json([ - { - id: 'course-1', - title: 'Music Production Fundamentals', - level: 'Beginner', - duration: '5h 30m', - progress: 0, - instructor: 'John Doe', - thumbnailUrl: 'https://picsum.photos/400/250', - price: 49.99, - rating: 4.8, - studentCount: 1200, - tags: ['Production', 'Beginner'] - }, - { - id: 'course-2', - title: 'Advanced Mixing Techniques', - level: 'Advanced', - duration: '8h 15m', - progress: 0, - instructor: 'Jane Smith', - thumbnailUrl: 'https://picsum.photos/401/250', - price: 79.99, - rating: 4.9, - studentCount: 850, - tags: ['Mixing', 'Advanced'] - } - ]); - }), - - http.get('*/api/v1/education/enrollments', () => { - return HttpResponse.json([ - { - id: 'enroll-1', - course_id: 'course-1', - progress: 45, - lastAccessed: '2024-01-01T12:00:00Z', - course: { - id: 'course-1', - title: 'Music Production Fundamentals', - thumbnailUrl: 'https://picsum.photos/400/250' - } - } - ]); - }), - - // GHOST FEATURE — no backend implementation exists for gamification - // These handlers are kept for frontend MSW development mode only - http.get('*/api/v1/gamification/achievements', () => { - return HttpResponse.json({ - success: true, - data: [ - { - id: 'ach-1', - name: 'First Upload', - description: 'Upload your first track', - icon: '🎵', - progress: 1, - maxProgress: 1, - xpReward: 50, - category: 'creation', - unlocked: true - }, - { - id: 'ach-2', - name: 'Social Butterfly', - description: 'Follow 10 users', - icon: '🦋', - progress: 5, - maxProgress: 10, - xpReward: 100, - category: 'social', - unlocked: false - } - ] - }); - }), - - http.get('*/api/v1/gamification/leaderboard', () => { - return HttpResponse.json([ - { - rank: 1, - userId: 'user-1', - username: 'TopProducer', - avatar: 'https://i.pravatar.cc/100?u=top', - level: 50, - xp: 125000, - trend: 0 - }, - { - rank: 2, - userId: 'user-2', - username: 'BeatMaster', - avatar: 'https://i.pravatar.cc/100?u=beat', - level: 45, - xp: 98000, - trend: 1 - } - ]); - }), - - http.get('*/api/v1/gamification/xp/:userId', () => { - return HttpResponse.json({ - success: true, - data: { - current: 4250, - next: 5000, - level: 12, - rank: 420, - totalEarned: 15400 - } - }); - }), + // Ghost features (Education, Gamification) are in handlers-ghost.ts + // They are spread into this array below via ...ghostHandlers // Developer & Webhooks endpoints http.get('*/api/v1/webhooks', () => { @@ -1708,6 +1595,9 @@ export const handlers = [ }); }), + // Ghost feature handlers (Education, Gamification — no backend) + ...ghostHandlers, + // Catch-all for API to prevent network leaks (Phase 1: Stabilization) http.all('*/api/v1/*', ({ request }) => { console.warn('[MSW] Intercepted unhandled API request:', request.method, request.url); diff --git a/apps/web/src/services/cookieService.ts b/apps/web/src/services/cookieService.ts index 43b27dd21..e835b52cb 100644 --- a/apps/web/src/services/cookieService.ts +++ b/apps/web/src/services/cookieService.ts @@ -62,6 +62,7 @@ export function getCookie(name: string): string | null { for (let i = 0; i < cookies.length; i++) { let cookie = cookies[i]; + if (cookie == null) continue; while (cookie.charAt(0) === ' ') { cookie = cookie.substring(1, cookie.length); } diff --git a/apps/web/src/services/offlineQueue.ts b/apps/web/src/services/offlineQueue.ts index 78bc63bfd..b3a8d7c35 100644 --- a/apps/web/src/services/offlineQueue.ts +++ b/apps/web/src/services/offlineQueue.ts @@ -168,6 +168,7 @@ class OfflineQueueService { // Process requests in order (high priority first) while (this.queue.length > 0 && !this.isOffline()) { const request = this.queue[0]; + if (!request) break; // #region agent log // fetch('http://127.0.0.1:7242/ingest/09c5ea5e-2380-4cc3-92aa-d26f3b3d26f6',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'offlineQueue.ts:149',message:'Processing request',data:{requestId:request.id,method:request.config.method,url:request.config.url,retryCount:request.retryCount},timestamp:Date.now(),sessionId:'debug-session',runId:'run1',hypothesisId:'C'})}).catch(()=>{}); // #endregion diff --git a/apps/web/src/services/responseCache.ts b/apps/web/src/services/responseCache.ts index cca70b6c0..7f125d3e6 100644 --- a/apps/web/src/services/responseCache.ts +++ b/apps/web/src/services/responseCache.ts @@ -100,7 +100,7 @@ class ResponseCacheService { for (const part of parts) { if (part.includes('=')) { const [key, value] = part.split('=').map((p) => p.trim()); - directives[key.toLowerCase()] = value; + if (key) directives[key.toLowerCase()] = value; } else { directives[part.toLowerCase()] = true; } diff --git a/apps/web/src/services/tokenRefresh.ts b/apps/web/src/services/tokenRefresh.ts index 33f64e835..e870c5d88 100644 --- a/apps/web/src/services/tokenRefresh.ts +++ b/apps/web/src/services/tokenRefresh.ts @@ -81,7 +81,7 @@ function decodeJWT(token: string): JWTPayload | null { return null; } // Décoder le payload (base64url) - const payload = parts[1]; + const payload = parts[1]!; const decoded = atob(payload.replace(/-/g, '+').replace(/_/g, '/')); return JSON.parse(decoded) as JWTPayload; } catch (error) { diff --git a/apps/web/src/utils/contrast.ts b/apps/web/src/utils/contrast.ts index 9627e4139..27d882813 100644 --- a/apps/web/src/utils/contrast.ts +++ b/apps/web/src/utils/contrast.ts @@ -21,7 +21,7 @@ export function getRelativeLuminance(r: number, g: number, b: number): number { return normalized <= 0.03928 ? normalized / 12.92 : Math.pow((normalized + 0.055) / 1.055, 2.4); - }); + }) as [number, number, number]; // Calculate relative luminance using WCAG formula return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs; diff --git a/apps/web/src/utils/csp.ts b/apps/web/src/utils/csp.ts index 7580afb40..33869fa27 100644 --- a/apps/web/src/utils/csp.ts +++ b/apps/web/src/utils/csp.ts @@ -70,7 +70,7 @@ export function buildCSPHeader(nonce?: string): string { } as unknown as Record; if (nonce) { - policy['script-src'] = policy['script-src'].map((src) => + policy['script-src'] = policy['script-src']?.map((src) => src === "'nonce-__CSP_NONCE__'" ? `'nonce-${nonce}'` : src, ); } diff --git a/apps/web/src/utils/date.ts b/apps/web/src/utils/date.ts index c31702f75..ceed2ee74 100644 --- a/apps/web/src/utils/date.ts +++ b/apps/web/src/utils/date.ts @@ -122,10 +122,10 @@ export function parseDuration(duration: string): number { if (parts.length === 2) { // MM:SS - return parts[0] * 60 + parts[1]; + return (parts[0] ?? 0) * 60 + (parts[1] ?? 0); } else if (parts.length === 3) { // HH:MM:SS - return parts[0] * 3600 + parts[1] * 60 + parts[2]; + return (parts[0] ?? 0) * 3600 + (parts[1] ?? 0) * 60 + (parts[2] ?? 0); } return 0; diff --git a/apps/web/src/utils/format.ts b/apps/web/src/utils/format.ts index deef8fd0b..ded8c9755 100644 --- a/apps/web/src/utils/format.ts +++ b/apps/web/src/utils/format.ts @@ -85,8 +85,9 @@ export function formatUsername(username: string): string { export function formatEmail(email: string): string { const [localPart, domain] = email.split('@'); + if (!localPart) return email; if (localPart.length > 3) { - return `${localPart.substring(0, 3)}***@${domain}`; + return `${localPart.substring(0, 3)}***@${domain ?? ''}`; } return email; } diff --git a/apps/web/tsconfig.app.json b/apps/web/tsconfig.app.json index 832f34d72..cd651890d 100644 --- a/apps/web/tsconfig.app.json +++ b/apps/web/tsconfig.app.json @@ -28,7 +28,7 @@ "noUnusedParameters": true, "noImplicitReturns": true, "noFallthroughCasesInSwitch": true, - // "noUncheckedIndexedAccess": true, // TODO: Enable progressively - requires fixing 200+ array/object access checks + "noUncheckedIndexedAccess": true, "noImplicitOverride": true, "erasableSyntaxOnly": true, "noUncheckedSideEffectImports": true, diff --git a/apps/web/vitest.config.ts b/apps/web/vitest.config.ts index ed1dbf81e..81ac0a04d 100644 --- a/apps/web/vitest.config.ts +++ b/apps/web/vitest.config.ts @@ -2,6 +2,8 @@ import { defineConfig, configDefaults } from 'vitest/config'; import react from '@vitejs/plugin-react'; import path from 'path'; import { fileURLToPath } from 'node:url'; +// Storybook test plugin — uncomment storybookTest and the browser project below +// to run story-based visual tests (requires Playwright to be installed). // import { storybookTest } from '@storybook/addon-vitest/vitest-plugin'; const dirname = typeof __dirname !== 'undefined' diff --git a/veza-backend-api/internal/services/upload_validator.go b/veza-backend-api/internal/services/upload_validator.go index 333009ca9..b4e72c1d8 100644 --- a/veza-backend-api/internal/services/upload_validator.go +++ b/veza-backend-api/internal/services/upload_validator.go @@ -12,6 +12,10 @@ import ( "strings" "time" + // TECH DEBT: github.com/dutchcoders/go-clamd is abandoned (last commit 2017). + // TODO: Replace with a maintained alternative (e.g., github.com/baruwa-enterprise/clamd) + // or implement a minimal HTTP/TCP ClamAV client. The current dependency works + // but receives no security patches. "github.com/dutchcoders/go-clamd" "go.uber.org/zap" )