diff --git a/apps/web/app/[locale]/(landing)/components/header.tsx b/apps/web/app/[locale]/(landing)/components/header.tsx index 91c4a5a..9d33d74 100644 --- a/apps/web/app/[locale]/(landing)/components/header.tsx +++ b/apps/web/app/[locale]/(landing)/components/header.tsx @@ -10,7 +10,7 @@ import { Logo } from "@workspace/ui/components/landing/logo"; const menuItems = [ { name: "Docs", href: "/docs" }, { name: "Pricing", href: "/" }, - { name: "Download", href: "/" }, + { name: "Download", href: "/download" }, { name: "Showcase", href: "/" }, ]; diff --git a/apps/web/app/[locale]/(landing)/components/hero-section.tsx b/apps/web/app/[locale]/(landing)/components/hero-section.tsx index 9bde8ad..3aa2e45 100644 --- a/apps/web/app/[locale]/(landing)/components/hero-section.tsx +++ b/apps/web/app/[locale]/(landing)/components/hero-section.tsx @@ -4,40 +4,10 @@ import Image from "next/image"; import { ArrowRight, Play, Rocket } from "lucide-react"; import { fetchLatestGithubVersion } from "@workspace/core/lib/utils"; import { Button } from "@workspace/ui/components/button"; -import { - AnimatedGroup, - type AnimatedGroupProps, -} from "@workspace/ui/components/landing/animated-group"; +import { AnimatedGroup } from "@workspace/ui/components/landing/animated-group"; import { LogoCloud } from "@workspace/ui/components/landing/logo-cloud"; import { TextEffect } from "@workspace/ui/components/landing/text-effect"; - -const transitionVariants: AnimatedGroupProps["variants"] = { - container: { - visible: { - transition: { - staggerChildren: 0.1, - delayChildren: 0.3, - }, - }, - }, - item: { - hidden: { - opacity: 0, - filter: "blur(12px)", - y: 12, - }, - visible: { - opacity: 1, - filter: "blur(0px)", - y: 0, - transition: { - type: "spring", - bounce: 0.3, - duration: 1.5, - }, - }, - }, -}; +import { transitionVariants } from "@/lib/animations"; export default function HeroSection() { const [latestTag, setLatestTag] = useState(null); diff --git a/apps/web/app/[locale]/(landing)/components/platform-cards.tsx b/apps/web/app/[locale]/(landing)/components/platform-cards.tsx new file mode 100644 index 0000000..54e8d44 --- /dev/null +++ b/apps/web/app/[locale]/(landing)/components/platform-cards.tsx @@ -0,0 +1,113 @@ +import Link from "next/link"; +import { Card, CardContent, CardHeader } from "@workspace/ui/components/card"; +import { Button } from "@workspace/ui/components/button"; +import { ReactNode } from "react"; +import { + platformCards, + type PlatformCardData, +} from "../download/platform-mappings"; + +interface PlatformCardsProps { + assets: Record; +} + +function DownloadButton({ + href, + label, + ext, +}: { + href: string | undefined; + label: string; + ext: string; +}) { + if (!href) return null; + return ( + + ); +} + +const colSpanClass = { + 2: "lg:col-span-2", + 3: "lg:col-span-3", +} as const; + +function PlatformCard({ + platform, + assets, +}: { + platform: PlatformCardData; + assets: Record; +}) { + return ( + + + {platform.icon} +

{platform.name}

+
+ + {platform.downloads.length > 0 ? ( + platform.downloads.map((dl) => ( + + )) + ) : ( +

Coming soon

+ )} +
+
+ ); +} + +export default function PlatformCards({ assets }: PlatformCardsProps) { + return ( +
+
+
+

+ Available Platforms +

+

+ Download TNTStack for your platform. +

+
+
+ {platformCards.map((platform) => ( + + ))} +
+
+
+ ); +} + +const CardDecorator = ({ children }: { children: ReactNode }) => ( +
+
+ +
+ {children} +
+
+); diff --git a/apps/web/app/[locale]/(landing)/download/download-content.tsx b/apps/web/app/[locale]/(landing)/download/download-content.tsx new file mode 100644 index 0000000..324cd61 --- /dev/null +++ b/apps/web/app/[locale]/(landing)/download/download-content.tsx @@ -0,0 +1,176 @@ +"use client"; + +import React, { useState, useEffect, useCallback } from "react"; +import Link from "next/link"; +import { ArrowDown } from "lucide-react"; +import { Button } from "@workspace/ui/components/button"; +import { Logo } from "@workspace/ui/components/landing/logo"; +import { AnimatedGroup } from "@workspace/ui/components/landing/animated-group"; +import { TextEffect } from "@workspace/ui/components/landing/text-effect"; +import { transitionVariants } from "@/lib/animations"; +import { detectPlatform, type Platform } from "@/lib/detect-platform"; +import { platformConfig } from "./platform-mappings"; +import { type ReleaseData } from "@/lib/github-releases"; +import PlatformCards from "../components/platform-cards"; + +interface DownloadContentProps { + release: ReleaseData | null; +} + +export default function DownloadContent({ release }: DownloadContentProps) { + const [platform, setPlatform] = useState("unknown"); + + useEffect(() => { + setPlatform(detectPlatform()); + }, []); + + const scrollToPlatforms = useCallback((e: React.MouseEvent) => { + e.preventDefault(); + document + .getElementById("platforms") + ?.scrollIntoView({ behavior: "smooth" }); + }, []); + + const { label, icon, primaryAssetKey } = platformConfig[platform]; + + const primaryUrl = + (release?.assets && primaryAssetKey && release.assets[primaryAssetKey]) || + "#"; + + return ( +
+
+
+
+ + + + + + Download TNTStack + + + Get the latest version for your platform. One codebase for Web, + Desktop, and Mobile. + + + {release?.version && ( + +

+ Latest release: v{release.version} +

+
+ )} + + + + + +
+ + +
+ +
+
+
+
+ ); +} diff --git a/apps/web/app/[locale]/(landing)/download/page.tsx b/apps/web/app/[locale]/(landing)/download/page.tsx new file mode 100644 index 0000000..df662db --- /dev/null +++ b/apps/web/app/[locale]/(landing)/download/page.tsx @@ -0,0 +1,7 @@ +import { fetchLatestReleaseWithAssets } from "@/lib/github-releases"; +import DownloadContent from "./download-content"; + +export default async function DownloadPage() { + const release = await fetchLatestReleaseWithAssets(); + return ; +} diff --git a/apps/web/app/[locale]/(landing)/download/platform-mappings.tsx b/apps/web/app/[locale]/(landing)/download/platform-mappings.tsx new file mode 100644 index 0000000..d7cdda1 --- /dev/null +++ b/apps/web/app/[locale]/(landing)/download/platform-mappings.tsx @@ -0,0 +1,107 @@ +import React from "react"; +import { Download } from "lucide-react"; +import { Windows } from "@workspace/ui/components/svgs/windows"; +import { Linux } from "@workspace/ui/components/svgs/linux"; +import { Apple } from "@workspace/ui/components/svgs/apple"; +import { Android } from "@workspace/ui/components/svgs/android"; +import { type Platform } from "@/lib/detect-platform"; + +// Per-platform UI config for the primary download button +export const platformConfig: Record< + Platform, + { label: string; icon: React.ReactNode; primaryAssetKey: string } +> = { + windows: { + label: "Download for Windows", + icon: , + primaryAssetKey: "windows_x64_exe", + }, + macos: { + label: "Download for macOS", + icon: , + primaryAssetKey: "macos_aarch64_dmg", + }, + linux: { + label: "Download for Linux", + icon: , + primaryAssetKey: "linux_amd64_deb", + }, + android: { + label: "Download for Android", + icon: , + primaryAssetKey: "android_arm64_apk", + }, + ios: { + label: "Download for iOS", + icon: , + primaryAssetKey: "", + }, + unknown: { + label: "Download", + icon: , + primaryAssetKey: "", + }, +}; + +export interface DownloadOption { + assetKey: string; + label: string; + ext: string; +} + +export interface PlatformCardData { + name: string; + icon: React.ReactNode; + colSpan: 2 | 3; + downloads: DownloadOption[]; +} + +// Platform cards data +export const platformCards: PlatformCardData[] = [ + { + name: "Windows", + icon: , + colSpan: 2, + downloads: [ + { assetKey: "windows_x64_exe", label: "Standard Installer", ext: ".exe" }, + { assetKey: "windows_x64_msi", label: "System Installer", ext: ".msi" }, + ], + }, + { + name: "macOS", + icon: , + colSpan: 2, + downloads: [ + { assetKey: "macos_aarch64_dmg", label: "Apple Silicon", ext: ".dmg" }, + { assetKey: "macos_x64_dmg", label: "Intel Chip", ext: ".dmg" }, + ], + }, + { + name: "Linux", + icon: , + colSpan: 2, + downloads: [ + { assetKey: "linux_amd64_deb", label: "Debian/Ubuntu", ext: ".deb" }, + { + assetKey: "linux_amd64_appimage", + label: "Universal", + ext: ".AppImage", + }, + ], + }, + { + name: "Android", + icon: , + colSpan: 3, + downloads: [ + { assetKey: "android_universal_apk", label: "Universal", ext: ".apk" }, + { assetKey: "android_arm64_apk", label: "ARM64", ext: ".apk" }, + ], + }, + { + name: "iOS", + icon: , + colSpan: 3, + downloads: [], + }, +]; diff --git a/apps/web/app/[locale]/(landing)/layout.tsx b/apps/web/app/[locale]/(landing)/layout.tsx index cc71ef7..830aae0 100644 --- a/apps/web/app/[locale]/(landing)/layout.tsx +++ b/apps/web/app/[locale]/(landing)/layout.tsx @@ -1,4 +1,5 @@ import { ThemeProvider } from "@workspace/core/providers/theme-provider"; +import { HeroHeader } from "./components/header"; interface LandingLayoutProps { children: React.ReactNode; @@ -13,6 +14,7 @@ export default function LandingLayout({ children }: LandingLayoutProps) { disableTransitionOnChange enableColorScheme > + {children} ); diff --git a/apps/web/app/[locale]/(landing)/page.tsx b/apps/web/app/[locale]/(landing)/page.tsx index 9b30b07..12acd94 100644 --- a/apps/web/app/[locale]/(landing)/page.tsx +++ b/apps/web/app/[locale]/(landing)/page.tsx @@ -1,13 +1,7 @@ "use client"; -import { HeroHeader } from "./components/header"; import HeroSection from "./components/hero-section"; export default function Landing() { - return ( - <> - - - - ); + return ; } diff --git a/apps/web/content/docs/architecture/web-app.mdx b/apps/web/content/docs/architecture/web-app.mdx index 088aca3..4c0f8d8 100644 --- a/apps/web/content/docs/architecture/web-app.mdx +++ b/apps/web/content/docs/architecture/web-app.mdx @@ -45,6 +45,9 @@ A server-rendered Next.js app that hosts the landing page, web app, documentatio + + + @@ -59,7 +62,7 @@ Pages are split into three route groups under `[locale]`, each with its own layo | Group | Paths | Layout | |---|---|---| -| `(landing)` | `/` | Marketing layout (no sidebar) | +| `(landing)` | `/`, `/download` | Marketing layout (no sidebar) | | `(app)` | `/home`, `/dashboard`, `/settings` | Sidebar-based app shell | | `(docs)` | `/docs/...` | Fumadocs documentation layout | diff --git a/apps/web/lib/animations.ts b/apps/web/lib/animations.ts new file mode 100644 index 0000000..b8cf06f --- /dev/null +++ b/apps/web/lib/animations.ts @@ -0,0 +1,29 @@ +import { type AnimatedGroupProps } from "@workspace/ui/components/landing/animated-group"; + +export const transitionVariants: AnimatedGroupProps["variants"] = { + container: { + visible: { + transition: { + staggerChildren: 0.1, + delayChildren: 0.3, + }, + }, + }, + item: { + hidden: { + opacity: 0, + filter: "blur(12px)", + y: 12, + }, + visible: { + opacity: 1, + filter: "blur(0px)", + y: 0, + transition: { + type: "spring", + bounce: 0.3, + duration: 1.5, + }, + }, + }, +}; diff --git a/apps/web/lib/detect-platform.ts b/apps/web/lib/detect-platform.ts new file mode 100644 index 0000000..d6f210e --- /dev/null +++ b/apps/web/lib/detect-platform.ts @@ -0,0 +1,18 @@ +export type Platform = + | "windows" + | "macos" + | "linux" + | "android" + | "ios" + | "unknown"; + +export function detectPlatform(): Platform { + if (typeof navigator === "undefined") return "unknown"; + const ua = navigator.userAgent.toLowerCase(); + if (/android/.test(ua)) return "android"; + if (/iphone|ipad|ipod/.test(ua)) return "ios"; + if (/win/.test(ua)) return "windows"; + if (/mac/.test(ua)) return "macos"; + if (/linux/.test(ua)) return "linux"; + return "unknown"; +} diff --git a/apps/web/lib/github-releases.ts b/apps/web/lib/github-releases.ts new file mode 100644 index 0000000..ef5d649 --- /dev/null +++ b/apps/web/lib/github-releases.ts @@ -0,0 +1,82 @@ +export const GITHUB_REPO = "odest/tntstack"; + +export interface ReleaseData { + version: string; + assets: Record; +} + +// Map from asset filename pattern to a normalized key +function assetNameToKey(name: string): string | null { + const lower = name.toLowerCase(); + + // Windows + if (lower.includes("windows") && lower.endsWith(".exe")) + return "windows_x64_exe"; + if (lower.includes("windows") && lower.endsWith(".msi")) + return "windows_x64_msi"; + + // macOS + if (lower.includes("macos_aarch64") && lower.endsWith(".dmg")) + return "macos_aarch64_dmg"; + if (lower.includes("macos_x64") && lower.endsWith(".dmg")) + return "macos_x64_dmg"; + + // Linux + if (lower.includes("linux") && lower.endsWith(".appimage")) + return "linux_amd64_appimage"; + if (lower.includes("linux") && lower.endsWith(".deb")) + return "linux_amd64_deb"; + + // Android + if (lower.includes("android_universal") && lower.endsWith(".apk")) + return "android_universal_apk"; + if (lower.includes("android_arm64") && lower.endsWith(".apk")) + return "android_arm64_apk"; + + return null; +} + +interface GitHubRelease { + tag_name: string; + prerelease: boolean; + draft: boolean; + assets: { name: string; browser_download_url: string; size: number }[]; +} + +// Fetches the latest GitHub release that has download assets. Uses ISR with 1-hour revalidation. +export async function fetchLatestReleaseWithAssets(): Promise { + try { + const res = await fetch( + `https://api.github.com/repos/${GITHUB_REPO}/releases?per_page=20`, + { next: { revalidate: 3600 } }, + ); + if (!res.ok) return null; + + const releases: GitHubRelease[] = await res.json(); + + const release = releases.find( + (r) => + !r.prerelease && + !r.draft && + /^v\d/.test(r.tag_name) && + r.assets.length > 0, + ); + + if (!release) return null; + + const assets: Record = {}; + for (const asset of release.assets) { + const key = assetNameToKey(asset.name); + if (key) { + assets[key] = asset.browser_download_url; + } + } + + return { + version: release.tag_name.replace(/^v/, ""), + assets, + }; + } catch { + return null; + } +} diff --git a/packages/ui/src/components/svgs/android.tsx b/packages/ui/src/components/svgs/android.tsx new file mode 100644 index 0000000..69dda93 --- /dev/null +++ b/packages/ui/src/components/svgs/android.tsx @@ -0,0 +1,16 @@ +import type { SVGProps } from "react"; + +const Android = (props: SVGProps) => ( + + + + +); + +export { Android }; diff --git a/packages/ui/src/components/svgs/apple.tsx b/packages/ui/src/components/svgs/apple.tsx new file mode 100644 index 0000000..d78fe63 --- /dev/null +++ b/packages/ui/src/components/svgs/apple.tsx @@ -0,0 +1,12 @@ +import type { SVGProps } from "react"; + +const Apple = (props: SVGProps) => ( + + + +); + +export { Apple }; diff --git a/packages/ui/src/components/svgs/linux.tsx b/packages/ui/src/components/svgs/linux.tsx new file mode 100644 index 0000000..440ab5c --- /dev/null +++ b/packages/ui/src/components/svgs/linux.tsx @@ -0,0 +1,9 @@ +import type { SVGProps } from "react"; + +const Linux = (props: SVGProps) => ( + + + +); + +export { Linux }; diff --git a/packages/ui/src/components/svgs/windows.tsx b/packages/ui/src/components/svgs/windows.tsx new file mode 100644 index 0000000..162225b --- /dev/null +++ b/packages/ui/src/components/svgs/windows.tsx @@ -0,0 +1,12 @@ +import type { SVGProps } from "react"; + +const Windows = (props: SVGProps) => ( + + + +); + +export { Windows };