Compare commits

...

3 Commits

Author SHA1 Message Date
Kingkor Roy Tirtho
f228937e3e website: hide older releases before 4.0.2 2025-08-04 13:06:44 +06:00
Kingkor Roy Tirtho
ee7d0cfeb5 website: markdown mdx support and wrap up other pages 2025-08-04 13:04:22 +06:00
Kingkor Roy Tirtho
7a630507fb website: add downloads pages 2025-08-04 12:45:28 +06:00
17 changed files with 1522 additions and 7 deletions

View File

@ -5,11 +5,13 @@ import tailwindcss from '@tailwindcss/vite';
import react from '@astrojs/react';
import mdx from '@astrojs/mdx';
// https://astro.build/config
export default defineConfig({
vite: {
plugins: [tailwindcss()]
},
integrations: [react()]
integrations: [react(), mdx()]
});

View File

@ -9,19 +9,27 @@
"astro": "astro"
},
"dependencies": {
"@astrojs/mdx": "^4.3.3",
"@astrojs/react": "^4.3.0",
"@octokit/rest": "^22.0.0",
"@skeletonlabs/skeleton-react": "^1.2.4",
"@tailwindcss/vite": "^4.1.11",
"@types/react": "^19.1.9",
"@types/react-dom": "^19.1.7",
"astro": "^5.12.8",
"date-fns": "^4.1.0",
"markdown-it": "^14.1.0",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-icons": "^5.5.0",
"sanitize-html": "^2.17.0",
"tailwindcss": "^4.1.11",
"usehooks-ts": "^3.1.1"
},
"devDependencies": {
"@skeletonlabs/skeleton": "^3.1.7"
"@skeletonlabs/skeleton": "^3.1.7",
"@tailwindcss/typography": "^0.5.16",
"@types/markdown-it": "^14.1.2",
"@types/sanitize-html": "^2.16.0"
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,3 @@
<div class="prose lg:prose-lg dark:prose-invert max-w-5xl mx-auto">
<slot />
</div>

View File

@ -1,4 +1,5 @@
---
import { FaGithub } from "react-icons/fa6";
import "../styles/global.css";
import TopBar from "~/components/navigation/TopBar.astro";
---
@ -11,10 +12,52 @@ import TopBar from "~/components/navigation/TopBar.astro";
<link rel="icon" href="/favicon.ico" type="image/x-icon" />
<meta name="generator" content={Astro.generator} />
<title>Spotube</title>
<meta
name="description"
content="An Open Source Music Client for every platform"
/>
<meta
name="keywords"
content="music, client, open source, music, streaming"
/>
<meta name="author" content="KRTirtho" />
<meta name="robots" content="index, follow" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#1DB954" />
</head>
<body>
<TopBar />
<slot />
<main class="p-2 md:p-4 min-h-[90vh]">
<TopBar />
<slot />
</main>
<footer class="w-full bg-tertiary-100-900 p-4 flex justify-between">
<div>
<h3 class="h3">Spotube</h3>
<p>
Copyright © {new Date().getFullYear()} Spotube
</p>
</div>
<ul>
<li>
<a href="https://github.com/KRTirtho/spotube">
<FaGithub className="inline mr-1" />
Github
</a>
</li>
<li>
<a href="https://opencollective.org/spotube">
<img
src="https://avatars0.githubusercontent.com/u/13403593?v=4"
alt="OpenCollective"
height="20"
width="20"
class="inline mr-1"
/>
OpenCollective
</a>
</li>
</ul>
</footer>
</body>
</html>

View File

@ -0,0 +1,33 @@
---
import type { IconType } from "react-icons";
interface Props {
links: Record<string, [string, IconType[], string]>;
}
const { links } = Astro.props;
---
<div class="grid sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{
Object.entries(links).map((link) => {
return (
<a
href={link[1][0]}
class="flex flex-col btn preset-tonal-secondary rounded-xl p-0 overflow-hidden"
>
<div class="relative bg-primary-500 p-4 flex gap-4 justify-center rounded-t-xl w-full">
{link[1][1].map((icon) => {
const Icon = icon;
return <Icon />;
})}
<p class="chip preset-tonal-warning text-warning-400 absolute right-2 uppercase">
{link[1][2]}
</p>
</div>
<p class="p-4">{link[0]}</p>
</a>
);
})
}
</div>

View File

@ -0,0 +1,39 @@
import type { RestEndpointMethodTypes } from "@octokit/rest";
import { LuBook, LuChevronDown, LuChevronUp } from "react-icons/lu";
import markdownIt from "markdown-it";
import sanitizeHtml from "sanitize-html";
interface Props {
release: RestEndpointMethodTypes["repos"]["getReleaseByTag"]["response"]["data"];
}
export default function ReleaseBody({ release }: Props) {
const summary = "Release Notes & Changelogs";
const body = release.body ?? "No release notes available.";
const md = markdownIt({
html: true,
linkify: true,
typographer: true,
});
const sanitizedBody = sanitizeHtml(md.render(body));
return (<details className="rounded-md p-4 my-4 preset-tonal-primary group">
<summary className="flex items-center cursor-pointer font-semibold text-lg gap-2">
<LuBook className="inline" />
{summary}
<span className="ml-auto flex items-center">
<span className="block group-open:hidden">
<LuChevronDown />
</span>
<span className="hidden group-open:block">
<LuChevronUp />
</span>
</span>
</summary>
<article
className="prose lg:prose-xl dark:prose-invert"
dangerouslySetInnerHTML={{ __html: sanitizedBody }}
/>
</details>)
}

View File

@ -0,0 +1,183 @@
import { formatDistanceToNow, formatRelative } from "date-fns";
import ReleaseBody from "~/modules/downloads/older/release-body";
import RootLayout from "~/layouts/RootLayout.astro";
import { Octokit, type RestEndpointMethodTypes } from "@octokit/rest";
import {
FaAndroid,
FaApple,
FaGit,
FaGooglePlay,
FaLinux,
FaWindows,
} from "react-icons/fa6";
import type { IconType } from "react-icons";
import { useEffect, useState } from "react";
function getIcon(assetUrl: string) {
assetUrl = assetUrl.toLowerCase();
if (assetUrl.includes("linux")) return FaLinux;
if (assetUrl.includes("windows")) return FaWindows;
if (assetUrl.includes("mac")) return FaApple;
if (assetUrl.includes("android")) return FaAndroid;
if (assetUrl.includes("playstore")) return FaGooglePlay;
if (assetUrl.includes("ios")) return FaApple;
return FaGit;
}
function formatName(assetName: string) {
// format the assetName to be
// {OS} ({package extension})
const lowerCasedAssetName = assetName.toLowerCase();
const extension = assetName.split(".").at(-1);
if (lowerCasedAssetName.includes("linux")) {
if (lowerCasedAssetName.includes("aarch64")) {
return [`Linux`, extension, `ARM64`]
}
return [`Linux`, extension, `x64`]
};
if (lowerCasedAssetName.includes("windows")) return [`Windows`, extension];
if (lowerCasedAssetName.includes("mac")) return [`macOS`, extension];
if (
lowerCasedAssetName.includes("android") ||
lowerCasedAssetName.includes("playstore")
)
return [`Android`, extension];
if (lowerCasedAssetName.includes("ios")) return [`iOS`, extension];
return [assetName.replace(`.${extension}`, ""), extension];
}
type OctokitAsset =
RestEndpointMethodTypes["repos"]["listReleases"]["response"]["data"][0]["assets"][0];
function groupByOS(downloads: OctokitAsset[]) {
return downloads.reduce(
(acc, val) => {
const lowName = val.name.toLowerCase();
if (lowName.includes("android") || lowName.includes("playstore"))
acc["android"] = [...(acc.android ?? []), val];
if (lowName.includes("linux"))
acc["linux"] = [...(acc["linux"] ?? []), val];
if (lowName.includes("windows"))
acc["windows"] = [...(acc["windows"] ?? []), val];
if (lowName.includes("ios")) acc["ios"] = [...(acc["ios"] ?? []), val];
if (lowName.includes("mac")) acc["mac"] = [...(acc["mac"] ?? []), val];
return acc;
},
{} as Record<
"android" | "ios" | "mac" | "linux" | "windows",
OctokitAsset[]
>
);
}
const icons: Record<string, [IconType, string]> = {
android: [FaAndroid, "#3DDC84"],
mac: [FaApple, ""],
ios: [FaApple, ""],
linux: [FaLinux, "#000000"],
windows: [FaWindows, "#0078D7"],
};
export default function ReleasesSection() {
const github = new Octokit();
const [releases, setReleases] = useState<RestEndpointMethodTypes["repos"]["listReleases"]["response"]["data"]>([]);
useEffect(() => {
github.repos.listReleases({
owner: "KRTirtho",
repo: "spotube",
}).then((res) => {
setReleases(
res.data.filter((release) => {
// Ignore all releases that were published before March 18 2025
return new Date(release.published_at ?? new Date()) >= new Date("2025-03-18T00:00:00Z");
})
);
})
}, [])
return <>
{
releases.map((release) => {
return (
<div>
<h4
className="h4"
title={formatRelative(
release.published_at ?? new Date(),
new Date()
)}
>
{release.tag_name}
<span className="text-sm font-normal">
(
{formatDistanceToNow(release.published_at ?? new Date(), {
addSuffix: true,
})}
)
</span>
</h4>
<div className="flex flex-col gap-5">
{Object.entries(groupByOS(release.assets)).map(
([osName, assets]) => {
const Icon = icons[osName][0];
return (
<div className="flex flex-col gap-4">
<h5 className="h5 capitalize">
<Icon className="inline" color={icons[osName][1]} />
{osName}
</h5>
<div className="flex flex-wrap gap-4">
{assets.map((asset) => {
const Icon = getIcon(asset.browser_download_url);
const formattedName = formatName(asset.name);
return (
<a href={asset.browser_download_url}>
<button className="btn preset-tonal-primary rounded p-0 flex flex-col">
<span className="bg-primary-500 rounded-t p-3 w-full">
<Icon className="inline" />
</span>
<span className="p-4 space-x-1">
<span>
{formattedName[0]}
</span>
<span className="chip preset-tonal-error">
{formattedName[1]}
</span>
{
formattedName[2] ?
<span className="chip preset-tonal-error">
{formattedName[2]}
</span> : <></>
}
</span>
</button>
</a>
);
})}
</div>
</div>
);
}
)}
</div>
<ReleaseBody release={release} />
<hr />
</div>
);
})
}
</>
}

View File

@ -0,0 +1,80 @@
import { useEffect, useState } from "react";
import { Avatar } from "@skeletonlabs/skeleton-react";
interface Member {
MemberId: number;
createdAt: string;
type: string;
role: string;
isActive: boolean;
totalAmountDonated: number;
currency?: string;
lastTransactionAt: string;
lastTransactionAmount: number;
profile: string;
name: string;
company?: string;
description?: string;
image?: string;
email?: string;
twitter?: string;
github?: string;
website?: string;
tier?: string;
}
const formatter = new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
compactDisplay: 'short',
maximumFractionDigits: 0
});
export function Supporters() {
const [members, setMembers] = useState<Member[]>([]);
useEffect(() => {
// Fetch members data from an API or other source
async function fetchMembers() {
const res = await fetch('https://opencollective.com/spotube/members/all.json');
const members = (await res.json()) as Member[];
setMembers(
members
.filter((m) => m.totalAmountDonated > 0)
.sort((a, b) => b.totalAmountDonated - a.totalAmountDonated)
);
};
fetchMembers();
}, []);
return <div
className="gap-4 grid"
style={{
gridTemplateColumns: 'repeat(auto-fill, minmax(150px, 1fr))',
gridAutoRows: 'minmax(50px, auto)',
}}
>
{
members.map((member) => {
return <a
key={member.MemberId}
href={member.profile}
target="_blank"
className="flex items-center gap-2 px-2 py-1 overflow-ellipsis preset-tonal-secondary rounded-lg"
>
<Avatar src={member.image} name={member.name} classes="w-10 h-10" />
<div className="flex flex-col overflow-hidden">
<p className="truncate">{member.name}</p>
<p className="capitalize text-sm underline decoration-dotted">
{formatter.format(member.totalAmountDonated)}
({member.role.toLowerCase()})
</p>
</div>
</a>;
})
}
</div>;
}

View File

@ -2,4 +2,27 @@
import RootLayout from "~/layouts/RootLayout.astro";
---
<RootLayout />
<RootLayout>
<section class="p-4 md:p-16">
<h2 class="h2">About</h2>
<br /><br />
<h4 class="h4">Author & Developer</h4>
<br />
<a
href="https://github.com/KRTirtho"
target="_blank"
class="btn preset-tonal-primary max-w-44 flex flex-col items-center p-4 rounded-2xl"
>
<img
alt="Author of Spotube"
src="https://github.com/KRTirtho.png"
class="h-auto w-40 rounded-full"
/>
<br />
<h5>Kingkor Roy Tirtho</h5>
<p>Flutter developer</p>
</a>
</section>
</RootLayout>

View File

@ -1,5 +1,53 @@
---
import type { IconType } from "react-icons";
import { LuDownload, LuHistory, LuPackage, LuSparkles } from "react-icons/lu";
import { extendedDownloadLinks } from "~/collections/app";
import RootLayout from "~/layouts/RootLayout.astro";
import DownloadItems from "~/modules/downloads/download-item.astro";
const otherDownloads: [string, string, IconType][] = [
["/downloads/packages", "CLI Packages Managers", LuPackage],
["/downloads/older", "Older Versions", LuHistory],
["/downloads/nightly", "Nightly Builds", LuSparkles],
];
---
<RootLayout />
<RootLayout>
<section class="p-4 md:p-16 md:pb-4">
<h2 class="h2 flex items-center gap-4">
Download
<LuDownload className="inline" size={30} />
</h2>
<br /><br />
<h5 class="h5">Spotube is available for every platform</h5>
<br />
<DownloadItems links={extendedDownloadLinks} />
<br />
<br />
<!-- <Ads adSlot={ADS_SLOTS.downloadPageDisplay} adFormat="auto" /> -->
<br />
<h2 class="h2">Other Downloads</h2>
<br /><br />
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-2 max-w-3xl">
{
otherDownloads.map((download) => {
const Icon = download[2];
return (
<a href={download[0]}>
<div class="btn preset-tonal-secondary flex flex-col items-center p-4 gap-4">
<Icon />
<h5 class="h5">{download[1]}</h5>
</div>
</a>
);
})
}
</div>
<br />
<!-- <Ads adSlot={ADS_SLOTS.downloadPageDisplay} adFormat="auto" /> -->
</section>
</RootLayout>

View File

@ -0,0 +1,47 @@
---
import { LuBug, LuSparkles, LuTriangleAlert } from "react-icons/lu";
import { extendedNightlyDownloadLinks } from "~/collections/app";
import RootLayout from "~/layouts/RootLayout.astro";
import DownloadItems from "~/modules/downloads/download-item.astro";
---
<RootLayout>
<section class="p-4 md:p-16">
<h2 class="h2 flex items-center gap-4">
Nightly Downloads
<LuSparkles className="inline" size={30} />
</h2>
<br /><br />
<aside class="preset-tonal-warning rounded-xl">
<div class="h3 pl-4 pt-4">
<LuTriangleAlert className="text-warning-500" />
</div>
<div class="p-4">
<h3 class="h3">
Nightly versions may contain bugs <LuBug className="inline" />
</h3>
<p>
Although Nightly versions are packed with newest and greatest
features, it's often unstable and not tested by the maintainers and
publisher(s).
<br />
<span class="text-error-500 underline decoration-dotted">
So use it at your own risk.
</span>
<span>
Go to <a href="/downloads" class="anchor">Downloads</a> for more stable
releases.
</span>
</p>
</div>
</aside>
<br />
<p class="mb-4">Following are the new v5 Nightly versions:</p>
<DownloadItems links={extendedNightlyDownloadLinks} />
<br />
<!-- <Ads adSlot={ADS_SLOTS.downloadPageDisplay} adFormat="auto" /> -->
<br />
</section>
</RootLayout>

View File

@ -0,0 +1,12 @@
---
import RootLayout from "~/layouts/RootLayout.astro";
import ReleasesSection from "~/modules/downloads/older/releases";
---
<RootLayout>
<div class="p-4 md:p-24">
<div class="flex flex-col gap-5">
<ReleasesSection client:only />
</div>
</div>
</RootLayout>

View File

@ -0,0 +1,68 @@
import { FaLinux, FaWindows, FaApple } from 'react-icons/fa6';
import RootLayout from 'layouts/RootLayout.astro';
import MarkdownLayout from 'layouts/MarkdownLayout.astro';
<RootLayout>
<MarkdownLayout>
<div class="p-4 md:ps-24">
<h2 class="h2">Package Managers</h2>
Spotube is available in various Package Managers supported by Platform
## <FaLinux className="inline" /> Linux
### Flatpak📦
Make sure [Flatpak](https://flatpak.org) is installed in your Linux device & Run the following command in the terminal:
```bash
$ flatpak install com.github.KRTirtho.Spotube
```
### Arch User Repository (AUR)♾️
If you're an Arch Linux user, you can also install Spotube from AUR.
Make sure you have `yay`/`pamac`/`paru` installed in your system. And Run the Following command in the Terminal:
```bash
$ yay -Sy spotube-bin
```
```bash
$ pamac install spotube-bin
```
```bash
$ paru -Sy spotube-bin
```
{/* <Ads
style="display:block; text-align:center;"
adSlot={ADS_SLOTS.packagePageArticle}
adLayout="in-article"
adFormat="fluid"
fullWidthResponsive={false}
/> */}
## <FaApple className="inline" /> MacOS
### Homebrew🍻
Spotube can be installed through Homebrew. We host our own cask definition thus you'll need to add our tap first:
```bash
$ brew tap krtirtho/apps
$ brew install --cask spotube
```
{/* <Ads
style="display:block; text-align:center;"
adSlot={ADS_SLOTS.packagePageArticle}
adLayout="in-article"
adFormat="fluid"
fullWidthResponsive={false}
/> */}
## <FaWindows className="inline" color="#00A2F0" /> Windows
### Chocolatey🍫
Spotube is available in [community.chocolatey.org](https://community.chocolatey.org) repo. If you have chocolatey install in your system just run following command in an Elevated Command Prompt or PowerShell:
```powershell
$ choco install spotube
```
### WinGet💫
Spotube is also available in the Official Windows PackageManager WinGet. Make sure you have WinGet installed in your Windows machine and run following in a Terminal:
```powershell
$ winget install --id KRTirtho.Spotube
```
### Scoop🥄
Spotube is also available in [Scoop](https://scoop.sh) bucket. Make sure you have Scoop installed in your Windows machine and run following in a Terminal:
```powershell
$ scoop bucket add extras
$ scoop install spotube
```
</div>
</MarkdownLayout>
</RootLayout>

View File

@ -1,5 +1,78 @@
---
import { FaAndroid, FaApple, FaLinux, FaWindows } from "react-icons/fa6";
import RootLayout from "../layouts/RootLayout.astro";
import { LuHeart } from "react-icons/lu";
import { Supporters } from "~/modules/root/supporters";
---
<RootLayout />
<RootLayout>
<section class="ps-4 pt-16 md:ps-24 md:pt-24">
<div class="flex flex-col gap-4">
<div>
<h1 class="h1">Spotube</h1>
<br />
<h3 class="h3">
A cross-platform Extensible open-source Music Streaming platform
<div class="inline-flex gap-3 items-center">
<FaAndroid className="inline text-[#3DDC84]" />
<FaWindows className="inline text-[#00A2F0]" />
<FaLinux className="inline" />
<FaApple className="inline" />
</div>
</h3>
<p class="text-surface-500">
And it's <span class="text-error-500 underline decoration-dashed"
>not</span
>
built with Electron (web technologies)
</p>
<br />
<div class="flex items-center gap-3">
<a
href="https://news.ycombinator.com/item?id=39066136"
target="_blank"
>
<img
src="https://hackerbadge.vercel.app/api?id=39066136"
alt="HackerNews"
/>
</a>
<!-- <a href="https://flathub.org/apps/com.github.KRTirtho.Spotube" target="_blank">
<img
width="160"
alt="Download on Flathub"
src="https://flathub.org/api/badge?locale=en"
/>
</a> -->
</div>
</div>
</div>
<br />
<!-- <Ads adSlot={ADS_SLOTS.rootPageDisplay} adFormat="auto" /> -->
<br />
<div class="flex flex-col gap-4">
<h2 class="h2">
Supporters
<LuHeart className="inline-block" color="red" />
</h2>
<p class="text-surface-500">
We are grateful for the support of individuals and organizations who
have made Spotube possible.
</p>
<div class="flex justify-center">
<a href="https://opencollective.com/spotube/donate" target="_blank">
<img
src="https://opencollective.com/webpack/donate/button@2x.png?color=blue"
width="300"
alt="Open Collective"
/>
</a>
</div>
<Supporters client:only />
</div>
<br />
<!-- <Ads adSlot={ADS_SLOTS.rootPageDisplay} adFormat="auto" /> -->
</section>
</RootLayout>

View File

@ -1,4 +1,5 @@
@import "tailwindcss";
@plugin "@tailwindcss/typography";
@source '../../node_modules/@skeletonlabs/skeleton-react/dist';

View File

@ -16,5 +16,7 @@
],
},
"baseUrl": "./src",
"module": "node16",
"moduleResolution": "node16"
}
}