Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
163 changes: 163 additions & 0 deletions apps/web/app/[locale]/(landing)/components/footer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
"use client";

import Link from "next/link";
import type { ReactNode } from "react";
import { cn } from "@workspace/ui/lib/utils";
import { Logo } from "@workspace/ui/components/landing/logo";
import {
GithubIcon,
MessageSquareIcon,
BugIcon,
GitPullRequestIcon,
} from "lucide-react";
import { ModeSwitch } from "@workspace/core/components/common/mode-switch";
import { AnimatedContainer } from "@workspace/ui/components/landing/animated-container";
import { TextHoverEffect } from "@workspace/ui/components/landing/text-hover-effect";
import { BorderBeam } from "@workspace/ui/components/landing/border-beam";

type FooterLink = {
title: string;
href: string;
isExternal: boolean;
icon?: ReactNode;
};

type FooterSection = {
label: string;
links: FooterLink[];
};

const footerLinks: FooterSection[] = [
{
label: "Product",
links: [
{ title: "Pricing", href: "/", isExternal: false },
{ title: "Features", href: "/", isExternal: false },
{ title: "Download", href: "/download", isExternal: false },
{ title: "Showcase", href: "/", isExternal: false },
],
},
{
label: "Resources",
links: [
{ title: "Documentation", href: "/docs", isExternal: false },
{ title: "Quick Start", href: "/docs/quick-start", isExternal: false },
{
title: "Architecture",
href: "/docs/architecture/overview",
isExternal: false,
},
{
title: "Changelog",
href: "https://github.com/odest/tntstack/blob/master/CHANGELOG.md",
isExternal: true,
},
],
},
{
label: "Legal",
links: [
{
title: "MIT License",
href: "https://github.com/odest/tntstack/blob/master/LICENSE",
isExternal: true,
},
{ title: "Privacy Policy", href: "/", isExternal: false },
{ title: "Terms of Service", href: "/", isExternal: false },
{ title: "Security", href: "/", isExternal: false },
],
},
{
label: "Community",
links: [
{
title: "GitHub",
href: "https://github.com/odest/tntstack",
isExternal: true,
icon: <GithubIcon className="w-4 h-4" />,
},
{
title: "Contribute",
href: "https://github.com/odest/tntstack/blob/master/CONTRIBUTING.md",
isExternal: true,
icon: <GitPullRequestIcon className="w-4 h-4" />,
},
{
title: "Discussions",
href: "https://github.com/odest/tntstack/discussions",
isExternal: true,
icon: <MessageSquareIcon className="w-4 h-4" />,
},
{
title: "Report an Issue",
href: "https://github.com/odest/tntstack/issues",
isExternal: true,
icon: <BugIcon className="w-4 h-4" />,
},
],
},
];

export function Footer() {
return (
<footer
className={cn(
"relative mx-auto flex w-full max-w-6xl flex-col items-center justify-center overflow-hidden rounded-t-4xl border-t px-6 md:rounded-t-6xl md:px-8",
"dark:bg-[radial-gradient(35%_128px_at_50%_0%,--theme(--color-foreground/.1),transparent)]",
)}
>
<div className="absolute top-0 right-1/2 left-1/2 h-px w-1/3 -translate-x-1/2 -translate-y-1/2 rounded-full bg-foreground/20 blur" />

<div className="grid w-full gap-8 py-6 md:py-8 lg:grid-cols-3 lg:gap-8">
<AnimatedContainer className="space-y-4">
<Link href="/">
<div className="flex flex-row items-center gap-2">
<Logo className="size-8!" />
<h2 className="text-xl font-bold">TNTStack</h2>
</div>
</Link>
<p className="mt-4 text-muted-foreground text-sm">
Build Cross-Platform Apps Faster Than Ever
</p>
<ModeSwitch />
</AnimatedContainer>

<div className="mt-10 grid grid-cols-2 gap-8 md:grid-cols-4 lg:col-span-2 lg:mt-0">
{footerLinks.map((section, index) => (
<AnimatedContainer delay={0.1 + index * 0.1} key={section.label}>
<div className="mb-10 md:mb-0">
<h3 className="text-xs">{section.label}</h3>
<ul className="mt-4 space-y-2 text-muted-foreground text-sm">
{section.links.map((link) => (
<li key={link.title}>
<Link
className="inline-flex items-center duration-250 hover:text-foreground [&_svg]:me-1 [&_svg]:size-4"
href={link.href}
target={link.isExternal ? "_blank" : "_self"}
rel={
link.isExternal ? "noopener noreferrer" : undefined
}
key={`${section.label}-${link.title}`}
>
{link.icon}
{link.title}
</Link>
</li>
))}
</ul>
</div>
</AnimatedContainer>
))}
</div>
</div>
<div className="flex w-full items-center justify-center overflow-hidden">
<TextHoverEffect text="TNTSTACK" />
</div>
<BorderBeam
duration={6}
size={200}
className="from-transparent via-primary to-transparent"
/>
</footer>
);
}
11 changes: 9 additions & 2 deletions apps/web/app/[locale]/(landing)/components/hero-section.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { Button } from "@workspace/ui/components/button";
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";
import { BorderBeam } from "@workspace/ui/components/landing/border-beam";
import { transitionVariants } from "@/lib/animations";

