diff --git a/apps/web/src/components/layout/Navbar.tsx b/apps/web/src/components/layout/Navbar.tsx index 711a28b79..e32d91a25 100644 --- a/apps/web/src/components/layout/Navbar.tsx +++ b/apps/web/src/components/layout/Navbar.tsx @@ -12,6 +12,7 @@ import { } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; +import { Tooltip } from '@/components/ui/tooltip'; import { useCartStore } from '../../stores/cartStore'; import { useTheme } from '../theme/ThemeProvider'; import { Notification } from '../../types'; @@ -143,15 +144,16 @@ export const Navbar: React.FC = ({ onNavigate, onLogout }) => { - + + + {/* Cart Trigger */} + + + )} {notification.actionUrl && ( - + + +
{share.expires_at && ( @@ -98,15 +100,16 @@ export function ShareLinkManagerItem({
{onRevoke && ( - + + + )} diff --git a/apps/web/src/features/chat/components/virtualized-chat-messages/VirtualizedChatMessagesScrollButton.tsx b/apps/web/src/features/chat/components/virtualized-chat-messages/VirtualizedChatMessagesScrollButton.tsx index 5196169fd..a4826d8bf 100644 --- a/apps/web/src/features/chat/components/virtualized-chat-messages/VirtualizedChatMessagesScrollButton.tsx +++ b/apps/web/src/features/chat/components/virtualized-chat-messages/VirtualizedChatMessagesScrollButton.tsx @@ -1,30 +1,33 @@ +import { Tooltip } from '@/components/ui/tooltip'; + interface VirtualizedChatMessagesScrollButtonProps { onClick: () => void; } export function VirtualizedChatMessagesScrollButton({ onClick }: VirtualizedChatMessagesScrollButtonProps) { return ( - + + + + + ); } diff --git a/apps/web/src/features/player/components/MiniPlayer.tsx b/apps/web/src/features/player/components/MiniPlayer.tsx index e1e0c4445..1b6f23472 100644 --- a/apps/web/src/features/player/components/MiniPlayer.tsx +++ b/apps/web/src/features/player/components/MiniPlayer.tsx @@ -5,6 +5,7 @@ import { ChevronUp, X } from 'lucide-react'; import { cn } from '@/lib/utils'; +import { Tooltip } from '@/components/ui/tooltip'; import { usePlayer } from '../hooks/usePlayer'; import { TrackInfo } from './TrackInfo'; import { PlayPauseButton } from './PlayPauseButton'; @@ -111,39 +112,41 @@ export function MiniPlayer({ {/* Toggle Button */} - - - {/* Close Button (optional) */} - {onClose && ( + + + + {/* Close Button (optional) */} + {onClose && ( + + + )} diff --git a/apps/web/src/features/player/components/PlayerControls.tsx b/apps/web/src/features/player/components/PlayerControls.tsx index 6d63febfb..5ffbf1b17 100644 --- a/apps/web/src/features/player/components/PlayerControls.tsx +++ b/apps/web/src/features/player/components/PlayerControls.tsx @@ -1,6 +1,7 @@ import { Play, Pause, SkipBack, SkipForward, Shuffle, Repeat } from 'lucide-react'; import { cn } from '@/lib/utils'; import { Button } from '@/components/ui/button'; +import { Tooltip } from '@/components/ui/tooltip'; export interface PlayerControlsProps { isPlaying: boolean; @@ -27,18 +28,19 @@ export function PlayerControls({ }: PlayerControlsProps) { return (
- + + + - + + +
); } diff --git a/apps/web/src/features/player/components/PlayerExpanded.tsx b/apps/web/src/features/player/components/PlayerExpanded.tsx index f44dfd559..8209e8692 100644 --- a/apps/web/src/features/player/components/PlayerExpanded.tsx +++ b/apps/web/src/features/player/components/PlayerExpanded.tsx @@ -3,6 +3,7 @@ import { usePlayerStore } from '../store/playerStore'; import { cn } from '@/lib/utils'; import { Button } from '@/components/ui/button'; import { Slider } from '@/components/ui/slider'; +import { Tooltip } from '@/components/ui/tooltip'; import { ChevronDown, Heart, MoreHorizontal, Share2, Mic2, AlignLeft @@ -167,15 +168,16 @@ export function PlayerExpanded({ isOpen, onClose, currentTime, duration, onSeek, - + + + @@ -191,15 +193,16 @@ export function PlayerExpanded({ isOpen, onClose, currentTime, duration, onSeek, onMouseLeave={() => setAutoScrollLyrics(true)} >
- + + +
{lyrics?.length ? (
{/* Repeat Button */} - + /> + {repeat === 'playlist' && ( + + )} + {getRepeatLabel()} + + {/* Shuffle Button */} - + + +
); } diff --git a/apps/web/src/features/player/components/VolumeControl.tsx b/apps/web/src/features/player/components/VolumeControl.tsx index 14bcd8c0a..d5c89fca5 100644 --- a/apps/web/src/features/player/components/VolumeControl.tsx +++ b/apps/web/src/features/player/components/VolumeControl.tsx @@ -6,6 +6,7 @@ import React, { useState, useRef, useCallback, useEffect } from 'react'; import { Volume2, VolumeX, Volume1 } from 'lucide-react'; import { cn } from '@/lib/utils'; +import { Tooltip } from '@/components/ui/tooltip'; export interface VolumeControlProps { volume: number; // 0-100 @@ -101,23 +102,24 @@ export function VolumeControl({ return (
{/* Mute Button */} - + + + {/* Volume Slider */} {showSlider && ( diff --git a/apps/web/src/features/search/components/search-page/SearchPage.tsx b/apps/web/src/features/search/components/search-page/SearchPage.tsx index db996cf49..f2f0e2cf9 100644 --- a/apps/web/src/features/search/components/search-page/SearchPage.tsx +++ b/apps/web/src/features/search/components/search-page/SearchPage.tsx @@ -5,7 +5,6 @@ import { SearchPageEmpty } from './SearchPageEmpty'; import { SearchPageError } from './SearchPageError'; import { SearchPageResults } from './SearchPageResults'; import { SearchPageSkeleton } from './SearchPageSkeleton'; -import { LoadingSpinner } from '@/components/ui/loading-spinner'; /** * Search page — orchestrator. @@ -38,19 +37,13 @@ export function SearchPage() { )} {isLoading ? ( -
- -

Scanning frequencies...

-
+ ) : !query ? ( ) : !hasResults ? ( ) : results ? ( - + ) : null}
); diff --git a/apps/web/src/features/search/components/search-page/SearchPageResults.tsx b/apps/web/src/features/search/components/search-page/SearchPageResults.tsx index 012f2f177..d3da40c0a 100644 --- a/apps/web/src/features/search/components/search-page/SearchPageResults.tsx +++ b/apps/web/src/features/search/components/search-page/SearchPageResults.tsx @@ -5,12 +5,14 @@ import { Avatar } from '@/components/ui/avatar'; import { Music, User } from 'lucide-react'; import { formatDistanceToNow } from 'date-fns'; import type { SearchResults } from '@/types/search'; +import { highlightMatch } from '../../utils/highlightMatch'; interface SearchPageResultsProps { results: SearchResults; + query?: string; } -export function SearchPageResults({ results }: SearchPageResultsProps) { +export function SearchPageResults({ results, query = '' }: SearchPageResultsProps) { const navigate = useNavigate(); return ( @@ -72,10 +74,10 @@ export function SearchPageResults({ results }: SearchPageResultsProps) {

- {track.title} + {highlightMatch(track.title, query)}

- {track.artist} + {highlightMatch(track.artist, query)}

@@ -105,7 +107,7 @@ export function SearchPageResults({ results }: SearchPageResultsProps) { className="w-24 h-24 mb-4 shadow-lg group-hover:scale-105 transition-transform" />

- {artist.username} + {highlightMatch(artist.username, query)}

{artist.followers_count ?? 0} followers @@ -143,8 +145,8 @@ export function SearchPageResults({ results }: SearchPageResultsProps) { )}

-

{track.title}

-

{track.artist}

+

{highlightMatch(track.title, query)}

+

{highlightMatch(track.artist, query)}

{track.created_at ? `${formatDistanceToNow(new Date(track.created_at))} ago` : null} @@ -172,7 +174,7 @@ export function SearchPageResults({ results }: SearchPageResultsProps) { className="w-32 h-32 mb-4 shadow-lg group-hover:scale-105 transition-transform" />

- {artist.username} + {highlightMatch(artist.username, query)}

))} @@ -202,7 +204,7 @@ export function SearchPageResults({ results }: SearchPageResultsProps) {
-

{playlist.title}

+

{highlightMatch(playlist.title, query)}

{playlist.description ?? 'No description'}

diff --git a/apps/web/src/features/search/components/search-page/SearchPageSkeleton.tsx b/apps/web/src/features/search/components/search-page/SearchPageSkeleton.tsx index 83b33ac21..bd208cbf0 100644 --- a/apps/web/src/features/search/components/search-page/SearchPageSkeleton.tsx +++ b/apps/web/src/features/search/components/search-page/SearchPageSkeleton.tsx @@ -1,20 +1,66 @@ import { Skeleton } from '@/components/ui/skeleton'; /** - * Skeleton aligned with SearchPage layout to avoid layout shift. - * Same structure: header (title + input) + content area. + * Content-aware skeleton matching SearchPageResults layout. + * + * Renders two sections: + * 1. "Top Tracks" — 6 horizontal cards (icon + title/subtitle) + * 2. "Artists" — 5 avatar cards + * + * No arbitrary values — uses Tailwind scale + layout primitives. */ export function SearchPageSkeleton() { return ( -
-
- - -
-
- - +
+ {/* Tabs bar skeleton */} +
+ + + +
+ + {/* Top Tracks section */} +
+
+ + +
+
+ {Array.from({ length: 6 }).map((_, i) => ( +
+ +
+ + +
+
+ ))} +
+
+ + {/* Artists section */} +
+
+ + +
+
+ {Array.from({ length: 5 }).map((_, i) => ( +
+ + + +
+ ))} +
+
); } diff --git a/apps/web/src/features/search/components/search/SearchDropdown.tsx b/apps/web/src/features/search/components/search/SearchDropdown.tsx index 0670006d9..654679d79 100644 --- a/apps/web/src/features/search/components/search/SearchDropdown.tsx +++ b/apps/web/src/features/search/components/search/SearchDropdown.tsx @@ -2,6 +2,7 @@ import { Clock, Music, User, List } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { cn } from '@/lib/utils'; import type { SearchResult } from './types'; +import { highlightMatch } from '../../utils/highlightMatch'; export type DisplayItem = | { type: 'history'; data: string } @@ -140,10 +141,10 @@ export function SearchDropdown({ {getResultIcon(result.type)}
-
{result.title}
+
{highlightMatch(result.title, query)}
{result.subtitle && (
- {result.subtitle} + {highlightMatch(result.subtitle, query)}
)}
diff --git a/apps/web/src/features/search/utils/highlightMatch.tsx b/apps/web/src/features/search/utils/highlightMatch.tsx new file mode 100644 index 000000000..1725e8026 --- /dev/null +++ b/apps/web/src/features/search/utils/highlightMatch.tsx @@ -0,0 +1,33 @@ +import React from 'react'; + +/** + * Highlights portions of `text` that match `query` (case-insensitive). + * + * Returns the original string when there's nothing to highlight, + * or a ReactNode array with `` wrappers around matched fragments. + */ +export function highlightMatch( + text: string, + query: string, +): React.ReactNode { + if (!query.trim()) return text; + + const escaped = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const regex = new RegExp(`(${escaped})`, 'gi'); + const parts = text.split(regex); + + if (parts.length === 1) return text; + + return parts.map((part, i) => + regex.test(part) ? ( + + {part} + + ) : ( + {part} + ), + ); +} diff --git a/apps/web/src/features/studio/components/cloud-file-browser/FileTableRow.tsx b/apps/web/src/features/studio/components/cloud-file-browser/FileTableRow.tsx index 2c12a41c4..bf4d233a6 100644 --- a/apps/web/src/features/studio/components/cloud-file-browser/FileTableRow.tsx +++ b/apps/web/src/features/studio/components/cloud-file-browser/FileTableRow.tsx @@ -1,5 +1,6 @@ import React from 'react'; import { Button } from '@/components/ui/button'; +import { Tooltip } from '@/components/ui/tooltip'; import { CheckSquare, Square, @@ -122,15 +123,16 @@ export function FileTableRow({
{file.type === 'audio' && ( - + + + )} - + + + + + + - + + + + + +
Sort: