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 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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
|
|
@ -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}`;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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']
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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]);
|
||||||
|
|
|
||||||
|
|
@ -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}.
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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];
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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(() => {
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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(() => {
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
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 { 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);
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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'
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
)
|
)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue