chore: enable noUncheckedIndexedAccess, isolate ghost MSW handlers, document go-clamd tech debt

- Enable TypeScript noUncheckedIndexedAccess and fix 133 resulting errors
  across 46 files with proper null guards, optional chaining, and fallbacks
- Extract education/gamification ghost feature MSW handlers into handlers-ghost.ts
- Add Storybook test plugin documentation in vitest.config.ts
- Document abandoned go-clamd dependency (2017) as tech debt in upload_validator.go

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
senke 2026-02-12 23:12:35 +01:00
parent 1695606e24
commit 09bb663659
50 changed files with 252 additions and 187 deletions

View file

@ -98,6 +98,10 @@ export function Onboarding({
const isFirstStep = currentStep === 0; const isFirstStep = currentStep === 0;
const isLastStep = currentStep === steps.length - 1; const isLastStep = currentStep === steps.length - 1;
if (!step) {
return null;
}
return ( return (
<Dialog <Dialog
open={open} open={open}
@ -110,9 +114,9 @@ export function Onboarding({
<div className="flex items-center justify-between w-full"> <div className="flex items-center justify-between w-full">
{/* Progress indicator */} {/* Progress indicator */}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{steps.map((_, index) => ( {steps.map((step, index) => (
<div <div
key={index} key={step.title}
className={cn( className={cn(
'h-2 w-2 rounded-full transition-colors', 'h-2 w-2 rounded-full transition-colors',
index === currentStep index === currentStep

View file

@ -129,7 +129,7 @@ export function BarChart({
className="cursor-pointer transition-opacity hover:opacity-80" className="cursor-pointer transition-opacity hover:opacity-80"
> >
<title> <title>
{bar.label}: {data[index].value} {bar.label}: {data[index]?.value}
</title> </title>
</rect> </rect>
{showValues && ( {showValues && (
@ -140,7 +140,7 @@ export function BarChart({
className="fill-foreground text-[1.5px]" className="fill-foreground text-[1.5px]"
fontSize="1.5" fontSize="1.5"
> >
{data[index].value} {data[index]?.value}
</text> </text>
)} )}
</g> </g>

View file

@ -145,7 +145,7 @@ export function LineChart({
className="cursor-pointer transition-all hover:r-[6]" className="cursor-pointer transition-all hover:r-[6]"
> >
<title> <title>
{point.label}: {data[index].value} {point.label}: {data[index]?.value}
</title> </title>
</circle> </circle>
))} ))}

View file

@ -51,10 +51,13 @@ export const StatCard: React.FC<StatCardProps> = ({
y: height - ((val - min) / range) * height, 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++) { for (let i = 0; i < points.length - 1; i++) {
const curr = points[i]; const curr = points[i];
const next = points[i + 1]; const next = points[i + 1];
if (!curr || !next) continue;
const mx = (curr.x + next.x) / 2; const mx = (curr.x + next.x) / 2;
pathStr += ` C ${mx},${curr.y} ${mx},${next.y} ${next.x},${next.y}`; pathStr += ` C ${mx},${curr.y} ${mx},${next.y} ${next.x},${next.y}`;
} }

View file

@ -13,7 +13,7 @@ const ENDPOINTS = [
export const APIPlaygroundView: React.FC = () => { export const APIPlaygroundView: React.FC = () => {
const { addToast } = useToast(); 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 [params, setParams] = useState('{\n "limit": 10,\n "offset": 0\n}');
const [response, setResponse] = useState<string | null>(null); const [response, setResponse] = useState<string | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);

View file

@ -23,6 +23,10 @@ export const QuizModal: React.FC<QuizModalProps> = ({
const currentQuestion = quiz.questions[currentQuestionIndex]; const currentQuestion = quiz.questions[currentQuestionIndex];
const isLastQuestion = currentQuestionIndex === quiz.questions.length - 1; const isLastQuestion = currentQuestionIndex === quiz.questions.length - 1;
if (!currentQuestion) {
return null;
}
const handleAnswerSelect = (index: number) => { const handleAnswerSelect = (index: number) => {
const newAnswers = [...selectedAnswers]; const newAnswers = [...selectedAnswers];
newAnswers[currentQuestionIndex] = index; newAnswers[currentQuestionIndex] = index;

View file

@ -67,6 +67,7 @@ export const LeaderboardView: React.FC = () => {
{/* Order: Silver (index 1), Gold (index 0), Bronze (index 2) */} {/* Order: Silver (index 1), Gold (index 0), Bronze (index 2) */}
{[leaderboard[1], leaderboard[0], leaderboard[2]].map( {[leaderboard[1], leaderboard[0], leaderboard[2]].map(
(entry, i) => { (entry, i) => {
if (!entry) return null;
const podiumStyles = { const podiumStyles = {
// Gold (center, 1st place) // Gold (center, 1st place)
1: { 1: {
@ -89,7 +90,8 @@ export const LeaderboardView: React.FC = () => {
badge: 'bg-orange-400 text-background', badge: 'bg-orange-400 text-background',
label: 'text-orange-400', label: 'text-orange-400',
}, },
}[i]!; }[i as 0 | 1 | 2];
if (!podiumStyles) return null;
return ( return (
<div <div

View file

@ -100,11 +100,12 @@ export function Tabs({
attempts++; attempts++;
} }
if (items[newIndex] && !items[newIndex].disabled) { const targetItem = items[newIndex];
handleTabChange(items[newIndex].id); if (targetItem && !targetItem.disabled) {
handleTabChange(targetItem.id);
// Focus sur le nouvel onglet après un court délai pour permettre la mise à jour // Focus sur le nouvel onglet après un court délai pour permettre la mise à jour
setTimeout(() => { setTimeout(() => {
tabRefs.current[items[newIndex].id]?.focus(); tabRefs.current[targetItem.id]?.focus();
}, 0); }, 0);
} }
}, },

View file

@ -15,7 +15,7 @@ export const LyricsPanel: React.FC = () => {
return ( return (
currentTime >= line.time && currentTime >= line.time &&
(i === currentTrack.lyrics!.length - 1 || (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 = const isActive =
currentTime >= line.time && currentTime >= line.time &&
(i === currentTrack.lyrics!.length - 1 || (i === currentTrack.lyrics!.length - 1 ||
currentTime < currentTrack.lyrics![i + 1].time); currentTime < (currentTrack.lyrics![i + 1]?.time ?? Infinity));
return ( return (
<p <p
key={i} key={i}

View file

@ -59,16 +59,17 @@ export function GlobalSearchBar({
searchPromises.push(Promise.resolve({ playlists: [], total: 0 })); searchPromises.push(Promise.resolve({ playlists: [], total: 0 }));
} }
const [tracksData, usersData, playlistsData] = await Promise.allSettled( const settled = await Promise.allSettled(searchPromises);
searchPromises, const tracksData = settled[0];
); const usersData = settled[1];
const playlistsData = settled[2];
const duration = performance.now() - startTime; const duration = performance.now() - startTime;
logger.debug('Search requests completed', { logger.debug('Search requests completed', {
duration: `${duration.toFixed(2)}ms`, duration: `${duration.toFixed(2)}ms`,
tracksStatus: tracksData.status, tracksStatus: tracksData?.status,
usersStatus: usersData.status, usersStatus: usersData?.status,
playlistsStatus: playlistsData.status, playlistsStatus: playlistsData?.status,
component: 'GlobalSearchBar', component: 'GlobalSearchBar',
}); });
@ -76,7 +77,7 @@ export function GlobalSearchBar({
// Add track suggestions // Add track suggestions
// FIX: searchTracks retourne PaginatedResponse<Track> avec propriété 'data' // FIX: searchTracks retourne PaginatedResponse<Track> 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 }) => { 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({ results.push({
id: track.id, id: track.id,
@ -91,7 +92,7 @@ export function GlobalSearchBar({
// Add playlist suggestions (seulement si la feature est activée) // Add playlist suggestions (seulement si la feature est activée)
if ( if (
isFeatureEnabled('PLAYLIST_SEARCH') && isFeatureEnabled('PLAYLIST_SEARCH') &&
playlistsData.status === 'fulfilled' && playlistsData?.status === 'fulfilled' &&
playlistsData.value?.playlists playlistsData.value?.playlists
) { ) {
playlistsData.value.playlists.forEach((playlist: { id: string; title?: string; is_public?: boolean; cover_url?: string; thumbnail_url?: string }) => { 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 // 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 }) => { usersData.value.users.forEach((user: { id: string; username: string; email?: string; avatar_url?: string }) => {
results.push({ results.push({
id: user.id, id: user.id,

View file

@ -30,7 +30,7 @@ function formatRelativeTime(timestamp: string): string {
const units: Record<string, string> = { const units: Record<string, string> = {
s: 's', m: 'm', h: 'h', d: 'd', w: 'w', mo: 'mo', y: 'y', 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 // Try parsing as a date
const date = new Date(timestamp); const date = new Date(timestamp);

View file

@ -24,7 +24,7 @@ export function useAIToolsView() {
setIsProcessing(false); setIsProcessing(false);
addToast('Processing complete!', 'success'); addToast('Processing complete!', 'success');
setResult({ setResult({
fileName: files[0].name, fileName: files[0]?.name ?? 'unknown',
outputs: outputs:
tool === 'stem-splitter' tool === 'stem-splitter'
? ['Drums.wav', 'Bass.wav', 'Vocals.wav', 'Other.wav'] ? ['Drums.wav', 'Bass.wav', 'Vocals.wav', 'Other.wav']

View file

@ -79,6 +79,7 @@ export function AstralBackground() {
// Draw connections // Draw connections
for (let j = i + 1; j < particles.length; j++) { for (let j = i + 1; j < particles.length; j++) {
const p2 = particles[j]; const p2 = particles[j];
if (!p2) continue;
const dx = p.x - p2.x; const dx = p.x - p2.x;
const dy = p.y - p2.y; const dy = p.y - p2.y;
const distance = Math.sqrt(dx * dx + dy * dy); const distance = Math.sqrt(dx * dx + dy * dy);

View file

@ -199,7 +199,7 @@ export const Avatar = React.forwardRef<HTMLDivElement, AvatarProps>(
if (!name) return '?'; if (!name) return '?';
const parts = name.trim().split(' '); const parts = name.trim().split(' ');
if (parts.length >= 2) { 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(); return name.substring(0, 2).toUpperCase();
}; };

View file

@ -112,7 +112,7 @@ export function Dropdown({
focusedIndexRef.current >= 0 && focusedIndexRef.current >= 0 &&
elements[focusedIndexRef.current] elements[focusedIndexRef.current]
) { ) {
elements[focusedIndexRef.current].click(); elements[focusedIndexRef.current]?.click();
} }
break; break;

View file

@ -44,7 +44,7 @@ export function SelectDropdownContent({
const highlightedOptionId = const highlightedOptionId =
highlightedIndex >= 0 && highlightedIndex < allOptions.length highlightedIndex >= 0 && highlightedIndex < allOptions.length
? `${listboxId}-option-${allOptions[highlightedIndex].value}` ? `${listboxId}-option-${allOptions[highlightedIndex]?.value}`
: undefined; : undefined;
const handleKeyDown = (e: React.KeyboardEvent) => { const handleKeyDown = (e: React.KeyboardEvent) => {
@ -65,7 +65,7 @@ export function SelectDropdownContent({
case ' ': case ' ':
if (highlightedIndex >= 0 && highlightedIndex < allOptions.length) { if (highlightedIndex >= 0 && highlightedIndex < allOptions.length) {
e.preventDefault(); e.preventDefault();
onSelect(allOptions[highlightedIndex].value); onSelect(allOptions[highlightedIndex]!.value);
} }
break; break;
case 'Home': case 'Home':
@ -115,7 +115,7 @@ export function SelectDropdownContent({
isHighlighted={ isHighlighted={
highlightedIndex >= 0 && highlightedIndex >= 0 &&
highlightedIndex < allOptions.length && highlightedIndex < allOptions.length &&
allOptions[highlightedIndex].value === option.value allOptions[highlightedIndex]?.value === option.value
} }
multiple={multiple} multiple={multiple}
onSelect={onSelect} onSelect={onSelect}
@ -137,7 +137,7 @@ export function SelectDropdownContent({
isHighlighted={ isHighlighted={
highlightedIndex >= 0 && highlightedIndex >= 0 &&
highlightedIndex < allOptions.length && highlightedIndex < allOptions.length &&
allOptions[highlightedIndex].value === option.value allOptions[highlightedIndex]?.value === option.value
} }
multiple={multiple} multiple={multiple}
onSelect={onSelect} onSelect={onSelect}

View file

@ -18,7 +18,7 @@ export function useSelect({
options.forEach((option) => { options.forEach((option) => {
if (option.group) { if (option.group) {
if (!groups[option.group]) groups[option.group] = []; if (!groups[option.group]) groups[option.group] = [];
groups[option.group].push(option); groups[option.group]!.push(option);
} else { } else {
ungrouped.push(option); ungrouped.push(option);
} }

View file

@ -120,7 +120,7 @@ const Slider = React.forwardRef<HTMLInputElement, SliderProps>(
} }
}; };
const percentage = ((value[0] - min) / (max - min)) * 100; const percentage = (((value[0] ?? min) - min) / (max - min)) * 100;
return ( return (
<div <div

View file

@ -31,9 +31,9 @@ export const TabsList = React.forwardRef<HTMLDivElement, TabsListProps>(
} }
e.preventDefault(); e.preventDefault();
triggers[nextIndex].focus(); triggers[nextIndex]?.focus();
// Activate on arrow key navigation (follows WAI-ARIA tabs pattern) // 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); if (value) onValueChange?.(value);
}; };

View file

@ -56,7 +56,7 @@ export const VirtualizedList = React.forwardRef<
0, 0,
Math.floor(scrollTop / itemHeight) - overscan, Math.floor(scrollTop / itemHeight) - overscan,
); );
const endIndex = virtualItems[virtualItems.length - 1].index; const endIndex = virtualItems[virtualItems.length - 1]!.index;
onItemsRendered(startIndex, endIndex); onItemsRendered(startIndex, endIndex);
} }
}, [onScroll, onItemsRendered, virtualItems, itemHeight, overscan]); }, [onScroll, onItemsRendered, virtualItems, itemHeight, overscan]);

View file

@ -27,7 +27,7 @@ export function ProfileViewOverview({
<div className="w-32 h-32 md:w-48 md:h-48 shrink-0 shadow-2xl rounded-lg overflow-hidden"> <div className="w-32 h-32 md:w-48 md:h-48 shrink-0 shadow-2xl rounded-lg overflow-hidden">
<img <img
src={ src={
tracks[0].coverUrl || 'https://via.placeholder.com/400' tracks[0]?.coverUrl || 'https://via.placeholder.com/400'
} }
alt="" alt=""
className="w-full h-full object-cover" className="w-full h-full object-cover"
@ -40,7 +40,7 @@ export function ProfileViewOverview({
className="mb-2" className="mb-2"
/> />
<h2 className="text-2xl md:text-4xl font-heading font-bold text-foreground mb-2 tracking-tight"> <h2 className="text-2xl md:text-4xl font-heading font-bold text-foreground mb-2 tracking-tight">
{tracks[0].title} {tracks[0]?.title}
</h2> </h2>
<p className="text-muted-foreground mb-6"> <p className="text-muted-foreground mb-6">
Latest upload from {profile.username}. Latest upload from {profile.username}.

View file

@ -38,6 +38,7 @@ export function useAudioContextValue() {
const next = shuffle const next = shuffle
? queue[Math.floor(Math.random() * queue.length)] ? queue[Math.floor(Math.random() * queue.length)]
: queue[0]; : queue[0];
if (!next) return;
setHistory((prev) => (currentTrack ? [...prev, currentTrack] : prev)); setHistory((prev) => (currentTrack ? [...prev, currentTrack] : prev));
if (repeatMode !== 'all') { if (repeatMode !== 'all') {
setQueue((prev) => prev.filter((t) => t.id !== next.id)); setQueue((prev) => prev.filter((t) => t.id !== next.id));
@ -50,6 +51,7 @@ export function useAudioContextValue() {
} else if (autoplay) { } else if (autoplay) {
const randomMock = const randomMock =
mockTracks[Math.floor(Math.random() * mockTracks.length)]; mockTracks[Math.floor(Math.random() * mockTracks.length)];
if (!randomMock) return;
setHistory((prev) => (currentTrack ? [...prev, currentTrack] : prev)); setHistory((prev) => (currentTrack ? [...prev, currentTrack] : prev));
setCurrentTrack({ setCurrentTrack({
...randomMock, ...randomMock,

View file

@ -48,7 +48,7 @@ export function parseUserAgent(userAgent: string): DeviceInfo {
} else if (ua.includes('mac os x') || ua.includes('macintosh')) { } else if (ua.includes('mac os x') || ua.includes('macintosh')) {
info.os = 'macOS'; info.os = 'macOS';
const match = ua.match(/mac os x (\d+[._]\d+)/); const match = ua.match(/mac os x (\d+[._]\d+)/);
if (match) { if (match?.[1]) {
info.osVersion = match[1].replace('_', '.'); info.osVersion = match[1].replace('_', '.');
} }
} else if (ua.includes('linux')) { } else if (ua.includes('linux')) {
@ -66,7 +66,7 @@ export function parseUserAgent(userAgent: string): DeviceInfo {
) { ) {
info.os = 'iOS'; info.os = 'iOS';
const match = ua.match(/os (\d+[._]\d+)/); const match = ua.match(/os (\d+[._]\d+)/);
if (match) { if (match?.[1]) {
info.osVersion = match[1].replace('_', '.'); info.osVersion = match[1].replace('_', '.');
} }
} }
@ -115,7 +115,7 @@ export function parseUserAgent(userAgent: string): DeviceInfo {
} else if (ua.includes('android')) { } else if (ua.includes('android')) {
// Try to extract device model from Android user agent // Try to extract device model from Android user agent
const match = ua.match(/android.*?;\s*([^)]+)\)/); const match = ua.match(/android.*?;\s*([^)]+)\)/);
if (match) { if (match?.[1]) {
info.deviceModel = match[1].trim(); info.deviceModel = match[1].trim();
} }
} }

View file

@ -153,7 +153,7 @@ export const ChatRoom: React.FC<ChatRoomProps> = ({ conversationId }) => {
{currentMessages.map((msg, index) => { {currentMessages.map((msg, index) => {
const isMe = currentUserId ? msg.sender_id === currentUserId : false; const isMe = currentUserId ? msg.sender_id === currentUserId : false;
const isSequence = const isSequence =
index > 0 && currentMessages[index - 1].sender_id === msg.sender_id; index > 0 && currentMessages[index - 1]?.sender_id === msg.sender_id;
return ( return (
<div <div

View file

@ -46,8 +46,9 @@ export function VirtualizedChatMessages({
useEffect(() => { useEffect(() => {
if (!isFetching && messages.length > 0) { if (!isFetching && messages.length > 0) {
const lastMessage = messages[messages.length - 1]; const lastMessage = messages[messages.length - 1];
const isRecentMessage = const isRecentMessage = lastMessage
Date.now() - new Date(lastMessage.created_at).getTime() < 5000; ? Date.now() - new Date(lastMessage.created_at).getTime() < 5000
: false;
if (isRecentMessage) { if (isRecentMessage) {
scrollToBottom(); scrollToBottom();

View file

@ -106,7 +106,7 @@ export const useChatStore = create<ChatState>()(
if (!state.messages[message.conversation_id]) { if (!state.messages[message.conversation_id]) {
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) => loadMessages: (conversationId, newMessages) =>
set((state) => { set((state) => {
@ -142,10 +142,12 @@ export const useChatStore = create<ChatState>()(
if (!message.reactions) message.reactions = {}; if (!message.reactions) message.reactions = {};
// Remove existing reaction from this user if any // Remove existing reaction from this user if any
Object.keys(message.reactions).forEach((e) => { 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, (id) => id !== userId,
); );
if (message.reactions![e].length === 0) if (message.reactions![e]?.length === 0)
delete message.reactions![e]; delete message.reactions![e];
}); });
// Add new reaction // Add new reaction
@ -163,10 +165,12 @@ export const useChatStore = create<ChatState>()(
const message = messages.find((m) => m.id === messageId); const message = messages.find((m) => m.id === messageId);
if (message && message.reactions) { if (message && message.reactions) {
Object.keys(message.reactions).forEach((emoji) => { 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, (id) => id !== userId,
); );
if (message.reactions![emoji].length === 0) if (message.reactions![emoji]?.length === 0)
delete message.reactions![emoji]; delete message.reactions![emoji];
}); });
} }

View file

@ -194,7 +194,7 @@ export function Cart({ isOpen, onClose }: CartProps) {
<div className="flex justify-between text-lg font-semibold"> <div className="flex justify-between text-lg font-semibold">
<span>Total</span> <span>Total</span>
<span> <span>
{items.length > 0 {items.length > 0 && items[0]
? formatPrice(getTotal(), items[0].product.currency) ? formatPrice(getTotal(), items[0].product.currency)
: '€0.00'} : '€0.00'}
</span> </span>

View file

@ -49,9 +49,10 @@ export function PlaybackSpeedControl({
: DEFAULT_SPEEDS; : DEFAULT_SPEEDS;
const currentSpeedOption = const currentSpeedOption =
speeds.find((s) => s.value === currentSpeed) || speeds.find((s) => s.value === currentSpeed) ??
speeds.find((s) => s.value === 1) || speeds.find((s) => s.value === 1) ??
speeds[0]; speeds[0] ??
{ value: currentSpeed, label: `${currentSpeed}x` };
// Fermer le dropdown quand on clique en dehors // Fermer le dropdown quand on clique en dehors
useEffect(() => { useEffect(() => {

View file

@ -43,7 +43,7 @@ export function PlayerExpanded({ isOpen, onClose, currentTime, duration, onSeek,
const activeIndex = lyrics.findIndex( const activeIndex = lyrics.findIndex(
(line, i) => (line, i) =>
currentTime >= line.time && 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) { if (activeIndex >= 0) {
const el = lyricsScrollRef.current.children[activeIndex] as HTMLElement; const el = lyricsScrollRef.current.children[activeIndex] as HTMLElement;
@ -212,7 +212,7 @@ export function PlayerExpanded({ isOpen, onClose, currentTime, duration, onSeek,
{lyrics.map((line, i) => { {lyrics.map((line, i) => {
const isActive = const isActive =
currentTime >= line.time && currentTime >= line.time &&
(i === lyrics.length - 1 || currentTime < lyrics[i + 1].time); (i === lyrics.length - 1 || currentTime < (lyrics[i + 1]?.time ?? Infinity));
return ( return (
<p <p
key={i} key={i}

View file

@ -47,7 +47,9 @@ export function QualitySelector({
: DEFAULT_QUALITIES; : DEFAULT_QUALITIES;
const currentQualityOption = const currentQualityOption =
qualities.find((q) => 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 // Fermer le dropdown quand on clique en dehors
useEffect(() => { useEffect(() => {

View file

@ -75,7 +75,7 @@ export function useAudioAnalyser(
const step = Math.floor(data.length / BAR_COUNT); const step = Math.floor(data.length / BAR_COUNT);
const newLevels = Array.from( const newLevels = Array.from(
{ length: BAR_COUNT }, { 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); setLevels(newLevels);
rafRef.current = requestAnimationFrame(update); rafRef.current = requestAnimationFrame(update);

View file

@ -112,6 +112,7 @@ export function usePlaylistNotifications(
// Trouver la notification la plus récente // Trouver la notification la plus récente
const latestNotification = playlistNotifications[0]; const latestNotification = playlistNotifications[0];
if (!latestNotification) return;
// Si c'est une nouvelle notification (pas encore vue) // Si c'est une nouvelle notification (pas encore vue)
// FE-TYPE-001: Compare IDs as strings // FE-TYPE-001: Compare IDs as strings

View file

@ -42,6 +42,7 @@ export function useTouchGestures(handlers: TouchGestureHandlers = {}) {
const handleTouchStart = useCallback( const handleTouchStart = useCallback(
(e: React.TouchEvent) => { (e: React.TouchEvent) => {
const touch = e.touches[0]; const touch = e.touches[0];
if (!touch) return;
touchStartRef.current = { touchStartRef.current = {
x: touch.clientX, x: touch.clientX,
y: touch.clientY, y: touch.clientY,
@ -79,6 +80,7 @@ export function useTouchGestures(handlers: TouchGestureHandlers = {}) {
if (!touchStartRef.current) return; if (!touchStartRef.current) return;
const touch = e.changedTouches[0]; const touch = e.changedTouches[0];
if (!touch) return;
const deltaX = touch.clientX - touchStartRef.current.x; const deltaX = touch.clientX - touchStartRef.current.x;
const deltaY = touch.clientY - touchStartRef.current.y; const deltaY = touch.clientY - touchStartRef.current.y;
const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY); const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY);

View file

@ -41,11 +41,11 @@ function computeStats(heatmap: PlaybackHeatmapData): HeatmapStats | null {
const totalSkips = heatmap.segments.reduce((sum, seg) => sum + seg.skip_count, 0); const totalSkips = heatmap.segments.reduce((sum, seg) => sum + seg.skip_count, 0);
const maxIntensitySegment = heatmap.segments.reduce( const maxIntensitySegment = heatmap.segments.reduce(
(max, seg) => (seg.intensity > max.intensity ? seg : max), (max, seg) => (seg.intensity > max.intensity ? seg : max),
heatmap.segments[0], heatmap.segments[0]!,
); );
const maxSkipSegment = heatmap.segments.reduce( const maxSkipSegment = heatmap.segments.reduce(
(max, seg) => (seg.skip_count > max.skip_count ? seg : max), (max, seg) => (seg.skip_count > max.skip_count ? seg : max),
heatmap.segments[0], heatmap.segments[0]!,
); );
return { totalListens, totalSkips, maxIntensitySegment, maxSkipSegment }; return { totalListens, totalSkips, maxIntensitySegment, maxSkipSegment };
} }

View file

@ -274,6 +274,7 @@ export async function retryPendingAnalytics(): Promise<number> {
for (let i = pending.length - 1; i >= 0; i--) { for (let i = pending.length - 1; i >= 0; i--) {
const pendingEvent = pending[i]; const pendingEvent = pending[i];
if (!pendingEvent) continue;
// Ne pas retry les événements trop anciens (> 7 jours) // Ne pas retry les événements trop anciens (> 7 jours)
const age = Date.now() - pendingEvent.timestamp; const age = Date.now() - pendingEvent.timestamp;

View file

@ -72,7 +72,7 @@ export function TrackListPaginationNav({
{showPageNumbers && ( {showPageNumbers && (
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
{visiblePages[0] > 1 && ( {visiblePages[0] != null && visiblePages[0] > 1 && (
<> <>
<button <button
type="button" type="button"
@ -83,7 +83,7 @@ export function TrackListPaginationNav({
> >
1 1
</button> </button>
{visiblePages[0] > 2 && ( {visiblePages[0] != null && visiblePages[0] > 2 && (
<span className="px-2 text-muted-foreground/90 tabular-nums">...</span> <span className="px-2 text-muted-foreground/90 tabular-nums">...</span>
)} )}
</> </>
@ -106,9 +106,11 @@ export function TrackListPaginationNav({
{page} {page}
</button> </button>
))} ))}
{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 && (
<span className="px-2 text-muted-foreground/90 tabular-nums">...</span> <span className="px-2 text-muted-foreground/90 tabular-nums">...</span>
)} )}
<button <button
@ -121,7 +123,8 @@ export function TrackListPaginationNav({
{totalPages} {totalPages}
</button> </button>
</> </>
)} );
})()}
</div> </div>
)} )}

View file

@ -117,6 +117,7 @@ export function useInfiniteScroll({
observerRef.current = new IntersectionObserver( observerRef.current = new IntersectionObserver(
(entries) => { (entries) => {
const [entry] = 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 // 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) { if (entry.isIntersecting && !isLoadingRef.current && hasMore) {
handleLoadMore(); handleLoadMore();

View file

@ -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,
},
});
}),
];

View file

@ -1,4 +1,5 @@
import { http, HttpResponse } from 'msw'; import { http, HttpResponse } from 'msw';
import { ghostHandlers } from './handlers-ghost';
/** /**
* FE-API-019: MSW Mock Handlers * FE-API-019: MSW Mock Handlers
@ -547,122 +548,8 @@ export const handlers = [
}); });
}), }),
// GHOST FEATURE — no backend implementation exists for education // Ghost features (Education, Gamification) are in handlers-ghost.ts
// These handlers are kept for frontend MSW development mode only // They are spread into this array below via ...ghostHandlers
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
}
});
}),
// Developer & Webhooks endpoints // Developer & Webhooks endpoints
http.get('*/api/v1/webhooks', () => { 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) // Catch-all for API to prevent network leaks (Phase 1: Stabilization)
http.all('*/api/v1/*', ({ request }) => { http.all('*/api/v1/*', ({ request }) => {
console.warn('[MSW] Intercepted unhandled API request:', request.method, request.url); console.warn('[MSW] Intercepted unhandled API request:', request.method, request.url);

View file

@ -62,6 +62,7 @@ export function getCookie(name: string): string | null {
for (let i = 0; i < cookies.length; i++) { for (let i = 0; i < cookies.length; i++) {
let cookie = cookies[i]; let cookie = cookies[i];
if (cookie == null) continue;
while (cookie.charAt(0) === ' ') { while (cookie.charAt(0) === ' ') {
cookie = cookie.substring(1, cookie.length); cookie = cookie.substring(1, cookie.length);
} }

View file

@ -168,6 +168,7 @@ class OfflineQueueService {
// Process requests in order (high priority first) // Process requests in order (high priority first)
while (this.queue.length > 0 && !this.isOffline()) { while (this.queue.length > 0 && !this.isOffline()) {
const request = this.queue[0]; const request = this.queue[0];
if (!request) break;
// #region agent log // #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(()=>{}); // 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 // #endregion

View file

@ -100,7 +100,7 @@ class ResponseCacheService {
for (const part of parts) { for (const part of parts) {
if (part.includes('=')) { if (part.includes('=')) {
const [key, value] = part.split('=').map((p) => p.trim()); const [key, value] = part.split('=').map((p) => p.trim());
directives[key.toLowerCase()] = value; if (key) directives[key.toLowerCase()] = value;
} else { } else {
directives[part.toLowerCase()] = true; directives[part.toLowerCase()] = true;
} }

View file

@ -81,7 +81,7 @@ function decodeJWT(token: string): JWTPayload | null {
return null; return null;
} }
// Décoder le payload (base64url) // Décoder le payload (base64url)
const payload = parts[1]; const payload = parts[1]!;
const decoded = atob(payload.replace(/-/g, '+').replace(/_/g, '/')); const decoded = atob(payload.replace(/-/g, '+').replace(/_/g, '/'));
return JSON.parse(decoded) as JWTPayload; return JSON.parse(decoded) as JWTPayload;
} catch (error) { } catch (error) {

View file

@ -21,7 +21,7 @@ export function getRelativeLuminance(r: number, g: number, b: number): number {
return normalized <= 0.03928 return normalized <= 0.03928
? normalized / 12.92 ? normalized / 12.92
: Math.pow((normalized + 0.055) / 1.055, 2.4); : Math.pow((normalized + 0.055) / 1.055, 2.4);
}); }) as [number, number, number];
// Calculate relative luminance using WCAG formula // Calculate relative luminance using WCAG formula
return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs; return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs;

View file

@ -70,7 +70,7 @@ export function buildCSPHeader(nonce?: string): string {
} as unknown as Record<string, string[]>; } as unknown as Record<string, string[]>;
if (nonce) { 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, src === "'nonce-__CSP_NONCE__'" ? `'nonce-${nonce}'` : src,
); );
} }

View file

@ -122,10 +122,10 @@ export function parseDuration(duration: string): number {
if (parts.length === 2) { if (parts.length === 2) {
// MM:SS // MM:SS
return parts[0] * 60 + parts[1]; return (parts[0] ?? 0) * 60 + (parts[1] ?? 0);
} else if (parts.length === 3) { } else if (parts.length === 3) {
// HH:MM:SS // 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; return 0;

View file

@ -85,8 +85,9 @@ export function formatUsername(username: string): string {
export function formatEmail(email: string): string { export function formatEmail(email: string): string {
const [localPart, domain] = email.split('@'); const [localPart, domain] = email.split('@');
if (!localPart) return email;
if (localPart.length > 3) { if (localPart.length > 3) {
return `${localPart.substring(0, 3)}***@${domain}`; return `${localPart.substring(0, 3)}***@${domain ?? ''}`;
} }
return email; return email;
} }

View file

@ -28,7 +28,7 @@
"noUnusedParameters": true, "noUnusedParameters": true,
"noImplicitReturns": true, "noImplicitReturns": true,
"noFallthroughCasesInSwitch": true, "noFallthroughCasesInSwitch": true,
// "noUncheckedIndexedAccess": true, // TODO: Enable progressively - requires fixing 200+ array/object access checks "noUncheckedIndexedAccess": true,
"noImplicitOverride": true, "noImplicitOverride": true,
"erasableSyntaxOnly": true, "erasableSyntaxOnly": true,
"noUncheckedSideEffectImports": true, "noUncheckedSideEffectImports": true,

View file

@ -2,6 +2,8 @@ import { defineConfig, configDefaults } from 'vitest/config';
import react from '@vitejs/plugin-react'; import react from '@vitejs/plugin-react';
import path from 'path'; import path from 'path';
import { fileURLToPath } from 'node:url'; 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'; // import { storybookTest } from '@storybook/addon-vitest/vitest-plugin';
const dirname = const dirname =
typeof __dirname !== 'undefined' typeof __dirname !== 'undefined'

View file

@ -12,6 +12,10 @@ import (
"strings" "strings"
"time" "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" "github.com/dutchcoders/go-clamd"
"go.uber.org/zap" "go.uber.org/zap"
) )