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:
parent
1695606e24
commit
09bb663659
50 changed files with 252 additions and 187 deletions
|
|
@ -98,6 +98,10 @@ export function Onboarding({
|
|||
const isFirstStep = currentStep === 0;
|
||||
const isLastStep = currentStep === steps.length - 1;
|
||||
|
||||
if (!step) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
|
|
@ -110,9 +114,9 @@ export function Onboarding({
|
|||
<div className="flex items-center justify-between w-full">
|
||||
{/* Progress indicator */}
|
||||
<div className="flex items-center gap-2">
|
||||
{steps.map((_, index) => (
|
||||
{steps.map((step, index) => (
|
||||
<div
|
||||
key={index}
|
||||
key={step.title}
|
||||
className={cn(
|
||||
'h-2 w-2 rounded-full transition-colors',
|
||||
index === currentStep
|
||||
|
|
|
|||
|
|
@ -129,7 +129,7 @@ export function BarChart({
|
|||
className="cursor-pointer transition-opacity hover:opacity-80"
|
||||
>
|
||||
<title>
|
||||
{bar.label}: {data[index].value}
|
||||
{bar.label}: {data[index]?.value}
|
||||
</title>
|
||||
</rect>
|
||||
{showValues && (
|
||||
|
|
@ -140,7 +140,7 @@ export function BarChart({
|
|||
className="fill-foreground text-[1.5px]"
|
||||
fontSize="1.5"
|
||||
>
|
||||
{data[index].value}
|
||||
{data[index]?.value}
|
||||
</text>
|
||||
)}
|
||||
</g>
|
||||
|
|
|
|||
|
|
@ -145,7 +145,7 @@ export function LineChart({
|
|||
className="cursor-pointer transition-all hover:r-[6]"
|
||||
>
|
||||
<title>
|
||||
{point.label}: {data[index].value}
|
||||
{point.label}: {data[index]?.value}
|
||||
</title>
|
||||
</circle>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -51,10 +51,13 @@ export const StatCard: React.FC<StatCardProps> = ({
|
|||
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}`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
|
|
|||
|
|
@ -23,6 +23,10 @@ export const QuizModal: React.FC<QuizModalProps> = ({
|
|||
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;
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -100,11 +100,12 @@ export function Tabs({
|
|||
attempts++;
|
||||
}
|
||||
|
||||
if (items[newIndex] && !items[newIndex].disabled) {
|
||||
handleTabChange(items[newIndex].id);
|
||||
const targetItem = items[newIndex];
|
||||
if (targetItem && !targetItem.disabled) {
|
||||
handleTabChange(targetItem.id);
|
||||
// Focus sur le nouvel onglet après un court délai pour permettre la mise à jour
|
||||
setTimeout(() => {
|
||||
tabRefs.current[items[newIndex].id]?.focus();
|
||||
tabRefs.current[targetItem.id]?.focus();
|
||||
}, 0);
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<p
|
||||
key={i}
|
||||
|
|
|
|||
|
|
@ -59,16 +59,17 @@ export function GlobalSearchBar({
|
|||
searchPromises.push(Promise.resolve({ playlists: [], total: 0 }));
|
||||
}
|
||||
|
||||
const [tracksData, usersData, playlistsData] = await Promise.allSettled(
|
||||
searchPromises,
|
||||
);
|
||||
const settled = await Promise.allSettled(searchPromises);
|
||||
const tracksData = settled[0];
|
||||
const usersData = settled[1];
|
||||
const playlistsData = settled[2];
|
||||
|
||||
const duration = performance.now() - startTime;
|
||||
logger.debug('Search requests completed', {
|
||||
duration: `${duration.toFixed(2)}ms`,
|
||||
tracksStatus: tracksData.status,
|
||||
usersStatus: usersData.status,
|
||||
playlistsStatus: playlistsData.status,
|
||||
tracksStatus: tracksData?.status,
|
||||
usersStatus: usersData?.status,
|
||||
playlistsStatus: playlistsData?.status,
|
||||
component: 'GlobalSearchBar',
|
||||
});
|
||||
|
||||
|
|
@ -76,7 +77,7 @@ export function GlobalSearchBar({
|
|||
|
||||
// Add track suggestions
|
||||
// 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 }) => {
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ function formatRelativeTime(timestamp: string): string {
|
|||
const units: Record<string, string> = {
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -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']
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -199,7 +199,7 @@ export const Avatar = React.forwardRef<HTMLDivElement, AvatarProps>(
|
|||
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();
|
||||
};
|
||||
|
|
|
|||
|
|
@ -112,7 +112,7 @@ export function Dropdown({
|
|||
focusedIndexRef.current >= 0 &&
|
||||
elements[focusedIndexRef.current]
|
||||
) {
|
||||
elements[focusedIndexRef.current].click();
|
||||
elements[focusedIndexRef.current]?.click();
|
||||
}
|
||||
break;
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -31,9 +31,9 @@ export const TabsList = React.forwardRef<HTMLDivElement, TabsListProps>(
|
|||
}
|
||||
|
||||
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);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
<img
|
||||
src={
|
||||
tracks[0].coverUrl || 'https://via.placeholder.com/400'
|
||||
tracks[0]?.coverUrl || 'https://via.placeholder.com/400'
|
||||
}
|
||||
alt=""
|
||||
className="w-full h-full object-cover"
|
||||
|
|
@ -40,7 +40,7 @@ export function ProfileViewOverview({
|
|||
className="mb-2"
|
||||
/>
|
||||
<h2 className="text-2xl md:text-4xl font-heading font-bold text-foreground mb-2 tracking-tight">
|
||||
{tracks[0].title}
|
||||
{tracks[0]?.title}
|
||||
</h2>
|
||||
<p className="text-muted-foreground mb-6">
|
||||
Latest upload from {profile.username}.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -153,7 +153,7 @@ export const ChatRoom: React.FC<ChatRoomProps> = ({ 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 (
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -46,8 +46,9 @@ export function VirtualizedChatMessages({
|
|||
useEffect(() => {
|
||||
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();
|
||||
|
|
|
|||
|
|
@ -106,7 +106,7 @@ export const useChatStore = create<ChatState>()(
|
|||
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<ChatState>()(
|
|||
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<ChatState>()(
|
|||
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];
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -194,7 +194,7 @@ export function Cart({ isOpen, onClose }: CartProps) {
|
|||
<div className="flex justify-between text-lg font-semibold">
|
||||
<span>Total</span>
|
||||
<span>
|
||||
{items.length > 0
|
||||
{items.length > 0 && items[0]
|
||||
? formatPrice(getTotal(), items[0].product.currency)
|
||||
: '€0.00'}
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<p
|
||||
key={i}
|
||||
|
|
|
|||
|
|
@ -47,7 +47,9 @@ export function QualitySelector({
|
|||
: DEFAULT_QUALITIES;
|
||||
|
||||
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
|
||||
useEffect(() => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -274,6 +274,7 @@ export async function retryPendingAnalytics(): Promise<number> {
|
|||
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -72,7 +72,7 @@ export function TrackListPaginationNav({
|
|||
|
||||
{showPageNumbers && (
|
||||
<div className="flex items-center gap-1">
|
||||
{visiblePages[0] > 1 && (
|
||||
{visiblePages[0] != null && visiblePages[0] > 1 && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -83,7 +83,7 @@ export function TrackListPaginationNav({
|
|||
>
|
||||
1
|
||||
</button>
|
||||
{visiblePages[0] > 2 && (
|
||||
{visiblePages[0] != null && visiblePages[0] > 2 && (
|
||||
<span className="px-2 text-muted-foreground/90 tabular-nums">...</span>
|
||||
)}
|
||||
</>
|
||||
|
|
@ -106,9 +106,11 @@ export function TrackListPaginationNav({
|
|||
{page}
|
||||
</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>
|
||||
)}
|
||||
<button
|
||||
|
|
@ -121,7 +123,8 @@ export function TrackListPaginationNav({
|
|||
{totalPages}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
132
apps/web/src/mocks/handlers-ghost.ts
Normal file
132
apps/web/src/mocks/handlers-ghost.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
}),
|
||||
];
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -70,7 +70,7 @@ export function buildCSPHeader(nonce?: string): string {
|
|||
} as unknown as Record<string, string[]>;
|
||||
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)
|
||||
|
|
|
|||
Loading…
Reference in a new issue