v7.0.6
This commit is contained in:
+17
-1
@@ -714,6 +714,17 @@ func FilterTrack(data map[string]interface{}, albumFetchData ...map[string]inter
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if albumArtistsString == "" {
|
||||||
|
albumArtists := extractArtists(getMap(albumData, "artists"))
|
||||||
|
if len(albumArtists) > 0 {
|
||||||
|
albumArtistNames := []string{}
|
||||||
|
for _, artist := range albumArtists {
|
||||||
|
albumArtistNames = append(albumArtistNames, getString(artist, "name"))
|
||||||
|
}
|
||||||
|
albumArtistsString = strings.Join(albumArtistNames, ", ")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
albumInfo = map[string]interface{}{
|
albumInfo = map[string]interface{}{
|
||||||
"id": albumID,
|
"id": albumID,
|
||||||
"name": getString(albumData, "name"),
|
"name": getString(albumData, "name"),
|
||||||
@@ -1228,6 +1239,11 @@ func extractDiscographyItems(itemsData map[string]interface{}) []map[string]inte
|
|||||||
return items
|
return items
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func stripHTMLTags(s string) string {
|
||||||
|
re := regexp.MustCompile(`<[^>]*>`)
|
||||||
|
return re.ReplaceAllString(s, "")
|
||||||
|
}
|
||||||
|
|
||||||
func FilterArtist(data map[string]interface{}) map[string]interface{} {
|
func FilterArtist(data map[string]interface{}) map[string]interface{} {
|
||||||
dataMap := getMap(data, "data")
|
dataMap := getMap(data, "data")
|
||||||
artistData := getMap(dataMap, "artistUnion")
|
artistData := getMap(dataMap, "artistUnion")
|
||||||
@@ -1243,7 +1259,7 @@ func FilterArtist(data map[string]interface{}) map[string]interface{} {
|
|||||||
if ok {
|
if ok {
|
||||||
biographyText := getString(biographyMap, "text")
|
biographyText := getString(biographyMap, "text")
|
||||||
if biographyText != "" {
|
if biographyText != "" {
|
||||||
profile["biography"] = html.UnescapeString(biographyText)
|
profile["biography"] = html.UnescapeString(stripHTMLTags(biographyText))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
export const langColors: Record<string, string> = {
|
||||||
|
"TypeScript": "#2b7489",
|
||||||
|
"Go": "#375eab",
|
||||||
|
"Python": "#3572A5",
|
||||||
|
"CSS": "#563d7c",
|
||||||
|
"HTML": "#e44b23",
|
||||||
|
"JavaScript": "#f1e05a",
|
||||||
|
"Java": "#b07219",
|
||||||
|
"C": "#555555",
|
||||||
|
"C Sharp": "#178600",
|
||||||
|
"cpp": "#f34b7d",
|
||||||
|
"Ruby": "#701516",
|
||||||
|
"PHP": "#4F5D95",
|
||||||
|
"Swift": "#ffac45",
|
||||||
|
"Kotlin": "#F18E33",
|
||||||
|
"Rust": "#dea584",
|
||||||
|
"Shell": "#89e051"
|
||||||
|
};
|
||||||
@@ -8,11 +8,12 @@ import { Label } from "@/components/ui/label";
|
|||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
||||||
import { Bug, Lightbulb, ExternalLink } from "lucide-react";
|
import { Bug, Lightbulb, ExternalLink, Star, GitFork, Clock, Download } from "lucide-react";
|
||||||
import ExyezedIcon from "@/assets/icons/exyezed.svg";
|
import ExyezedIcon from "@/assets/icons/exyezed.svg";
|
||||||
import SpotubeDLIcon from "@/assets/icons/spotubedl.svg";
|
import SpotubeDLIcon from "@/assets/icons/spotubedl.svg";
|
||||||
import SpotiDownloaderIcon from "@/assets/icons/spotidownloader.svg";
|
import SpotiDownloaderIcon from "@/assets/icons/spotidownloader.svg";
|
||||||
import XBatchDLIcon from "@/assets/icons/xbatchdl.svg";
|
import XBatchDLIcon from "@/assets/icons/xbatchdl.svg";
|
||||||
|
import { langColors } from "@/assets/github-lang-colors";
|
||||||
interface AboutPageProps {
|
interface AboutPageProps {
|
||||||
version: string;
|
version: string;
|
||||||
}
|
}
|
||||||
@@ -27,6 +28,7 @@ export function AboutPage({ version }: AboutPageProps) {
|
|||||||
const [featureDesc, setFeatureDesc] = useState("");
|
const [featureDesc, setFeatureDesc] = useState("");
|
||||||
const [useCase, setUseCase] = useState("");
|
const [useCase, setUseCase] = useState("");
|
||||||
const [featureContext, setFeatureContext] = useState("");
|
const [featureContext, setFeatureContext] = useState("");
|
||||||
|
const [repoStats, setRepoStats] = useState<Record<string, any>>({});
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchOS = async () => {
|
const fetchOS = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -66,6 +68,80 @@ export function AboutPage({ version }: AboutPageProps) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
fetchLocation();
|
fetchLocation();
|
||||||
|
const fetchRepoStats = async () => {
|
||||||
|
const CACHE_KEY = 'github_repo_stats';
|
||||||
|
const CACHE_DURATION = 1000 * 60 * 60;
|
||||||
|
const cached = localStorage.getItem(CACHE_KEY);
|
||||||
|
if (cached) {
|
||||||
|
try {
|
||||||
|
const { data, timestamp } = JSON.parse(cached);
|
||||||
|
if (Date.now() - timestamp < CACHE_DURATION) {
|
||||||
|
setRepoStats(data);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
console.error('Failed to parse cache:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const repos = [
|
||||||
|
{ name: 'SpotiDownloader', owner: 'afkarxyz' },
|
||||||
|
{ name: 'Twitter-X-Media-Batch-Downloader', owner: 'afkarxyz' }
|
||||||
|
];
|
||||||
|
const stats: Record<string, any> = {};
|
||||||
|
for (const repo of repos) {
|
||||||
|
try {
|
||||||
|
const [repoRes, releasesRes, langsRes] = await Promise.all([
|
||||||
|
fetch(`https://api.github.com/repos/${repo.owner}/${repo.name}`),
|
||||||
|
fetch(`https://api.github.com/repos/${repo.owner}/${repo.name}/releases`),
|
||||||
|
fetch(`https://api.github.com/repos/${repo.owner}/${repo.name}/languages`)
|
||||||
|
]);
|
||||||
|
if (repoRes.status === 403) {
|
||||||
|
if (cached) {
|
||||||
|
const { data } = JSON.parse(cached);
|
||||||
|
setRepoStats(data);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (repoRes.ok && releasesRes.ok && langsRes.ok) {
|
||||||
|
const repoData = await repoRes.json();
|
||||||
|
const releases = await releasesRes.json();
|
||||||
|
const languages = await langsRes.json();
|
||||||
|
let totalDownloads = 0;
|
||||||
|
let latestDownloads = 0;
|
||||||
|
if (releases.length > 0) {
|
||||||
|
latestDownloads = releases[0].assets?.reduce((sum: number, asset: any) => sum + (asset.download_count || 0), 0) || 0;
|
||||||
|
totalDownloads = releases.reduce((sum: number, release: any) => {
|
||||||
|
return sum + (release.assets?.reduce((s: number, a: any) => s + (a.download_count || 0), 0) || 0);
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
const topLangs = Object.entries(languages)
|
||||||
|
.sort(([, a]: any, [, b]: any) => b - a)
|
||||||
|
.slice(0, 4)
|
||||||
|
.map(([lang]) => lang);
|
||||||
|
stats[repo.name] = {
|
||||||
|
stars: repoData.stargazers_count,
|
||||||
|
forks: repoData.forks_count,
|
||||||
|
createdAt: repoData.created_at,
|
||||||
|
totalDownloads,
|
||||||
|
latestDownloads,
|
||||||
|
languages: topLangs
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
console.error(`Failed to fetch stats for ${repo.name}:`, err);
|
||||||
|
if (cached) {
|
||||||
|
const { data } = JSON.parse(cached);
|
||||||
|
setRepoStats(data);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setRepoStats(stats);
|
||||||
|
localStorage.setItem(CACHE_KEY, JSON.stringify({ data: stats, timestamp: Date.now() }));
|
||||||
|
};
|
||||||
|
fetchRepoStats();
|
||||||
}, []);
|
}, []);
|
||||||
const faqs = [
|
const faqs = [
|
||||||
{
|
{
|
||||||
@@ -92,6 +168,34 @@ export function AboutPage({ version }: AboutPageProps) {
|
|||||||
const sanitizeForURL = (text: string): string => {
|
const sanitizeForURL = (text: string): string => {
|
||||||
return text.replace(/[()]/g, "").replace(/,/g, " -");
|
return text.replace(/[()]/g, "").replace(/,/g, " -");
|
||||||
};
|
};
|
||||||
|
const formatTimeAgo = (dateString: string): string => {
|
||||||
|
const now = new Date();
|
||||||
|
const updated = new Date(dateString);
|
||||||
|
const diffMs = now.getTime() - updated.getTime();
|
||||||
|
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||||
|
const diffMonths = Math.floor(diffDays / 30);
|
||||||
|
if (diffDays === 0)
|
||||||
|
return 'today';
|
||||||
|
if (diffDays === 1)
|
||||||
|
return '1d';
|
||||||
|
if (diffDays < 30)
|
||||||
|
return `${diffDays}d`;
|
||||||
|
if (diffMonths === 1)
|
||||||
|
return '1mo';
|
||||||
|
if (diffMonths < 12)
|
||||||
|
return `${diffMonths}mo`;
|
||||||
|
const diffYears = Math.floor(diffMonths / 12);
|
||||||
|
return `${diffYears}y`;
|
||||||
|
};
|
||||||
|
const formatNumber = (num: number): string => {
|
||||||
|
if (num >= 1000) {
|
||||||
|
return num.toLocaleString();
|
||||||
|
}
|
||||||
|
return num.toString();
|
||||||
|
};
|
||||||
|
const getLangColor = (lang: string): string => {
|
||||||
|
return langColors[lang] || '#858585';
|
||||||
|
};
|
||||||
const handleSubmit = () => {
|
const handleSubmit = () => {
|
||||||
let title = "";
|
let title = "";
|
||||||
let body = "";
|
let body = "";
|
||||||
@@ -256,12 +360,40 @@ ${location || "Unknown"}
|
|||||||
<CardTitle className="flex items-center gap-2"><img src={SpotiDownloaderIcon} className="h-5 w-5" alt="SpotiDownloader" /> SpotiDownloader</CardTitle>
|
<CardTitle className="flex items-center gap-2"><img src={SpotiDownloaderIcon} className="h-5 w-5" alt="SpotiDownloader" /> SpotiDownloader</CardTitle>
|
||||||
<CardDescription>Get Spotify tracks in MP3 and FLAC via the spotidownloader.com API.</CardDescription>
|
<CardDescription>Get Spotify tracks in MP3 and FLAC via the spotidownloader.com API.</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
{repoStats['SpotiDownloader'] && (<CardContent className="space-y-3">
|
||||||
|
<div className="flex flex-wrap gap-2 text-xs">
|
||||||
|
{repoStats['SpotiDownloader'].languages?.map((lang: string) => (<span key={lang} className="px-2 py-0.5 rounded-full font-medium" style={{ backgroundColor: getLangColor(lang) + '20', color: getLangColor(lang) }}>{lang}</span>))}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||||
|
<span className="flex items-center gap-1"><Star className="h-3.5 w-3.5 fill-amber-500 text-amber-500" /> {formatNumber(repoStats['SpotiDownloader'].stars)}</span>
|
||||||
|
<span className="flex items-center gap-1"><GitFork className="h-3.5 w-3.5" /> {repoStats['SpotiDownloader'].forks}</span>
|
||||||
|
<span className="flex items-center gap-1"><Clock className="h-3.5 w-3.5" /> {formatTimeAgo(repoStats['SpotiDownloader'].createdAt)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||||
|
<span className="flex items-center gap-1"><Download className="h-3.5 w-3.5" /> TOTAL: {formatNumber(repoStats['SpotiDownloader'].totalDownloads)}</span>
|
||||||
|
<span className="flex items-center gap-1 text-green-600 dark:text-green-400"><Download className="h-3.5 w-3.5" /> LATEST: {formatNumber(repoStats['SpotiDownloader'].latestDownloads)}</span>
|
||||||
|
</div>
|
||||||
|
</CardContent>)}
|
||||||
</Card>
|
</Card>
|
||||||
<Card className="hover:bg-muted/50 hover:border-primary/50 transition-colors cursor-pointer" onClick={() => openExternal("https://github.com/afkarxyz/Twitter-X-Media-Batch-Downloader")}>
|
<Card className="hover:bg-muted/50 hover:border-primary/50 transition-colors cursor-pointer" onClick={() => openExternal("https://github.com/afkarxyz/Twitter-X-Media-Batch-Downloader")}>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2"><img src={XBatchDLIcon} className="h-5 w-5" alt="Twitter/X Media Batch Downloader" /> Twitter/X Batch Downloader</CardTitle>
|
<CardTitle className="flex items-center gap-2"><img src={XBatchDLIcon} className="h-5 w-5" alt="Twitter/X Media Batch Downloader" /> Twitter/X Media Batch Downloader</CardTitle>
|
||||||
<CardDescription>A GUI tool to download original-quality images and videos from Twitter/X accounts, powered by gallery-dl by @mikf</CardDescription>
|
<CardDescription>A GUI tool to download original-quality images and videos from Twitter/X accounts, powered by gallery-dl by @mikf</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
{repoStats['Twitter-X-Media-Batch-Downloader'] && (<CardContent className="space-y-3">
|
||||||
|
<div className="flex flex-wrap gap-2 text-xs">
|
||||||
|
{repoStats['Twitter-X-Media-Batch-Downloader'].languages?.map((lang: string) => (<span key={lang} className="px-2 py-0.5 rounded-full font-medium" style={{ backgroundColor: getLangColor(lang) + '20', color: getLangColor(lang) }}>{lang}</span>))}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||||
|
<span className="flex items-center gap-1"><Star className="h-3.5 w-3.5 fill-amber-500 text-amber-500" /> {formatNumber(repoStats['Twitter-X-Media-Batch-Downloader'].stars)}</span>
|
||||||
|
<span className="flex items-center gap-1"><GitFork className="h-3.5 w-3.5" /> {repoStats['Twitter-X-Media-Batch-Downloader'].forks}</span>
|
||||||
|
<span className="flex items-center gap-1"><Clock className="h-3.5 w-3.5" /> {formatTimeAgo(repoStats['Twitter-X-Media-Batch-Downloader'].createdAt)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||||
|
<span className="flex items-center gap-1"><Download className="h-3.5 w-3.5" /> TOTAL: {formatNumber(repoStats['Twitter-X-Media-Batch-Downloader'].totalDownloads)}</span>
|
||||||
|
<span className="flex items-center gap-1 text-green-600 dark:text-green-400"><Download className="h-3.5 w-3.5" /> LATEST: {formatNumber(repoStats['Twitter-X-Media-Batch-Downloader'].latestDownloads)}</span>
|
||||||
|
</div>
|
||||||
|
</CardContent>)}
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|||||||
@@ -131,151 +131,155 @@ export function HistoryPage() {
|
|||||||
setShowClearConfirm(false);
|
setShowClearConfirm(false);
|
||||||
};
|
};
|
||||||
return (<div className="space-y-6">
|
return (<div className="space-y-6">
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<h2 className="text-2xl font-bold tracking-tight">Download History</h2>
|
|
||||||
{history.length > 0 && (<Badge variant="secondary" className="font-mono">
|
|
||||||
{history.length.toLocaleString('en-US')}
|
|
||||||
</Badge>)}
|
|
||||||
</div>
|
|
||||||
<Button variant="outline" size="sm" onClick={() => setShowClearConfirm(true)} disabled={history.length === 0} className="cursor-pointer gap-2">
|
|
||||||
<Trash2 className="h-4 w-4"/> Clear
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="relative flex-1">
|
<h2 className="text-2xl font-bold tracking-tight">Download History</h2>
|
||||||
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground"/>
|
{history.length > 0 && (<Badge variant="secondary" className="font-mono">
|
||||||
<Input placeholder="Search history..." value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} className="pl-8 h-9"/>
|
{history.length.toLocaleString('en-US')}
|
||||||
</div>
|
</Badge>)}
|
||||||
<Select value={sortBy} onValueChange={setSortBy}>
|
|
||||||
<SelectTrigger className="w-[180px] h-9">
|
|
||||||
<ArrowUpDown className="mr-2 h-4 w-4"/>
|
|
||||||
<SelectValue placeholder="Sort by"/>
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="default">Default</SelectItem>
|
|
||||||
<SelectItem value="date_desc">Date (Newest)</SelectItem>
|
|
||||||
<SelectItem value="date_asc">Date (Oldest)</SelectItem>
|
|
||||||
<SelectItem value="title_asc">Title (A-Z)</SelectItem>
|
|
||||||
<SelectItem value="title_desc">Title (Z-A)</SelectItem>
|
|
||||||
<SelectItem value="artist_asc">Artist (A-Z)</SelectItem>
|
|
||||||
<SelectItem value="artist_desc">Artist (Z-A)</SelectItem>
|
|
||||||
<SelectItem value="duration_asc">Duration (Short)</SelectItem>
|
|
||||||
<SelectItem value="duration_desc">Duration (Long)</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
</div>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => setShowClearConfirm(true)} disabled={history.length === 0} className="cursor-pointer gap-2">
|
||||||
|
<Trash2 className="h-4 w-4" /> Clear
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-md border overflow-hidden">
|
<div className="flex items-center gap-2">
|
||||||
{paginatedHistory.length === 0 ? (<div className="flex flex-col items-center justify-center p-16 text-center text-muted-foreground gap-3">
|
<div className="relative flex-1">
|
||||||
<div className="rounded-full bg-muted/50 p-4 ring-8 ring-muted/20">
|
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||||
<History className="h-10 w-10 opacity-40"/>
|
<Input placeholder="Search history..." value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} className="pl-8 h-9" />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<Select value={sortBy} onValueChange={setSortBy}>
|
||||||
<p className="font-medium text-foreground/80">No download history</p>
|
<SelectTrigger className="w-[180px] h-9">
|
||||||
<p className="text-sm">Your downloaded tracks will appear here.</p>
|
<ArrowUpDown className="mr-2 h-4 w-4" />
|
||||||
</div>
|
<SelectValue placeholder="Sort by" />
|
||||||
</div>) : (<table className="w-full table-fixed">
|
</SelectTrigger>
|
||||||
<thead>
|
<SelectContent>
|
||||||
<tr className="border-b bg-muted/50">
|
<SelectItem value="default">Default</SelectItem>
|
||||||
<th className="h-10 px-4 text-left align-middle font-medium text-muted-foreground w-12 text-xs uppercase">#</th>
|
<SelectItem value="date_desc">Date (Newest)</SelectItem>
|
||||||
<th className="h-10 px-4 text-left align-middle font-medium text-muted-foreground text-xs uppercase">Title</th>
|
<SelectItem value="date_asc">Date (Oldest)</SelectItem>
|
||||||
<th className="h-10 px-4 text-left align-middle font-medium text-muted-foreground hidden md:table-cell text-xs uppercase w-1/4">Album</th>
|
<SelectItem value="title_asc">Title (A-Z)</SelectItem>
|
||||||
<th className="h-10 px-4 text-left align-middle font-medium text-muted-foreground hidden lg:table-cell w-32 text-xs uppercase">Format</th>
|
<SelectItem value="title_desc">Title (Z-A)</SelectItem>
|
||||||
<th className="h-10 px-4 text-left align-middle font-medium text-muted-foreground hidden xl:table-cell w-16 text-xs uppercase text-nowrap">Dur</th>
|
<SelectItem value="artist_asc">Artist (A-Z)</SelectItem>
|
||||||
<th className="h-10 px-4 text-left align-middle font-medium text-muted-foreground hidden md:table-cell w-40 text-xs uppercase text-nowrap">Downloaded At</th>
|
<SelectItem value="artist_desc">Artist (Z-A)</SelectItem>
|
||||||
<th className="h-10 px-4 text-center align-middle font-medium text-muted-foreground w-16 text-xs uppercase text-nowrap">Link</th>
|
<SelectItem value="duration_asc">Duration (Short)</SelectItem>
|
||||||
</tr>
|
<SelectItem value="duration_desc">Duration (Long)</SelectItem>
|
||||||
</thead>
|
</SelectContent>
|
||||||
<tbody>
|
</Select>
|
||||||
{paginatedHistory.map((item, index) => (<tr key={item.id} className="border-b transition-colors hover:bg-muted/50">
|
|
||||||
<td className="p-3 align-middle text-sm text-muted-foreground text-left font-mono">
|
|
||||||
{startIndex + index + 1}
|
|
||||||
</td>
|
|
||||||
<td className="p-3 align-middle min-w-0">
|
|
||||||
<div className="flex items-center gap-3 min-w-0">
|
|
||||||
<img src={item.cover_url || "https://placehold.co/300?text=No+Cover"} alt={item.album} className="h-10 w-10 rounded shrink-0 bg-secondary object-cover" onError={(e) => { (e.target as HTMLImageElement).src = "https://placehold.co/300?text=No+Cover"; }}/>
|
|
||||||
<div className="flex flex-col min-w-0 flex-1">
|
|
||||||
<span className="font-medium text-sm truncate">{item.title}</span>
|
|
||||||
<span className="text-xs text-muted-foreground truncate">{item.artists}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="p-3 align-middle text-sm text-muted-foreground hidden md:table-cell">
|
|
||||||
<div className="truncate">{item.album}</div>
|
|
||||||
</td>
|
|
||||||
<td className="p-3 align-middle text-left hidden lg:table-cell">
|
|
||||||
<div className="flex flex-col items-start gap-1">
|
|
||||||
<span className="text-[10px] font-bold text-foreground">{item.format}</span>
|
|
||||||
{item.quality && <span className="text-[9px] text-muted-foreground leading-none whitespace-nowrap">{item.quality}</span>}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="p-3 align-middle text-sm text-muted-foreground text-left hidden xl:table-cell font-mono">
|
|
||||||
{item.duration_str}
|
|
||||||
</td>
|
|
||||||
<td className="p-3 align-middle text-xs text-muted-foreground hidden md:table-cell whitespace-nowrap">
|
|
||||||
{formatDate(item.timestamp)}
|
|
||||||
</td>
|
|
||||||
<td className="p-3 align-middle text-center">
|
|
||||||
<Button variant="ghost" size="icon" className="h-8 w-8 cursor-pointer" onClick={() => openExternal(`https://open.spotify.com/track/${item.spotify_id}`)}>
|
|
||||||
<ExternalLink className="h-4 w-4"/>
|
|
||||||
</Button>
|
|
||||||
</td>
|
|
||||||
</tr>))}
|
|
||||||
</tbody>
|
|
||||||
</table>)}
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{totalPages > 1 && (<Pagination>
|
<div className="rounded-md border overflow-hidden">
|
||||||
<PaginationContent>
|
{paginatedHistory.length === 0 ? (<div className="flex flex-col items-center justify-center p-16 text-center text-muted-foreground gap-3">
|
||||||
<PaginationItem>
|
<div className="rounded-full bg-muted/50 p-4 ring-8 ring-muted/20">
|
||||||
<PaginationPrevious href="#" onClick={(e) => {
|
<History className="h-10 w-10 opacity-40" />
|
||||||
e.preventDefault();
|
</div>
|
||||||
if (currentPage > 1)
|
<div className="space-y-1">
|
||||||
setCurrentPage(currentPage - 1);
|
<p className="font-medium text-foreground/80">No download history</p>
|
||||||
}} className={currentPage === 1 ? "pointer-events-none opacity-50" : "cursor-pointer"}/>
|
<p className="text-sm">Your downloaded tracks will appear here.</p>
|
||||||
</PaginationItem>
|
</div>
|
||||||
|
</div>) : (<table className="w-full table-fixed">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b bg-muted/50">
|
||||||
|
<th className="h-10 px-4 text-left align-middle font-medium text-muted-foreground w-12 text-xs uppercase">#</th>
|
||||||
|
<th className="h-10 px-4 text-left align-middle font-medium text-muted-foreground text-xs uppercase">Title</th>
|
||||||
|
<th className="h-10 px-4 text-left align-middle font-medium text-muted-foreground hidden md:table-cell text-xs uppercase w-1/4">Album</th>
|
||||||
|
<th className="h-10 px-4 text-left align-middle font-medium text-muted-foreground hidden lg:table-cell w-32 text-xs uppercase">Format</th>
|
||||||
|
<th className="h-10 px-4 text-left align-middle font-medium text-muted-foreground hidden xl:table-cell w-16 text-xs uppercase text-nowrap">Dur</th>
|
||||||
|
<th className="h-10 px-4 text-left align-middle font-medium text-muted-foreground hidden md:table-cell w-40 text-xs uppercase text-nowrap">Downloaded At</th>
|
||||||
|
<th className="h-10 px-4 text-center align-middle font-medium text-muted-foreground w-16 text-xs uppercase text-nowrap">Link</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{paginatedHistory.map((item, index) => (<tr key={item.id} className="border-b transition-colors hover:bg-muted/50">
|
||||||
|
<td className="p-3 align-middle text-sm text-muted-foreground text-left font-mono">
|
||||||
|
{startIndex + index + 1}
|
||||||
|
</td>
|
||||||
|
<td className="p-3 align-middle min-w-0">
|
||||||
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
|
<img src={item.cover_url || "https://placehold.co/300?text=No+Cover"} alt={item.album} className="h-10 w-10 rounded shrink-0 bg-secondary object-cover" onError={(e) => { (e.target as HTMLImageElement).src = "https://placehold.co/300?text=No+Cover"; }} />
|
||||||
|
<div className="flex flex-col min-w-0 flex-1">
|
||||||
|
<span className="font-medium text-sm truncate">{item.title}</span>
|
||||||
|
<span className="text-xs text-muted-foreground truncate">{item.artists}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="p-3 align-middle text-sm text-muted-foreground hidden md:table-cell">
|
||||||
|
<div className="truncate">{item.album}</div>
|
||||||
|
</td>
|
||||||
|
<td className="p-3 align-middle text-left hidden lg:table-cell">
|
||||||
|
<div className="flex flex-col items-start gap-1">
|
||||||
|
<span className="text-xs font-bold text-foreground">
|
||||||
|
{['HI_RES_LOSSLESS', 'LOSSLESS'].includes(item.format) ? 'FLAC' : item.format}
|
||||||
|
</span>
|
||||||
|
{item.quality && <span className="text-[11px] text-muted-foreground leading-none whitespace-nowrap">{item.quality}</span>}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="p-3 align-middle text-sm text-muted-foreground text-left hidden xl:table-cell font-mono">
|
||||||
|
{item.duration_str}
|
||||||
|
</td>
|
||||||
|
<td className="p-3 align-middle text-xs text-muted-foreground hidden md:table-cell whitespace-nowrap">
|
||||||
|
{formatDate(item.timestamp)}
|
||||||
|
</td>
|
||||||
|
<td className="p-3 align-middle text-center">
|
||||||
|
<Button variant="ghost" size="icon" className="h-8 w-8 cursor-pointer" onClick={() => openExternal(`https://open.spotify.com/track/${item.spotify_id}`)}>
|
||||||
|
<ExternalLink className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</td>
|
||||||
|
</tr>))}
|
||||||
|
</tbody>
|
||||||
|
</table>)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{getPaginationPages(currentPage, totalPages).map((page, index) => (page === 'ellipsis' ? (<PaginationItem key={`ellipsis-${index}`}>
|
{
|
||||||
<PaginationEllipsis />
|
totalPages > 1 && (<Pagination>
|
||||||
</PaginationItem>) : (<PaginationItem key={page}>
|
<PaginationContent>
|
||||||
<PaginationLink href="#" onClick={(e) => {
|
<PaginationItem>
|
||||||
e.preventDefault();
|
<PaginationPrevious href="#" onClick={(e) => {
|
||||||
setCurrentPage(page);
|
e.preventDefault();
|
||||||
}} isActive={currentPage === page} className="cursor-pointer">
|
if (currentPage > 1)
|
||||||
{page}
|
setCurrentPage(currentPage - 1);
|
||||||
</PaginationLink>
|
}} className={currentPage === 1 ? "pointer-events-none opacity-50" : "cursor-pointer"} />
|
||||||
</PaginationItem>)))}
|
</PaginationItem>
|
||||||
|
|
||||||
<PaginationItem>
|
{getPaginationPages(currentPage, totalPages).map((page, index) => (page === 'ellipsis' ? (<PaginationItem key={`ellipsis-${index}`}>
|
||||||
<PaginationNext href="#" onClick={(e) => {
|
<PaginationEllipsis />
|
||||||
e.preventDefault();
|
</PaginationItem>) : (<PaginationItem key={page}>
|
||||||
if (currentPage < totalPages)
|
<PaginationLink href="#" onClick={(e) => {
|
||||||
setCurrentPage(currentPage + 1);
|
e.preventDefault();
|
||||||
}} className={currentPage === totalPages ? "pointer-events-none opacity-50" : "cursor-pointer"}/>
|
setCurrentPage(page);
|
||||||
</PaginationItem>
|
}} isActive={currentPage === page} className="cursor-pointer">
|
||||||
</PaginationContent>
|
{page}
|
||||||
</Pagination>)}
|
</PaginationLink>
|
||||||
|
</PaginationItem>)))}
|
||||||
|
|
||||||
<Dialog open={showClearConfirm} onOpenChange={setShowClearConfirm}>
|
<PaginationItem>
|
||||||
<DialogContent className="max-w-md [&>button]:hidden">
|
<PaginationNext href="#" onClick={(e) => {
|
||||||
<DialogHeader>
|
e.preventDefault();
|
||||||
<DialogTitle>Clear Download History?</DialogTitle>
|
if (currentPage < totalPages)
|
||||||
<DialogDescription>
|
setCurrentPage(currentPage + 1);
|
||||||
This will remove all entries from your download history. This action cannot be undone.
|
}} className={currentPage === totalPages ? "pointer-events-none opacity-50" : "cursor-pointer"} />
|
||||||
Note: The actual downloaded files will NOT be deleted.
|
</PaginationItem>
|
||||||
</DialogDescription>
|
</PaginationContent>
|
||||||
</DialogHeader>
|
</Pagination>)
|
||||||
<DialogFooter>
|
}
|
||||||
<Button variant="outline" onClick={() => setShowClearConfirm(false)} className="cursor-pointer">Cancel</Button>
|
|
||||||
<Button variant="destructive" onClick={handleClearHistory} className="cursor-pointer">
|
<Dialog open={showClearConfirm} onOpenChange={setShowClearConfirm}>
|
||||||
Clear History
|
<DialogContent className="max-w-md [&>button]:hidden">
|
||||||
</Button>
|
<DialogHeader>
|
||||||
</DialogFooter>
|
<DialogTitle>Clear Download History?</DialogTitle>
|
||||||
</DialogContent>
|
<DialogDescription>
|
||||||
</Dialog>
|
This will remove all entries from your download history. This action cannot be undone.
|
||||||
</div>);
|
Note: The actual downloaded files will NOT be deleted.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setShowClearConfirm(false)} className="cursor-pointer">Cancel</Button>
|
||||||
|
<Button variant="destructive" onClick={handleClearHistory} className="cursor-pointer">
|
||||||
|
Clear History
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div >);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -266,40 +266,40 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest }: Setting
|
|||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="tidal-qobuz">
|
<SelectItem value="tidal-qobuz">
|
||||||
<span className="flex items-center gap-1.5"><TidalIcon className="fill-white"/><ArrowRight className="h-3 w-3 text-muted-foreground"/><QobuzIcon className="fill-white"/></span>
|
<span className="flex items-center gap-1.5"><TidalIcon className="fill-current"/><ArrowRight className="h-3 w-3 text-muted-foreground"/><QobuzIcon className="fill-current"/></span>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
<SelectItem value="tidal-amazon">
|
<SelectItem value="tidal-amazon">
|
||||||
<span className="flex items-center gap-1.5"><TidalIcon className="fill-white"/><ArrowRight className="h-3 w-3 text-muted-foreground"/><AmazonIcon className="fill-white"/></span>
|
<span className="flex items-center gap-1.5"><TidalIcon className="fill-current"/><ArrowRight className="h-3 w-3 text-muted-foreground"/><AmazonIcon className="fill-current"/></span>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
<SelectItem value="qobuz-tidal">
|
<SelectItem value="qobuz-tidal">
|
||||||
<span className="flex items-center gap-1.5"><QobuzIcon className="fill-white"/><ArrowRight className="h-3 w-3 text-muted-foreground"/><TidalIcon className="fill-white"/></span>
|
<span className="flex items-center gap-1.5"><QobuzIcon className="fill-current"/><ArrowRight className="h-3 w-3 text-muted-foreground"/><TidalIcon className="fill-current"/></span>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
<SelectItem value="qobuz-amazon">
|
<SelectItem value="qobuz-amazon">
|
||||||
<span className="flex items-center gap-1.5"><QobuzIcon className="fill-white"/><ArrowRight className="h-3 w-3 text-muted-foreground"/><AmazonIcon className="fill-white"/></span>
|
<span className="flex items-center gap-1.5"><QobuzIcon className="fill-current"/><ArrowRight className="h-3 w-3 text-muted-foreground"/><AmazonIcon className="fill-current"/></span>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
<SelectItem value="amazon-tidal">
|
<SelectItem value="amazon-tidal">
|
||||||
<span className="flex items-center gap-1.5"><AmazonIcon className="fill-white"/><ArrowRight className="h-3 w-3 text-muted-foreground"/><TidalIcon className="fill-white"/></span>
|
<span className="flex items-center gap-1.5"><AmazonIcon className="fill-current"/><ArrowRight className="h-3 w-3 text-muted-foreground"/><TidalIcon className="fill-current"/></span>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
<SelectItem value="amazon-qobuz">
|
<SelectItem value="amazon-qobuz">
|
||||||
<span className="flex items-center gap-1.5"><AmazonIcon className="fill-white"/><ArrowRight className="h-3 w-3 text-muted-foreground"/><QobuzIcon className="fill-white"/></span>
|
<span className="flex items-center gap-1.5"><AmazonIcon className="fill-current"/><ArrowRight className="h-3 w-3 text-muted-foreground"/><QobuzIcon className="fill-current"/></span>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
<SelectItem value="tidal-qobuz-amazon">
|
<SelectItem value="tidal-qobuz-amazon">
|
||||||
<span className="flex items-center gap-1.5"><TidalIcon className="fill-white"/><ArrowRight className="h-3 w-3 text-muted-foreground"/><QobuzIcon className="fill-white"/><ArrowRight className="h-3 w-3 text-muted-foreground"/><AmazonIcon className="fill-white"/></span>
|
<span className="flex items-center gap-1.5"><TidalIcon className="fill-current"/><ArrowRight className="h-3 w-3 text-muted-foreground"/><QobuzIcon className="fill-current"/><ArrowRight className="h-3 w-3 text-muted-foreground"/><AmazonIcon className="fill-current"/></span>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
<SelectItem value="tidal-amazon-qobuz">
|
<SelectItem value="tidal-amazon-qobuz">
|
||||||
<span className="flex items-center gap-1.5"><TidalIcon className="fill-white"/><ArrowRight className="h-3 w-3 text-muted-foreground"/><AmazonIcon className="fill-white"/><ArrowRight className="h-3 w-3 text-muted-foreground"/><QobuzIcon className="fill-white"/></span>
|
<span className="flex items-center gap-1.5"><TidalIcon className="fill-current"/><ArrowRight className="h-3 w-3 text-muted-foreground"/><AmazonIcon className="fill-current"/><ArrowRight className="h-3 w-3 text-muted-foreground"/><QobuzIcon className="fill-current"/></span>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
<SelectItem value="qobuz-tidal-amazon">
|
<SelectItem value="qobuz-tidal-amazon">
|
||||||
<span className="flex items-center gap-1.5"><QobuzIcon className="fill-white"/><ArrowRight className="h-3 w-3 text-muted-foreground"/><TidalIcon className="fill-white"/><ArrowRight className="h-3 w-3 text-muted-foreground"/><AmazonIcon className="fill-white"/></span>
|
<span className="flex items-center gap-1.5"><QobuzIcon className="fill-current"/><ArrowRight className="h-3 w-3 text-muted-foreground"/><TidalIcon className="fill-current"/><ArrowRight className="h-3 w-3 text-muted-foreground"/><AmazonIcon className="fill-current"/></span>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
<SelectItem value="qobuz-amazon-tidal">
|
<SelectItem value="qobuz-amazon-tidal">
|
||||||
<span className="flex items-center gap-1.5"><QobuzIcon className="fill-white"/><ArrowRight className="h-3 w-3 text-muted-foreground"/><AmazonIcon className="fill-white"/><ArrowRight className="h-3 w-3 text-muted-foreground"/><TidalIcon className="fill-white"/></span>
|
<span className="flex items-center gap-1.5"><QobuzIcon className="fill-current"/><ArrowRight className="h-3 w-3 text-muted-foreground"/><AmazonIcon className="fill-current"/><ArrowRight className="h-3 w-3 text-muted-foreground"/><TidalIcon className="fill-current"/></span>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
<SelectItem value="amazon-tidal-qobuz">
|
<SelectItem value="amazon-tidal-qobuz">
|
||||||
<span className="flex items-center gap-1.5"><AmazonIcon className="fill-white"/><ArrowRight className="h-3 w-3 text-muted-foreground"/><TidalIcon className="fill-white"/><ArrowRight className="h-3 w-3 text-muted-foreground"/><QobuzIcon className="fill-white"/></span>
|
<span className="flex items-center gap-1.5"><AmazonIcon className="fill-current"/><ArrowRight className="h-3 w-3 text-muted-foreground"/><TidalIcon className="fill-current"/><ArrowRight className="h-3 w-3 text-muted-foreground"/><QobuzIcon className="fill-current"/></span>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
<SelectItem value="amazon-qobuz-tidal">
|
<SelectItem value="amazon-qobuz-tidal">
|
||||||
<span className="flex items-center gap-1.5"><AmazonIcon className="fill-white"/><ArrowRight className="h-3 w-3 text-muted-foreground"/><QobuzIcon className="fill-white"/><ArrowRight className="h-3 w-3 text-muted-foreground"/><TidalIcon className="fill-white"/></span>
|
<span className="flex items-center gap-1.5"><AmazonIcon className="fill-current"/><ArrowRight className="h-3 w-3 text-muted-foreground"/><QobuzIcon className="fill-current"/><ArrowRight className="h-3 w-3 text-muted-foreground"/><TidalIcon className="fill-current"/></span>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|||||||
Reference in New Issue
Block a user