+
+
+
+
+
+
+
+
+
+
+
+ Preview Volume
+
+ {previewVolume}%
+
+
+
+
+
+
+ Network
+ {isSpotifyBlockedCountry && (
+ (Blocked by Spotify)
+ )}
+
+
+
+
+ {detectedFlagPath ? (
) : (
)}
+
+ {isLoadingCurrentIPInfo
+ ? "Detecting..."
+ : currentIPInfo
+ ? showIPAddress
+ ? `${currentIPInfo.ip} - ${currentIPInfo.country}${detectedCountryCode ? ` (${detectedCountryCode})` : ""}`
+ : `${currentIPInfo.country}${detectedCountryCode ? ` (${detectedCountryCode})` : ""}`
+ : "Unavailable"}
+
+
+ {currentIPInfo && !isLoadingCurrentIPInfo && (
setShowIPAddress((prev) => !prev)} className="inline-flex h-6 w-6 items-center justify-center rounded-sm text-muted-foreground hover:bg-muted hover:text-foreground transition-colors" aria-label={showIPAddress ? "Hide IP" : "Show IP"}>
+ {showIPAddress ? : }
+ )}
+
+ {!isLoadingCurrentIPInfo && !currentIPInfo && currentIPInfoError && (
+ IP detection unavailable
+
)}
+
+
+ openExternal("https://afkarxyz.fyi")} className="gap-2">
+
+ Website
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ >);
+}
diff --git a/frontend/src/components/TrackInfo.tsx b/frontend/src/components/TrackInfo.tsx
new file mode 100644
index 0000000..4ea8e7a
--- /dev/null
+++ b/frontend/src/components/TrackInfo.tsx
@@ -0,0 +1,190 @@
+import { Button } from "@/components/ui/button";
+import { Card, CardContent } from "@/components/ui/card";
+import { Download, FolderOpen, CheckCircle, XCircle, FileText, FileCheck, Globe, ImageDown, Play, Pause } from "lucide-react";
+import { Spinner } from "@/components/ui/spinner";
+import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip";
+import type { TrackMetadata, TrackAvailability } from "@/types/api";
+import { usePreview } from "@/hooks/usePreview";
+import { AvailabilityLinks, hasAvailabilityLinks } from "./AvailabilityLinks";
+import { buildClickableArtists } from "@/lib/artist-links";
+interface TrackInfoProps {
+ track: TrackMetadata & {
+ album_name: string;
+ release_date: string;
+ };
+ isDownloading: boolean;
+ downloadingTrack: string | null;
+ isDownloaded: boolean;
+ isFailed: boolean;
+ isSkipped: boolean;
+ downloadingLyricsTrack?: string | null;
+ downloadedLyrics?: boolean;
+ failedLyrics?: boolean;
+ skippedLyrics?: boolean;
+ checkingAvailability?: boolean;
+ availability?: TrackAvailability;
+ downloadingCover?: boolean;
+ downloadedCover?: boolean;
+ failedCover?: boolean;
+ skippedCover?: boolean;
+ onDownload: (id: string, name: string, artists: string, albumName?: string, spotifyId?: string, playlistName?: string, durationMs?: number, position?: number, albumArtist?: string, releaseDate?: string, coverUrl?: string, spotifyTrackNumber?: number, spotifyDiscNumber?: number, spotifyTotalTracks?: number, spotifyTotalDiscs?: number, copyright?: string, publisher?: string) => void;
+ onDownloadLyrics?: (spotifyId: string, name: string, artists: string, albumName?: string, albumArtist?: string, releaseDate?: string, discNumber?: number) => void;
+ onCheckAvailability?: (spotifyId: string) => void;
+ onDownloadCover?: (coverUrl: string, trackName: string, artistName: string, albumName?: string, playlistName?: string, position?: number, trackId?: string, albumArtist?: string, releaseDate?: string, discNumber?: number) => void;
+ onOpenFolder: () => void;
+ onAlbumClick?: (album: {
+ id: string;
+ name: string;
+ external_urls: string;
+ }) => void;
+ onArtistClick?: (artist: {
+ id: string;
+ name: string;
+ external_urls: string;
+ }) => void;
+ onBack?: () => void;
+}
+export function TrackInfo({ track, isDownloading, downloadingTrack, isDownloaded, isFailed, isSkipped, downloadingLyricsTrack, downloadedLyrics, failedLyrics, skippedLyrics, checkingAvailability, availability, downloadingCover, downloadedCover, failedCover, skippedCover, onDownload, onDownloadLyrics, onCheckAvailability, onDownloadCover, onOpenFolder, onAlbumClick, onArtistClick, onBack, }: TrackInfoProps) {
+ const { playPreview, loadingPreview, playingTrack } = usePreview();
+ const hasAlbumClick = !!(onAlbumClick && track.album_id && track.album_url);
+ const clickableArtists = buildClickableArtists(track.artists, track.artists_data, track.artist_id, track.artist_url);
+ const formatDuration = (ms: number) => {
+ const minutes = Math.floor(ms / 60000);
+ const seconds = Math.floor((ms % 60000) / 1000);
+ return `${minutes}:${seconds.toString().padStart(2, "0")}`;
+ };
+ const formatPlays = (plays: string) => {
+ const num = parseInt(plays, 10);
+ if (isNaN(num))
+ return plays;
+ return num.toLocaleString();
+ };
+ return (
+ {onBack && (
+
+
+
+
)}
+
+
+
+ {track.images && (
+
+
+ {formatDuration(track.duration_ms)}
+
+
)}
+
+
+
+
+
{track.name}
+ {track.is_explicit && (E )}
+ {isSkipped ? ( ) : isDownloaded ? ( ) : isFailed ? ( ) : null}
+
+
+ {clickableArtists.length > 0 ? clickableArtists.map((artist, index) => (
+ {onArtistClick ? ( onArtistClick({
+ id: artist.id,
+ name: artist.name,
+ external_urls: artist.external_urls,
+ })}>
+ {artist.name}
+ ) : (artist.name)}
+ {index < clickableArtists.length - 1 && ", "}
+ )) : track.artists}
+
+
+
+
+
+
Album
+
{hasAlbumClick ? ( onAlbumClick?.({
+ id: track.album_id!,
+ name: track.album_name,
+ external_urls: track.album_url!,
+ })}>
+ {track.album_name}
+ ) : (track.album_name)}
+
+ {track.plays && (
+
Total Plays
+
{formatPlays(track.plays)}
+
)}
+
+
+
+
Release Date
+
{track.release_date}
+
+ {track.copyright && (
+
Copyright
+
+ {track.copyright}
+
+
)}
+
+
+ {track.spotify_id && (
+
onDownload(track.spotify_id || "", track.name, track.artists, track.album_name, track.spotify_id, undefined, track.duration_ms, track.track_number, track.album_artist, track.release_date, track.images, track.track_number, track.disc_number, track.total_tracks, track.total_discs, track.copyright, track.publisher)} disabled={isDownloading || downloadingTrack === track.spotify_id}>
+ {downloadingTrack === track.spotify_id ? ( ) : (<>
+
+ Download
+ >)}
+
+ {track.spotify_id && (
+
+ playPreview(track.spotify_id!, track.name)} variant="outline" size="icon" disabled={loadingPreview === track.spotify_id}>
+ {loadingPreview === track.spotify_id ? ( ) : playingTrack === track.spotify_id ? ( ) : ( )}
+
+
+
+ {playingTrack === track.spotify_id ? "Stop Preview" : "Play Preview"}
+
+ )}
+ {track.spotify_id && onDownloadLyrics && (
+
+ onDownloadLyrics(track.spotify_id!, track.name, track.artists, track.album_name, track.album_artist, track.release_date, track.disc_number)} variant="outline" size="icon" disabled={downloadingLyricsTrack === track.spotify_id}>
+ {downloadingLyricsTrack === track.spotify_id ? ( ) : skippedLyrics ? ( ) : downloadedLyrics ? ( ) : failedLyrics ? ( ) : ( )}
+
+
+
+ Download Separate Lyric
+
+ )}
+ {track.images && onDownloadCover && (
+
+ onDownloadCover(track.images, track.name, track.artists, track.album_name, undefined, undefined, track.spotify_id, track.album_artist, track.release_date, track.disc_number)} variant="outline" size="icon" disabled={downloadingCover}>
+ {downloadingCover ? ( ) : skippedCover ? ( ) : downloadedCover ? ( ) : failedCover ? ( ) : ( )}
+
+
+
+ Download Separate Cover
+
+ )}
+ {track.spotify_id && onCheckAvailability && (
+
+ onCheckAvailability(track.spotify_id!)} variant="outline" size="icon" disabled={checkingAvailability}>
+ {checkingAvailability ? ( ) : availability ? (hasAvailabilityLinks(availability) ? ( ) : ( )) : ( )}
+
+
+
+
+
+ )}
+ {isDownloaded && (
+
+
+
+
+
+
+ Open Folder
+
+ )}
+
)}
+
+
+
+ );
+}
diff --git a/frontend/src/components/TrackList.tsx b/frontend/src/components/TrackList.tsx
new file mode 100644
index 0000000..c161814
--- /dev/null
+++ b/frontend/src/components/TrackList.tsx
@@ -0,0 +1,384 @@
+import { Button } from "@/components/ui/button";
+import { Checkbox } from "@/components/ui/checkbox";
+import { Download, CheckCircle, XCircle, FileCheck, FileText, Globe, ImageDown, Play, Pause } from "lucide-react";
+import { Spinner } from "@/components/ui/spinner";
+import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip";
+import { Pagination, PaginationContent, PaginationEllipsis, PaginationItem, PaginationLink, PaginationNext, PaginationPrevious, } from "@/components/ui/pagination";
+import type { TrackMetadata, TrackAvailability } from "@/types/api";
+import { usePreview } from "@/hooks/usePreview";
+import { AvailabilityLinks, hasAvailabilityLinks } from "./AvailabilityLinks";
+import { buildClickableArtists } from "@/lib/artist-links";
+interface TrackListProps {
+ tracks: TrackMetadata[];
+ searchQuery: string;
+ sortBy: string;
+ selectedTracks: string[];
+ downloadedTracks: Set
;
+ failedTracks: Set;
+ skippedTracks: Set;
+ downloadingTrack: string | null;
+ isDownloading: boolean;
+ currentPage: number;
+ itemsPerPage: number;
+ showCheckboxes?: boolean;
+ hideAlbumColumn?: boolean;
+ folderName?: string;
+ isArtistDiscography?: boolean;
+ downloadedLyrics?: Set;
+ failedLyrics?: Set;
+ skippedLyrics?: Set;
+ downloadingLyricsTrack?: string | null;
+ checkingAvailabilityTrack?: string | null;
+ availabilityMap?: Map;
+ downloadedCovers?: Set;
+ failedCovers?: Set;
+ skippedCovers?: Set;
+ downloadingCoverTrack?: string | null;
+ onToggleTrack: (id: string) => void;
+ onToggleSelectAll: (tracks: TrackMetadata[]) => void;
+ onDownloadTrack: (id: string, name: string, artists: string, albumName: string, spotifyId?: string, folderName?: string, durationMs?: number, position?: number, albumArtist?: string, releaseDate?: string, coverUrl?: string, spotifyTrackNumber?: number, spotifyDiscNumber?: number, spotifyTotalTracks?: number, spotifyTotalDiscs?: number, copyright?: string, publisher?: string) => void;
+ onDownloadLyrics?: (spotifyId: string, name: string, artists: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number, albumArtist?: string, releaseDate?: string, discNumber?: number) => void;
+ onCheckAvailability?: (spotifyId: string) => void;
+ onDownloadCover?: (coverUrl: string, trackName: string, artistName: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number, trackId?: string, albumArtist?: string, releaseDate?: string, discNumber?: number) => void;
+ onPageChange: (page: number) => void;
+ onAlbumClick?: (album: {
+ id: string;
+ name: string;
+ external_urls: string;
+ }) => void;
+ onArtistClick?: (artist: {
+ id: string;
+ name: string;
+ external_urls: string;
+ }) => void;
+ onTrackClick?: (track: TrackMetadata) => void;
+}
+export function TrackList({ tracks, searchQuery, sortBy, selectedTracks, downloadedTracks, failedTracks, skippedTracks, downloadingTrack, isDownloading, currentPage, itemsPerPage, showCheckboxes = false, hideAlbumColumn = false, folderName, isArtistDiscography = false, downloadedLyrics, failedLyrics, skippedLyrics, downloadingLyricsTrack, checkingAvailabilityTrack, availabilityMap, downloadedCovers, failedCovers, skippedCovers, downloadingCoverTrack, onToggleTrack, onToggleSelectAll, onDownloadTrack, onDownloadLyrics, onCheckAvailability, onDownloadCover, onPageChange, onAlbumClick, onArtistClick, onTrackClick, }: TrackListProps) {
+ const { playPreview, loadingPreview, playingTrack } = usePreview();
+ let filteredTracks = tracks.filter((track) => {
+ if (!searchQuery)
+ return true;
+ const query = searchQuery.toLowerCase();
+ return (track.name.toLowerCase().includes(query) ||
+ track.artists.toLowerCase().includes(query) ||
+ track.album_name.toLowerCase().includes(query));
+ });
+ if (sortBy === "title-asc") {
+ filteredTracks = [...filteredTracks].sort((a, b) => a.name.localeCompare(b.name));
+ }
+ else if (sortBy === "title-desc") {
+ filteredTracks = [...filteredTracks].sort((a, b) => b.name.localeCompare(a.name));
+ }
+ else if (sortBy === "artist-asc") {
+ filteredTracks = [...filteredTracks].sort((a, b) => a.artists.localeCompare(b.artists));
+ }
+ else if (sortBy === "artist-desc") {
+ filteredTracks = [...filteredTracks].sort((a, b) => b.artists.localeCompare(a.artists));
+ }
+ else if (sortBy === "duration-asc") {
+ filteredTracks = [...filteredTracks].sort((a, b) => a.duration_ms - b.duration_ms);
+ }
+ else if (sortBy === "duration-desc") {
+ filteredTracks = [...filteredTracks].sort((a, b) => b.duration_ms - a.duration_ms);
+ }
+ else if (sortBy === "plays-asc") {
+ filteredTracks = [...filteredTracks].sort((a, b) => {
+ const aPlays = a.plays ? parseInt(a.plays, 10) : 0;
+ const bPlays = b.plays ? parseInt(b.plays, 10) : 0;
+ if (isNaN(aPlays))
+ return 1;
+ if (isNaN(bPlays))
+ return -1;
+ return aPlays - bPlays;
+ });
+ }
+ else if (sortBy === "plays-desc") {
+ filteredTracks = [...filteredTracks].sort((a, b) => {
+ const aPlays = a.plays ? parseInt(a.plays, 10) : 0;
+ const bPlays = b.plays ? parseInt(b.plays, 10) : 0;
+ if (isNaN(aPlays))
+ return 1;
+ if (isNaN(bPlays))
+ return -1;
+ return bPlays - aPlays;
+ });
+ }
+ else if (sortBy === "downloaded") {
+ filteredTracks = [...filteredTracks].sort((a, b) => {
+ const aDownloaded = a.spotify_id ? downloadedTracks.has(a.spotify_id) : false;
+ const bDownloaded = b.spotify_id ? downloadedTracks.has(b.spotify_id) : false;
+ return (bDownloaded ? 1 : 0) - (aDownloaded ? 1 : 0);
+ });
+ }
+ else if (sortBy === "not-downloaded") {
+ filteredTracks = [...filteredTracks].sort((a, b) => {
+ const aDownloaded = a.spotify_id ? downloadedTracks.has(a.spotify_id) : false;
+ const bDownloaded = b.spotify_id ? downloadedTracks.has(b.spotify_id) : false;
+ return (aDownloaded ? 1 : 0) - (bDownloaded ? 1 : 0);
+ });
+ }
+ else if (sortBy === "failed") {
+ filteredTracks = [...filteredTracks].sort((a, b) => {
+ const aFailed = a.spotify_id ? failedTracks.has(a.spotify_id) : false;
+ const bFailed = b.spotify_id ? failedTracks.has(b.spotify_id) : false;
+ return (bFailed ? 1 : 0) - (aFailed ? 1 : 0);
+ });
+ }
+ const totalPages = Math.ceil(filteredTracks.length / itemsPerPage);
+ const startIndex = (currentPage - 1) * itemsPerPage;
+ const endIndex = startIndex + itemsPerPage;
+ const paginatedTracks = filteredTracks.slice(startIndex, endIndex);
+ const getPaginationPages = (current: number, total: number): (number | 'ellipsis')[] => {
+ if (total <= 10) {
+ return Array.from({ length: total }, (_, i) => i + 1);
+ }
+ const pages: (number | 'ellipsis')[] = [];
+ pages.push(1);
+ if (current <= 7) {
+ for (let i = 2; i <= 10; i++) {
+ pages.push(i);
+ }
+ pages.push('ellipsis');
+ pages.push(total);
+ }
+ else if (current >= total - 7) {
+ pages.push('ellipsis');
+ for (let i = total - 9; i <= total; i++) {
+ pages.push(i);
+ }
+ }
+ else {
+ pages.push('ellipsis');
+ pages.push(current - 1);
+ pages.push(current);
+ pages.push(current + 1);
+ pages.push('ellipsis');
+ pages.push(total);
+ }
+ return pages;
+ };
+ const tracksWithId = filteredTracks.filter((track) => track.spotify_id);
+ const allSelected = tracksWithId.length > 0 &&
+ tracksWithId.every((track) => selectedTracks.includes(track.spotify_id!));
+ const formatDuration = (ms: number) => {
+ const minutes = Math.floor(ms / 60000);
+ const seconds = Math.floor((ms % 60000) / 1000);
+ return `${minutes}:${seconds.toString().padStart(2, "0")}`;
+ };
+ const formatPlays = (plays: string | undefined) => {
+ if (!plays)
+ return "";
+ const num = parseInt(plays, 10);
+ if (isNaN(num))
+ return plays;
+ return num.toLocaleString();
+ };
+ const getAvailabilityButtonIcon = (spotifyId?: string) => {
+ if (!spotifyId) {
+ return ;
+ }
+ if (checkingAvailabilityTrack === spotifyId) {
+ return ;
+ }
+ const availability = availabilityMap?.get(spotifyId);
+ if (!availability) {
+ return ;
+ }
+ if (hasAvailabilityLinks(availability)) {
+ return ;
+ }
+ return ;
+ };
+ return (
+
+
+
+
+
+ {showCheckboxes && (
+ onToggleSelectAll(filteredTracks)}/>
+ )}
+
+ #
+
+
+ Title
+
+ {!hideAlbumColumn && (
+ Album
+ )}
+
+ Duration
+
+
+ Plays
+
+
+ Actions
+
+
+
+
+ {paginatedTracks.map((track, index) => (
+ {showCheckboxes && (
+ {track.spotify_id && ( onToggleTrack(track.spotify_id!)}/>)}
+ )}
+
+
+ {startIndex + index + 1}
+ {track.status && (track.status === "UP" || track.status === "DOWN" || track.status === "NEW") && (
+ {track.status === "NEW" ? "●" : track.status === "UP" ? "▲" : "▼"}
+ )}
+
+
+
+
+ {track.images && (
)}
+
+
+ {onTrackClick ? ( onTrackClick(track)}>
+ {track.name}
+ ) : ({track.name} )}
+ {track.is_explicit && (E )}
+
+ {track.spotify_id && skippedTracks.has(track.spotify_id) ? ( ) : track.spotify_id && downloadedTracks.has(track.spotify_id) ? ( ) : track.spotify_id && failedTracks.has(track.spotify_id) ? ( ) : null}
+
+
+ {(() => {
+ const clickableArtists = buildClickableArtists(track.artists, track.artists_data, track.artist_id, track.artist_url);
+ if (clickableArtists.length === 0) {
+ return track.artists;
+ }
+ return clickableArtists.map((artist, i) => (
+ {onArtistClick ? ( onArtistClick({
+ id: artist.id,
+ name: artist.name,
+ external_urls: artist.external_urls,
+ })}>
+ {artist.name}
+ ) : (artist.name)}
+ {i < clickableArtists.length - 1 && ", "}
+ ));
+ })()}
+
+
+
+
+ {!hideAlbumColumn && (
+ {onAlbumClick && track.album_id && track.album_url ? ( onAlbumClick({
+ id: track.album_id!,
+ name: track.album_name,
+ external_urls: track.album_url!,
+ })}>
+ {track.album_name}
+ ) : (track.album_name)}
+ )}
+
+ {formatDuration(track.duration_ms)}
+
+
+ {track.plays ? formatPlays(track.plays) : ""}
+
+
+
+ {track.spotify_id && (
+
+ onDownloadTrack(track.spotify_id!, track.name, track.artists, track.album_name, track.spotify_id, folderName, track.duration_ms, startIndex + index + 1, track.album_artist, track.release_date, track.images, track.track_number, track.disc_number, track.total_tracks, track.total_discs, track.copyright, track.publisher)} size="icon" disabled={isDownloading || downloadingTrack === track.spotify_id}>
+ {downloadingTrack === track.spotify_id ? ( ) : skippedTracks.has(track.spotify_id) ? ( ) : downloadedTracks.has(track.spotify_id) ? ( ) : failedTracks.has(track.spotify_id) ? ( ) : ( )}
+
+
+
+ {downloadingTrack === track.spotify_id ? (Downloading...
) : skippedTracks.has(track.spotify_id) ? (Already exists
) : downloadedTracks.has(track.spotify_id) ? (Downloaded
) : failedTracks.has(track.spotify_id) ? (Failed
) : (Download Track
)}
+
+ )}
+ {track.spotify_id && (
+
+ playPreview(track.spotify_id!, track.name)} size="icon" variant="outline" disabled={loadingPreview === track.spotify_id}>
+ {loadingPreview === track.spotify_id ? ( ) : playingTrack === track.spotify_id ? ( ) : ( )}
+
+
+
+ {playingTrack === track.spotify_id ? "Stop Preview" : "Play Preview"}
+
+ )}
+ {track.spotify_id && onDownloadLyrics && (
+
+ onDownloadLyrics(track.spotify_id!, track.name, track.artists, track.album_name, folderName, isArtistDiscography, startIndex + index + 1, track.album_artist, track.release_date, track.disc_number)} size="icon" variant="outline" disabled={downloadingLyricsTrack === track.spotify_id}>
+ {downloadingLyricsTrack === track.spotify_id ? ( ) : skippedLyrics?.has(track.spotify_id) ? ( ) : downloadedLyrics?.has(track.spotify_id) ? ( ) : failedLyrics?.has(track.spotify_id) ? ( ) : ( )}
+
+
+
+ Download Separate Lyric
+
+ )}
+ {track.images && onDownloadCover && (
+
+ {
+ const trackId = track.spotify_id || `${track.name}-${track.artists}`;
+ onDownloadCover(track.images, track.name, track.artists, track.album_name, folderName, isArtistDiscography, startIndex + index + 1, trackId, track.album_artist, track.release_date, track.disc_number);
+ }} size="icon" variant="outline" disabled={downloadingCoverTrack === (track.spotify_id || `${track.name}-${track.artists}`)}>
+ {downloadingCoverTrack === (track.spotify_id || `${track.name}-${track.artists}`) ? ( ) : skippedCovers?.has(track.spotify_id || `${track.name}-${track.artists}`) ? ( ) : downloadedCovers?.has(track.spotify_id || `${track.name}-${track.artists}`) ? ( ) : failedCovers?.has(track.spotify_id || `${track.name}-${track.artists}`) ? ( ) : ( )}
+
+
+
+ Download Separate Cover
+
+ )}
+ {track.spotify_id && onCheckAvailability && (
+
+ onCheckAvailability(track.spotify_id!)} size="icon" variant="outline" disabled={checkingAvailabilityTrack === track.spotify_id}>
+ {getAvailabilityButtonIcon(track.spotify_id)}
+
+
+
+
+
+ )}
+
+
+ ))}
+
+
+
+
+
+ {totalPages > 1 && (
+
+
+ {
+ e.preventDefault();
+ if (currentPage > 1)
+ onPageChange(currentPage - 1);
+ }} className={currentPage === 1 ? "pointer-events-none opacity-50" : "cursor-pointer"}/>
+
+
+ {getPaginationPages(currentPage, totalPages).map((page, index) => (page === 'ellipsis' ? (
+
+ ) : (
+ {
+ e.preventDefault();
+ onPageChange(page);
+ }} isActive={currentPage === page} className="cursor-pointer">
+ {page}
+
+ )))}
+
+
+ {
+ e.preventDefault();
+ if (currentPage < totalPages)
+ onPageChange(currentPage + 1);
+ }} className={currentPage === totalPages
+ ? "pointer-events-none opacity-50"
+ : "cursor-pointer"}/>
+
+
+ )}
+
);
+}
diff --git a/frontend/src/components/ui/activity.tsx b/frontend/src/components/ui/activity.tsx
new file mode 100644
index 0000000..1cde669
--- /dev/null
+++ b/frontend/src/components/ui/activity.tsx
@@ -0,0 +1,63 @@
+'use client';
+import type { Variants } from 'motion/react';
+import type { HTMLAttributes } from 'react';
+import { forwardRef, useCallback, useImperativeHandle, useRef } from 'react';
+import { motion, useAnimation } from 'motion/react';
+import { cn } from '@/lib/utils';
+export interface ActivityIconHandle {
+ startAnimation: () => void;
+ stopAnimation: () => void;
+}
+interface ActivityIconProps extends HTMLAttributes {
+ size?: number;
+}
+const PATH_VARIANTS: Variants = {
+ normal: {
+ pathLength: 1,
+ opacity: 1,
+ pathOffset: 0,
+ },
+ animate: {
+ pathLength: [0, 1],
+ opacity: [0, 1],
+ pathOffset: [1, 0],
+ transition: {
+ duration: 0.8,
+ ease: 'easeInOut',
+ },
+ },
+};
+const ActivityIcon = forwardRef(({ onMouseEnter, onMouseLeave, className, size = 28, ...props }, ref) => {
+ const controls = useAnimation();
+ const isControlledRef = useRef(false);
+ useImperativeHandle(ref, () => {
+ isControlledRef.current = true;
+ return {
+ startAnimation: () => controls.start('animate'),
+ stopAnimation: () => controls.start('normal'),
+ };
+ });
+ const handleMouseEnter = useCallback((e: React.MouseEvent) => {
+ if (!isControlledRef.current) {
+ controls.start('animate');
+ }
+ else {
+ onMouseEnter?.(e);
+ }
+ }, [controls, onMouseEnter]);
+ const handleMouseLeave = useCallback((e: React.MouseEvent) => {
+ if (!isControlledRef.current) {
+ controls.start('normal');
+ }
+ else {
+ onMouseLeave?.(e);
+ }
+ }, [controls, onMouseLeave]);
+ return (
+
+
+
+
);
+});
+ActivityIcon.displayName = 'ActivityIcon';
+export { ActivityIcon };
diff --git a/frontend/src/components/ui/audio-lines.tsx b/frontend/src/components/ui/audio-lines.tsx
new file mode 100644
index 0000000..0040347
--- /dev/null
+++ b/frontend/src/components/ui/audio-lines.tsx
@@ -0,0 +1,87 @@
+"use client";
+import { motion, useAnimation } from "motion/react";
+import type { HTMLAttributes } from "react";
+import { forwardRef, useCallback, useImperativeHandle, useRef } from "react";
+import { cn } from "@/lib/utils";
+export interface AudioLinesIconHandle {
+ startAnimation: () => void;
+ stopAnimation: () => void;
+}
+interface AudioLinesIconProps extends HTMLAttributes {
+ size?: number;
+}
+const AudioLinesIcon = forwardRef(({ onMouseEnter, onMouseLeave, className, size = 28, ...props }, ref) => {
+ const controls = useAnimation();
+ const isControlledRef = useRef(false);
+ useImperativeHandle(ref, () => {
+ isControlledRef.current = true;
+ return {
+ startAnimation: () => controls.start("animate"),
+ stopAnimation: () => controls.start("normal"),
+ };
+ });
+ const handleMouseEnter = useCallback((e: React.MouseEvent) => {
+ if (isControlledRef.current) {
+ onMouseEnter?.(e);
+ }
+ else {
+ controls.start("animate");
+ }
+ }, [controls, onMouseEnter]);
+ const handleMouseLeave = useCallback((e: React.MouseEvent) => {
+ if (isControlledRef.current) {
+ onMouseLeave?.(e);
+ }
+ else {
+ controls.start("normal");
+ }
+ }, [controls, onMouseLeave]);
+ return ();
+});
+AudioLinesIcon.displayName = "AudioLinesIcon";
+export { AudioLinesIcon };
diff --git a/frontend/src/components/ui/badge.tsx b/frontend/src/components/ui/badge.tsx
new file mode 100644
index 0000000..569cbdd
--- /dev/null
+++ b/frontend/src/components/ui/badge.tsx
@@ -0,0 +1,24 @@
+import * as React from "react";
+import { Slot } from "@radix-ui/react-slot";
+import { cva, type VariantProps } from "class-variance-authority";
+import { cn } from "@/lib/utils";
+const badgeVariants = cva("inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden", {
+ variants: {
+ variant: {
+ default: "border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
+ secondary: "border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
+ destructive: "border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
+ outline: "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+});
+function Badge({ className, variant, asChild = false, ...props }: React.ComponentProps<"span"> & VariantProps & {
+ asChild?: boolean;
+}) {
+ const Comp = asChild ? Slot : "span";
+ return ( );
+}
+export { Badge, badgeVariants };
diff --git a/frontend/src/components/ui/blocks-icon.tsx b/frontend/src/components/ui/blocks-icon.tsx
new file mode 100644
index 0000000..b7d7029
--- /dev/null
+++ b/frontend/src/components/ui/blocks-icon.tsx
@@ -0,0 +1,53 @@
+"use client";
+import type { Variants } from "motion/react";
+import { motion, useAnimation } from "motion/react";
+import type { HTMLAttributes } from "react";
+import { forwardRef, useCallback, useImperativeHandle, useRef } from "react";
+import { cn } from "@/lib/utils";
+export interface BlocksIconHandle {
+ startAnimation: () => void;
+ stopAnimation: () => void;
+}
+interface BlocksIconProps extends HTMLAttributes {
+ size?: number;
+ loop?: boolean;
+}
+const VARIANTS: Variants = {
+ normal: { translateX: 0, translateY: 0 },
+ animate: { translateX: -4, translateY: 4 },
+};
+const BlocksIcon = forwardRef(({ onMouseEnter, onMouseLeave, className, size = 28, loop = false, ...props }, ref) => {
+ const controls = useAnimation();
+ const isControlledRef = useRef(false);
+ useImperativeHandle(ref, () => {
+ isControlledRef.current = true;
+ return {
+ startAnimation: () => controls.start("animate"),
+ stopAnimation: () => controls.start("normal"),
+ };
+ });
+ const handleMouseEnter = useCallback((e: React.MouseEvent) => {
+ if (isControlledRef.current) {
+ onMouseEnter?.(e);
+ }
+ else {
+ controls.start("animate");
+ }
+ }, [controls, onMouseEnter]);
+ const handleMouseLeave = useCallback((e: React.MouseEvent) => {
+ if (isControlledRef.current) {
+ onMouseLeave?.(e);
+ }
+ else {
+ controls.start("normal");
+ }
+ }, [controls, onMouseLeave]);
+ return ();
+});
+BlocksIcon.displayName = "BlocksIcon";
+export { BlocksIcon };
diff --git a/frontend/src/components/ui/bug-report-icon.tsx b/frontend/src/components/ui/bug-report-icon.tsx
new file mode 100644
index 0000000..463f9ff
--- /dev/null
+++ b/frontend/src/components/ui/bug-report-icon.tsx
@@ -0,0 +1,132 @@
+"use client";
+
+import type { Transition, Variants } from "motion/react";
+import { AnimatePresence, motion } from "motion/react";
+import { useEffect, useState, type HTMLAttributes } from "react";
+import { cn } from "@/lib/utils";
+
+type ReportIconMode = "bug" | "bulb";
+
+interface BugReportIconProps extends HTMLAttributes {
+ size?: number;
+ loop?: boolean;
+}
+
+const LOOP_INTERVAL_MS = 2200;
+
+const GROUP_VARIANTS: Variants = {
+ hidden: {
+ opacity: 0,
+ },
+ visible: {
+ opacity: 1,
+ transition: {
+ duration: 0.2,
+ ease: [0, 0, 0.2, 1],
+ },
+ },
+ exit: {
+ opacity: 0,
+ transition: {
+ duration: 0.18,
+ ease: [0.4, 0, 1, 1],
+ },
+ },
+};
+
+const DRAW_VARIANTS: Variants = {
+ hidden: {
+ pathLength: 0,
+ opacity: 0,
+ },
+ visible: {
+ pathLength: 1,
+ opacity: 1,
+ },
+ exit: {
+ pathLength: 1,
+ opacity: 0,
+ },
+};
+
+function createDrawTransition(delay = 0, duration = 0.36): Transition {
+ return {
+ duration,
+ delay,
+ ease: [0.4, 0, 0.2, 1],
+ opacity: { delay },
+ };
+}
+
+function BugPaths() {
+ return (<>
+
+
+
+
+
+
+
+
+
+
+
+ >);
+}
+
+function BulbPaths() {
+ return (<>
+
+
+
+ >);
+}
+
+function ReportIconGroup({ mode }: { mode: ReportIconMode }) {
+ return (
+ {mode === "bug" ? : }
+ );
+}
+
+function StaticBugIcon() {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+function BugReportIcon({ className, size = 28, loop = false, ...props }: BugReportIconProps) {
+ const [mode, setMode] = useState("bug");
+
+ useEffect(() => {
+ if (!loop) {
+ setMode("bug");
+ return;
+ }
+
+ const intervalId = window.setInterval(() => {
+ setMode((currentMode) => currentMode === "bug" ? "bulb" : "bug");
+ }, LOOP_INTERVAL_MS);
+
+ return () => window.clearInterval(intervalId);
+ }, [loop]);
+
+ return (
+
+ {loop ? (
+
+ ) : ( )}
+
+
);
+}
+
+export { BugReportIcon };
diff --git a/frontend/src/components/ui/button.tsx b/frontend/src/components/ui/button.tsx
new file mode 100644
index 0000000..a6bb15e
--- /dev/null
+++ b/frontend/src/components/ui/button.tsx
@@ -0,0 +1,35 @@
+import * as React from "react";
+import { Slot } from "@radix-ui/react-slot";
+import { cva, type VariantProps } from "class-variance-authority";
+import { cn } from "@/lib/utils";
+const buttonVariants = cva("inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all cursor-pointer disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", {
+ variants: {
+ variant: {
+ default: "bg-primary text-primary-foreground hover:bg-primary/90",
+ destructive: "bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
+ outline: "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
+ secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
+ ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
+ link: "text-primary underline-offset-4 hover:underline",
+ },
+ size: {
+ default: "h-9 px-4 py-2 has-[>svg]:px-3",
+ sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
+ lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
+ icon: "h-9 w-9 p-0",
+ "icon-sm": "h-8 w-8 p-0",
+ "icon-lg": "h-10 w-10 p-0",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ size: "default",
+ },
+});
+function Button({ className, variant, size, asChild = false, ...props }: React.ComponentProps<"button"> & VariantProps & {
+ asChild?: boolean;
+}) {
+ const Comp = asChild ? Slot : "button";
+ return ( );
+}
+export { Button, buttonVariants };
diff --git a/frontend/src/components/ui/card.tsx b/frontend/src/components/ui/card.tsx
new file mode 100644
index 0000000..9beb361
--- /dev/null
+++ b/frontend/src/components/ui/card.tsx
@@ -0,0 +1,24 @@
+import * as React from "react";
+import { cn } from "@/lib/utils";
+function Card({ className, ...props }: React.ComponentProps<"div">) {
+ return (
);
+}
+function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
+ return (
);
+}
+function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
+ return (
);
+}
+function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
+ return (
);
+}
+function CardAction({ className, ...props }: React.ComponentProps<"div">) {
+ return (
);
+}
+function CardContent({ className, ...props }: React.ComponentProps<"div">) {
+ return (
);
+}
+function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
+ return (
);
+}
+export { Card, CardHeader, CardFooter, CardTitle, CardAction, CardDescription, CardContent, };
diff --git a/frontend/src/components/ui/checkbox.tsx b/frontend/src/components/ui/checkbox.tsx
new file mode 100644
index 0000000..4da2c71
--- /dev/null
+++ b/frontend/src/components/ui/checkbox.tsx
@@ -0,0 +1,13 @@
+"use client";
+import * as React from "react";
+import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
+import { CheckIcon } from "lucide-react";
+import { cn } from "@/lib/utils";
+function Checkbox({ className, ...props }: React.ComponentProps) {
+ return (
+
+
+
+ );
+}
+export { Checkbox };
diff --git a/frontend/src/components/ui/coffee.tsx b/frontend/src/components/ui/coffee.tsx
new file mode 100644
index 0000000..0dd8da8
--- /dev/null
+++ b/frontend/src/components/ui/coffee.tsx
@@ -0,0 +1,67 @@
+'use client';
+import type { Variants } from 'motion/react';
+import type { HTMLAttributes } from 'react';
+import { forwardRef, useCallback, useImperativeHandle, useRef } from 'react';
+import { motion, useAnimation } from 'motion/react';
+import { cn } from '@/lib/utils';
+export interface CoffeeIconHandle {
+ startAnimation: () => void;
+ stopAnimation: () => void;
+}
+interface CoffeeIconProps extends HTMLAttributes {
+ size?: number;
+ loop?: boolean;
+}
+const PATH_VARIANTS: Variants = {
+ normal: {
+ y: 0,
+ opacity: 1,
+ },
+ animate: (custom: number) => ({
+ y: -3,
+ opacity: [0, 1, 0],
+ transition: {
+ repeat: Infinity,
+ duration: 1.5,
+ ease: 'easeInOut',
+ delay: 0.2 * custom,
+ },
+ }),
+};
+const CoffeeIcon = forwardRef(({ onMouseEnter, onMouseLeave, className, size = 28, loop = false, ...props }, ref) => {
+ const controls = useAnimation();
+ const isControlledRef = useRef(false);
+ useImperativeHandle(ref, () => {
+ isControlledRef.current = true;
+ return {
+ startAnimation: () => controls.start('animate'),
+ stopAnimation: () => controls.start('normal'),
+ };
+ });
+ const handleMouseEnter = useCallback((e: React.MouseEvent) => {
+ if (!isControlledRef.current) {
+ controls.start('animate');
+ }
+ else {
+ onMouseEnter?.(e);
+ }
+ }, [controls, onMouseEnter]);
+ const handleMouseLeave = useCallback((e: React.MouseEvent) => {
+ if (!isControlledRef.current) {
+ controls.start('normal');
+ }
+ else {
+ onMouseLeave?.(e);
+ }
+ }, [controls, onMouseLeave]);
+ return ();
+});
+CoffeeIcon.displayName = 'CoffeeIcon';
+export { CoffeeIcon };
diff --git a/frontend/src/components/ui/context-menu.tsx b/frontend/src/components/ui/context-menu.tsx
new file mode 100644
index 0000000..5a334e3
--- /dev/null
+++ b/frontend/src/components/ui/context-menu.tsx
@@ -0,0 +1,77 @@
+"use client";
+import * as React from "react";
+import * as ContextMenuPrimitive from "@radix-ui/react-context-menu";
+import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
+import { cn } from "@/lib/utils";
+function ContextMenu({ ...props }: React.ComponentProps) {
+ return ;
+}
+function ContextMenuTrigger({ ...props }: React.ComponentProps) {
+ return ( );
+}
+function ContextMenuGroup({ ...props }: React.ComponentProps) {
+ return ( );
+}
+function ContextMenuPortal({ ...props }: React.ComponentProps) {
+ return ( );
+}
+function ContextMenuSub({ ...props }: React.ComponentProps) {
+ return ;
+}
+function ContextMenuRadioGroup({ ...props }: React.ComponentProps) {
+ return ( );
+}
+function ContextMenuSubTrigger({ className, inset, children, ...props }: React.ComponentProps & {
+ inset?: boolean;
+}) {
+ return (
+ {children}
+
+ );
+}
+function ContextMenuSubContent({ className, ...props }: React.ComponentProps) {
+ return ( );
+}
+function ContextMenuContent({ className, ...props }: React.ComponentProps) {
+ return (
+
+ );
+}
+function ContextMenuItem({ className, inset, variant = "default", ...props }: React.ComponentProps & {
+ inset?: boolean;
+ variant?: "default" | "destructive";
+}) {
+ return ( );
+}
+function ContextMenuCheckboxItem({ className, children, checked, ...props }: React.ComponentProps) {
+ return (
+
+
+
+
+
+ {children}
+ );
+}
+function ContextMenuRadioItem({ className, children, ...props }: React.ComponentProps) {
+ return (
+
+
+
+
+
+ {children}
+ );
+}
+function ContextMenuLabel({ className, inset, ...props }: React.ComponentProps & {
+ inset?: boolean;
+}) {
+ return ( );
+}
+function ContextMenuSeparator({ className, ...props }: React.ComponentProps) {
+ return ( );
+}
+function ContextMenuShortcut({ className, ...props }: React.ComponentProps<"span">) {
+ return ( );
+}
+export { ContextMenu, ContextMenuTrigger, ContextMenuContent, ContextMenuItem, ContextMenuCheckboxItem, ContextMenuRadioItem, ContextMenuLabel, ContextMenuSeparator, ContextMenuShortcut, ContextMenuGroup, ContextMenuPortal, ContextMenuSub, ContextMenuSubContent, ContextMenuSubTrigger, ContextMenuRadioGroup, };
diff --git a/frontend/src/components/ui/dialog.tsx b/frontend/src/components/ui/dialog.tsx
new file mode 100644
index 0000000..8c5ef4f
--- /dev/null
+++ b/frontend/src/components/ui/dialog.tsx
@@ -0,0 +1,47 @@
+"use client";
+import * as React from "react";
+import * as DialogPrimitive from "@radix-ui/react-dialog";
+import { XIcon } from "lucide-react";
+import { cn } from "@/lib/utils";
+function Dialog({ ...props }: React.ComponentProps) {
+ return ;
+}
+function DialogTrigger({ ...props }: React.ComponentProps) {
+ return ;
+}
+function DialogPortal({ ...props }: React.ComponentProps) {
+ return ;
+}
+function DialogClose({ ...props }: React.ComponentProps) {
+ return ;
+}
+function DialogOverlay({ className, ...props }: React.ComponentProps) {
+ return ( );
+}
+function DialogContent({ className, children, showCloseButton = true, ...props }: React.ComponentProps & {
+ showCloseButton?: boolean;
+}) {
+ return (
+
+
+ {children}
+ {showCloseButton && (
+
+ Close
+ )}
+
+ );
+}
+function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
+ return (
);
+}
+function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
+ return (
);
+}
+function DialogTitle({ className, ...props }: React.ComponentProps) {
+ return ( );
+}
+function DialogDescription({ className, ...props }: React.ComponentProps) {
+ return ( );
+}
+export { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogOverlay, DialogPortal, DialogTitle, DialogTrigger, };
diff --git a/frontend/src/components/ui/dropdown-menu.tsx b/frontend/src/components/ui/dropdown-menu.tsx
new file mode 100644
index 0000000..c769f7e
--- /dev/null
+++ b/frontend/src/components/ui/dropdown-menu.tsx
@@ -0,0 +1,76 @@
+import * as React from "react";
+import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
+import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui";
+import { cn } from "@/lib/utils";
+function DropdownMenu({ ...props }: React.ComponentProps) {
+ return ;
+}
+function DropdownMenuPortal({ ...props }: React.ComponentProps) {
+ return ( );
+}
+function DropdownMenuTrigger({ ...props }: React.ComponentProps) {
+ return ( );
+}
+function DropdownMenuContent({ className, sideOffset = 4, ...props }: React.ComponentProps) {
+ return (
+
+ );
+}
+function DropdownMenuGroup({ ...props }: React.ComponentProps) {
+ return ( );
+}
+function DropdownMenuItem({ className, inset, variant = "default", ...props }: React.ComponentProps & {
+ inset?: boolean;
+ variant?: "default" | "destructive";
+}) {
+ return ( );
+}
+function DropdownMenuCheckboxItem({ className, children, checked, ...props }: React.ComponentProps) {
+ return (
+
+
+
+
+
+ {children}
+ );
+}
+function DropdownMenuRadioGroup({ ...props }: React.ComponentProps) {
+ return ( );
+}
+function DropdownMenuRadioItem({ className, children, ...props }: React.ComponentProps) {
+ return (
+
+
+
+
+
+ {children}
+ );
+}
+function DropdownMenuLabel({ className, inset, ...props }: React.ComponentProps & {
+ inset?: boolean;
+}) {
+ return ( );
+}
+function DropdownMenuSeparator({ className, ...props }: React.ComponentProps) {
+ return ( );
+}
+function DropdownMenuShortcut({ className, ...props }: React.ComponentProps<"span">) {
+ return ( );
+}
+function DropdownMenuSub({ ...props }: React.ComponentProps) {
+ return ;
+}
+function DropdownMenuSubTrigger({ className, inset, children, ...props }: React.ComponentProps & {
+ inset?: boolean;
+}) {
+ return (
+ {children}
+
+ );
+}
+function DropdownMenuSubContent({ className, ...props }: React.ComponentProps) {
+ return ( );
+}
+export { DropdownMenu, DropdownMenuPortal, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuGroup, DropdownMenuLabel, DropdownMenuItem, DropdownMenuCheckboxItem, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuSeparator, DropdownMenuShortcut, DropdownMenuSub, DropdownMenuSubTrigger, DropdownMenuSubContent, };
diff --git a/frontend/src/components/ui/file-music.tsx b/frontend/src/components/ui/file-music.tsx
new file mode 100644
index 0000000..aa0d576
--- /dev/null
+++ b/frontend/src/components/ui/file-music.tsx
@@ -0,0 +1,64 @@
+'use client';
+import type { Variants } from 'motion/react';
+import type { HTMLAttributes } from 'react';
+import { forwardRef, useCallback, useImperativeHandle, useRef } from 'react';
+import { motion, useAnimation } from 'motion/react';
+import { cn } from '@/lib/utils';
+export interface FileMusicIconHandle {
+ startAnimation: () => void;
+ stopAnimation: () => void;
+}
+interface FileMusicIconProps extends HTMLAttributes {
+ size?: number;
+}
+const PATH_VARIANTS: Variants = {
+ normal: {
+ pathLength: 1,
+ opacity: 1,
+ },
+ animate: {
+ pathLength: [0, 1],
+ opacity: [0, 1],
+ transition: {
+ duration: 0.6,
+ ease: 'easeInOut',
+ },
+ },
+};
+const FileMusicIcon = forwardRef(({ onMouseEnter, onMouseLeave, className, size = 28, ...props }, ref) => {
+ const controls = useAnimation();
+ const isControlledRef = useRef(false);
+ useImperativeHandle(ref, () => {
+ isControlledRef.current = true;
+ return {
+ startAnimation: () => controls.start('animate'),
+ stopAnimation: () => controls.start('normal'),
+ };
+ });
+ const handleMouseEnter = useCallback((e: React.MouseEvent) => {
+ if (!isControlledRef.current) {
+ controls.start('animate');
+ }
+ else {
+ onMouseEnter?.(e);
+ }
+ }, [controls, onMouseEnter]);
+ const handleMouseLeave = useCallback((e: React.MouseEvent) => {
+ if (!isControlledRef.current) {
+ controls.start('normal');
+ }
+ else {
+ onMouseLeave?.(e);
+ }
+ }, [controls, onMouseLeave]);
+ return (
+
+
+
+
+
+
+
);
+});
+FileMusicIcon.displayName = 'FileMusicIcon';
+export { FileMusicIcon };
diff --git a/frontend/src/components/ui/file-pen.tsx b/frontend/src/components/ui/file-pen.tsx
new file mode 100644
index 0000000..ccc727f
--- /dev/null
+++ b/frontend/src/components/ui/file-pen.tsx
@@ -0,0 +1,63 @@
+'use client';
+import type { Variants } from 'motion/react';
+import type { HTMLAttributes } from 'react';
+import { forwardRef, useCallback, useImperativeHandle, useRef } from 'react';
+import { motion, useAnimation } from 'motion/react';
+import { cn } from '@/lib/utils';
+export interface FilePenIconHandle {
+ startAnimation: () => void;
+ stopAnimation: () => void;
+}
+interface FilePenIconProps extends HTMLAttributes {
+ size?: number;
+}
+const PATH_VARIANTS: Variants = {
+ normal: {
+ pathLength: 1,
+ opacity: 1,
+ },
+ animate: {
+ pathLength: [0, 1],
+ opacity: [0, 1],
+ transition: {
+ duration: 0.6,
+ ease: 'easeInOut',
+ },
+ },
+};
+const FilePenIcon = forwardRef(({ onMouseEnter, onMouseLeave, className, size = 28, ...props }, ref) => {
+ const controls = useAnimation();
+ const isControlledRef = useRef(false);
+ useImperativeHandle(ref, () => {
+ isControlledRef.current = true;
+ return {
+ startAnimation: () => controls.start('animate'),
+ stopAnimation: () => controls.start('normal'),
+ };
+ });
+ const handleMouseEnter = useCallback((e: React.MouseEvent) => {
+ if (!isControlledRef.current) {
+ controls.start('animate');
+ }
+ else {
+ onMouseEnter?.(e);
+ }
+ }, [controls, onMouseEnter]);
+ const handleMouseLeave = useCallback((e: React.MouseEvent) => {
+ if (!isControlledRef.current) {
+ controls.start('normal');
+ }
+ else {
+ onMouseLeave?.(e);
+ }
+ }, [controls, onMouseLeave]);
+ return (
+
+
+
+
+
+
);
+});
+FilePenIcon.displayName = 'FilePenIcon';
+export { FilePenIcon };
diff --git a/frontend/src/components/ui/history-icon.tsx b/frontend/src/components/ui/history-icon.tsx
new file mode 100644
index 0000000..a954d37
--- /dev/null
+++ b/frontend/src/components/ui/history-icon.tsx
@@ -0,0 +1,97 @@
+"use client";
+import type { Transition, Variants } from "motion/react";
+import { motion, useAnimation } from "motion/react";
+import type { HTMLAttributes } from "react";
+import { forwardRef, useCallback, useImperativeHandle, useRef } from "react";
+import { cn } from "@/lib/utils";
+export interface HistoryIconHandle {
+ startAnimation: () => void;
+ stopAnimation: () => void;
+}
+interface HistoryIconProps extends HTMLAttributes {
+ size?: number;
+}
+const ARROW_TRANSITION: Transition = {
+ type: "spring",
+ stiffness: 250,
+ damping: 25,
+};
+const ARROW_VARIANTS: Variants = {
+ normal: {
+ rotate: "0deg",
+ },
+ animate: {
+ rotate: "-50deg",
+ },
+};
+const HAND_TRANSITION: Transition = {
+ duration: 0.6,
+ ease: [0.4, 0, 0.2, 1],
+};
+const HAND_VARIANTS: Variants = {
+ normal: {
+ rotate: 0,
+ originX: "0%",
+ originY: "100%",
+ },
+ animate: {
+ rotate: -360,
+ originX: "0%",
+ originY: "100%",
+ },
+};
+const MINUTE_HAND_TRANSITION: Transition = {
+ duration: 0.5,
+ ease: "easeInOut",
+};
+const MINUTE_HAND_VARIANTS: Variants = {
+ normal: {
+ rotate: 0,
+ originX: "0%",
+ originY: "0%",
+ },
+ animate: {
+ rotate: -45,
+ originX: "0%",
+ originY: "0%",
+ },
+};
+const HistoryIcon = forwardRef(({ onMouseEnter, onMouseLeave, className, size = 28, ...props }, ref) => {
+ const controls = useAnimation();
+ const isControlledRef = useRef(false);
+ useImperativeHandle(ref, () => {
+ isControlledRef.current = true;
+ return {
+ startAnimation: () => controls.start("animate"),
+ stopAnimation: () => controls.start("normal"),
+ };
+ });
+ const handleMouseEnter = useCallback((e: React.MouseEvent) => {
+ if (isControlledRef.current) {
+ onMouseEnter?.(e);
+ }
+ else {
+ controls.start("animate");
+ }
+ }, [controls, onMouseEnter]);
+ const handleMouseLeave = useCallback((e: React.MouseEvent) => {
+ if (isControlledRef.current) {
+ onMouseLeave?.(e);
+ }
+ else {
+ controls.start("normal");
+ }
+ }, [controls, onMouseLeave]);
+ return ();
+});
+HistoryIcon.displayName = "HistoryIcon";
+export { HistoryIcon };
diff --git a/frontend/src/components/ui/home.tsx b/frontend/src/components/ui/home.tsx
new file mode 100644
index 0000000..8b6eb25
--- /dev/null
+++ b/frontend/src/components/ui/home.tsx
@@ -0,0 +1,62 @@
+'use client';
+import type { Transition, Variants } from 'motion/react';
+import type { HTMLAttributes } from 'react';
+import { forwardRef, useCallback, useImperativeHandle, useRef } from 'react';
+import { motion, useAnimation } from 'motion/react';
+import { cn } from '@/lib/utils';
+export interface HomeIconHandle {
+ startAnimation: () => void;
+ stopAnimation: () => void;
+}
+interface HomeIconProps extends HTMLAttributes {
+ size?: number;
+}
+const DEFAULT_TRANSITION: Transition = {
+ duration: 0.6,
+ opacity: { duration: 0.2 },
+};
+const PATH_VARIANTS: Variants = {
+ normal: {
+ pathLength: 1,
+ opacity: 1,
+ },
+ animate: {
+ opacity: [0, 1],
+ pathLength: [0, 1],
+ },
+};
+const HomeIcon = forwardRef(({ onMouseEnter, onMouseLeave, className, size = 28, ...props }, ref) => {
+ const controls = useAnimation();
+ const isControlledRef = useRef(false);
+ useImperativeHandle(ref, () => {
+ isControlledRef.current = true;
+ return {
+ startAnimation: () => controls.start('animate'),
+ stopAnimation: () => controls.start('normal'),
+ };
+ });
+ const handleMouseEnter = useCallback((e: React.MouseEvent) => {
+ if (!isControlledRef.current) {
+ controls.start('animate');
+ }
+ else {
+ onMouseEnter?.(e);
+ }
+ }, [controls, onMouseEnter]);
+ const handleMouseLeave = useCallback((e: React.MouseEvent) => {
+ if (!isControlledRef.current) {
+ controls.start('normal');
+ }
+ else {
+ onMouseLeave?.(e);
+ }
+ }, [controls, onMouseLeave]);
+ return ();
+});
+HomeIcon.displayName = 'HomeIcon';
+export { HomeIcon };
diff --git a/frontend/src/components/ui/input-with-context.tsx b/frontend/src/components/ui/input-with-context.tsx
new file mode 100644
index 0000000..1c3ee0e
--- /dev/null
+++ b/frontend/src/components/ui/input-with-context.tsx
@@ -0,0 +1,156 @@
+import * as React from "react";
+import { Input } from "@/components/ui/input";
+import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSeparator, ContextMenuTrigger, } from "@/components/ui/context-menu";
+import { Scissors, Copy, Clipboard, Type } from "lucide-react";
+export interface InputWithContextProps extends React.InputHTMLAttributes {
+ onValueChange?: (value: string) => void;
+}
+const InputWithContext = React.forwardRef(({ className, type, onValueChange, onChange, ...props }, ref) => {
+ const inputRef = React.useRef(null);
+ const [hasSelection, setHasSelection] = React.useState(false);
+ const [canPaste, setCanPaste] = React.useState(false);
+ React.useImperativeHandle(ref, () => inputRef.current as HTMLInputElement);
+ const updateSelectionState = () => {
+ const input = inputRef.current;
+ if (!input)
+ return;
+ const start = input.selectionStart ?? 0;
+ const end = input.selectionEnd ?? 0;
+ setHasSelection(start !== end);
+ };
+ const checkClipboard = async () => {
+ try {
+ const text = await navigator.clipboard.readText();
+ setCanPaste(text.length > 0);
+ }
+ catch {
+ setCanPaste(false);
+ }
+ };
+ const handleCut = async () => {
+ const input = inputRef.current;
+ if (!input)
+ return;
+ const start = input.selectionStart ?? 0;
+ const end = input.selectionEnd ?? 0;
+ const selectedText = input.value.substring(start, end);
+ if (selectedText) {
+ try {
+ await navigator.clipboard.writeText(selectedText);
+ const newValue = input.value.substring(0, start) + input.value.substring(end);
+ input.value = newValue;
+ input.setSelectionRange(start, start);
+ if (onChange) {
+ const event = {
+ target: input,
+ currentTarget: input,
+ } as React.ChangeEvent;
+ onChange(event);
+ }
+ if (onValueChange) {
+ onValueChange(newValue);
+ }
+ input.focus();
+ }
+ catch (err) {
+ console.error("Failed to cut:", err);
+ }
+ }
+ };
+ const handleCopy = async () => {
+ const input = inputRef.current;
+ if (!input)
+ return;
+ const start = input.selectionStart ?? 0;
+ const end = input.selectionEnd ?? 0;
+ const selectedText = input.value.substring(start, end);
+ if (selectedText) {
+ try {
+ await navigator.clipboard.writeText(selectedText);
+ input.focus();
+ }
+ catch (err) {
+ console.error("Failed to copy:", err);
+ }
+ }
+ };
+ const handlePaste = async () => {
+ const input = inputRef.current;
+ if (!input)
+ return;
+ try {
+ const text = await navigator.clipboard.readText();
+ const start = input.selectionStart ?? 0;
+ const end = input.selectionEnd ?? 0;
+ const newValue = input.value.substring(0, start) + text + input.value.substring(end);
+ input.value = newValue;
+ const newPosition = start + text.length;
+ input.setSelectionRange(newPosition, newPosition);
+ if (onChange) {
+ const event = {
+ target: input,
+ currentTarget: input,
+ } as React.ChangeEvent;
+ onChange(event);
+ }
+ if (onValueChange) {
+ onValueChange(newValue);
+ }
+ input.focus();
+ await checkClipboard();
+ }
+ catch (err) {
+ console.error("Failed to paste:", err);
+ }
+ };
+ const handleSelectAll = () => {
+ const input = inputRef.current;
+ if (!input)
+ return;
+ input.select();
+ input.focus();
+ updateSelectionState();
+ };
+ const handleInputChange = (e: React.ChangeEvent) => {
+ if (onChange) {
+ onChange(e);
+ }
+ if (onValueChange) {
+ onValueChange(e.target.value);
+ }
+ };
+ return ( {
+ if (open) {
+ checkClipboard();
+ }
+ }}>
+
+
+
+
+
+
+ Cut
+ Ctrl+X
+
+
+
+ Copy
+ Ctrl+C
+
+
+
+ Paste
+ Ctrl+V
+
+
+
+
+ Select All
+ Ctrl+A
+
+
+ );
+});
+InputWithContext.displayName = "InputWithContext";
+export { InputWithContext };
diff --git a/frontend/src/components/ui/input.tsx b/frontend/src/components/ui/input.tsx
new file mode 100644
index 0000000..3a6e9b1
--- /dev/null
+++ b/frontend/src/components/ui/input.tsx
@@ -0,0 +1,6 @@
+import * as React from "react";
+import { cn } from "@/lib/utils";
+function Input({ className, type, ...props }: React.ComponentProps<"input">) {
+ return ( );
+}
+export { Input };
diff --git a/frontend/src/components/ui/label.tsx b/frontend/src/components/ui/label.tsx
new file mode 100644
index 0000000..e4ef7b3
--- /dev/null
+++ b/frontend/src/components/ui/label.tsx
@@ -0,0 +1,8 @@
+"use client";
+import * as React from "react";
+import * as LabelPrimitive from "@radix-ui/react-label";
+import { cn } from "@/lib/utils";
+function Label({ className, ...props }: React.ComponentProps) {
+ return ( );
+}
+export { Label };
diff --git a/frontend/src/components/ui/menubar.tsx b/frontend/src/components/ui/menubar.tsx
new file mode 100644
index 0000000..2dfd7f2
--- /dev/null
+++ b/frontend/src/components/ui/menubar.tsx
@@ -0,0 +1,60 @@
+"use client";
+import * as React from "react";
+import * as MenubarPrimitive from "@radix-ui/react-menubar";
+import { Check, ChevronRight, Circle } from "lucide-react";
+import { cn } from "@/lib/utils";
+const Menubar = React.forwardRef, React.ComponentPropsWithoutRef>(({ className, ...props }, ref) => ( ));
+Menubar.displayName = MenubarPrimitive.Root.displayName;
+const MenubarMenu = MenubarPrimitive.Menu;
+const MenubarGroup = MenubarPrimitive.Group;
+const MenubarPortal = MenubarPrimitive.Portal;
+const MenubarSub = MenubarPrimitive.Sub;
+const MenubarRadioGroup = MenubarPrimitive.RadioGroup;
+const MenubarTrigger = React.forwardRef, React.ComponentPropsWithoutRef>(({ className, ...props }, ref) => ( ));
+MenubarTrigger.displayName = MenubarPrimitive.Trigger.displayName;
+const MenubarSubTrigger = React.forwardRef, React.ComponentPropsWithoutRef & {
+ inset?: boolean;
+}>(({ className, inset, children, ...props }, ref) => (
+ {children}
+
+ ));
+MenubarSubTrigger.displayName = MenubarPrimitive.SubTrigger.displayName;
+const MenubarSubContent = React.forwardRef, React.ComponentPropsWithoutRef>(({ className, ...props }, ref) => ( ));
+MenubarSubContent.displayName = MenubarPrimitive.SubContent.displayName;
+const MenubarContent = React.forwardRef, React.ComponentPropsWithoutRef>(({ className, align = "start", alignOffset = -4, sideOffset = 8, ...props }, ref) => (
+
+ ));
+MenubarContent.displayName = MenubarPrimitive.Content.displayName;
+const MenubarItem = React.forwardRef, React.ComponentPropsWithoutRef & {
+ inset?: boolean;
+}>(({ className, inset, ...props }, ref) => ( ));
+MenubarItem.displayName = MenubarPrimitive.Item.displayName;
+const MenubarCheckboxItem = React.forwardRef, React.ComponentPropsWithoutRef>(({ className, children, checked, ...props }, ref) => (
+
+