export default function HeroSection() {
Expand Down Expand Up @@ -110,6 +111,7 @@ export default function HeroSection() {
className="hidden size-full dark:block"
width="3276"
height="4095"
priority
/>
</AnimatedGroup>

Expand Down Expand Up @@ -218,19 +220,24 @@ export default function HeroSection() {
<div className="mask-b-from-55% relative -mr-56 mt-8 overflow-hidden px-2 sm:mr-0 sm:mt-12 md:mt-20">
<div className="inset-shadow-2xs ring-background dark:inset-shadow-white/20 bg-background relative mx-auto max-w-6xl overflow-hidden rounded-2xl border p-4 shadow-lg shadow-zinc-950/15 ring-1">
<Image
className="bg-background aspect-15/8 relative hidden rounded-2xl dark:block"
className="bg-background aspect-15/8 relative hidden rounded-2xl grayscale mix-blend-luminosity dark:block"
src="/app-screen-dark.png"
alt="app screen"
width="1920"
height="1080"
/>
<Image
className="z-2 border-border/25 aspect-15/8 relative rounded-2xl border dark:hidden"
className="z-2 border-border/25 aspect-15/8 relative rounded-2xl border grayscale mix-blend-luminosity dark:hidden"
src="/app-screen-light.png"
alt="app screen"
width="1920"
height="1080"
/>
<BorderBeam
duration={6}
size={200}
className="from-transparent via-primary to-transparent"
/>
</div>
</div>
</AnimatedGroup>
Expand Down
2 changes: 2 additions & 0 deletions apps/web/app/[locale]/(landing)/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { ThemeProvider } from "@workspace/core/providers/theme-provider";
import { HeroHeader } from "./components/header";
import { Footer } from "./components/footer";

interface LandingLayoutProps {
children: React.ReactNode;
Expand All @@ -16,6 +17,7 @@ export default function LandingLayout({ children }: LandingLayoutProps) {
>
<HeroHeader />
{children}
<Footer />
</ThemeProvider>
);
}
70 changes: 70 additions & 0 deletions packages/core/src/components/common/mode-switch.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
"use client";

import { LayoutGroup, motion } from "motion/react";
import { Monitor, Sun, Moon } from "lucide-react";
import { cn } from "@workspace/ui/lib/utils";
import { useMounted } from "@workspace/core/hooks/use-mounted";
import { useThemeTransition } from "@workspace/core/hooks/use-theme-transition";

type Mode = "system" | "light" | "dark";

const modes: { value: Mode; icon: typeof Monitor }[] = [
{ value: "system", icon: Monitor },
{ value: "light", icon: Sun },
{ value: "dark", icon: Moon },
];

export function ModeSwitch({ className }: { className?: string }) {
const mounted = useMounted();
const { theme, handleThemeChange } = useThemeTransition();

const selected = (theme as Mode) ?? "system";

if (!mounted) {
return <></>;
}

return (
<LayoutGroup>
<div
className={cn(
"inline-flex items-center gap-1 rounded-full border border-border bg-background p-1 backdrop-blur-sm",
className,
)}
>
{modes.map((mode) => {
const Icon = mode.icon;
const isActive = selected === mode.value;

return (
<button
key={mode.value}
type="button"
onClick={(e) => handleThemeChange(mode.value, e)}
className={cn(
"relative z-10 flex h-7 w-7 cursor-pointer items-center justify-center rounded-full transition-colors duration-200",
isActive
? "text-foreground"
: "text-muted-foreground hover:text-foreground/70",
)}
aria-label={`Switch to ${mode.value} mode`}
>
{isActive && (
<motion.span
layoutId="mode-switch-indicator"
className="absolute inset-0 rounded-full bg-background shadow-sm ring-1 ring-border"
transition={{
type: "spring",
stiffness: 500,
damping: 35,
}}
/>
)}
<Icon className="relative z-10 h-4 w-4" />
</button>
);
})}
</div>
</LayoutGroup>
);
}
32 changes: 32 additions & 0 deletions packages/ui/src/components/landing/animated-container.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
"use client";

import { motion, useReducedMotion } from "motion/react";
import type { ReactNode } from "react";

export function AnimatedContainer({
className,
delay = 0.1,
children,
}: {
delay?: number;
className?: string;
children: ReactNode;
}) {
const shouldReduceMotion = useReducedMotion();

if (shouldReduceMotion) {
return children;
}

return (
<motion.div
className={className}
initial={{ filter: "blur(4px)", translateY: -8, opacity: 0 }}
transition={{ delay, duration: 0.8 }}
viewport={{ once: true }}
whileInView={{ filter: "blur(0px)", translateY: 0, opacity: 1 }}
>
{children}
</motion.div>
);
}
Loading
Loading