feat(home-page): redesign home page with interactive components

Added new interactive components like animated grids, pulsating buttons, and ripple effects to enhance the user experience. Updated the layout to include sections for features, architecture, and file sharing. Improved the overall design with modern animations and typography.
This commit is contained in:
Daniel Luiz Alves
2025-04-24 20:17:04 -03:00
parent a3ed862ed9
commit e40254fea6
18 changed files with 1733 additions and 38 deletions

21
apps/docs/components.json Normal file
View File

@@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/app/global.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}

View File

@@ -1,5 +1,6 @@
---
title: 🔌 API Endpoints
tag: v2.0.0-beta
---
## 📚 Accessing the API Documentation

View File

@@ -9,13 +9,17 @@
"postinstall": "fumadocs-mdx"
},
"dependencies": {
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"fumadocs-core": "15.2.7",
"fumadocs-mdx": "11.6.0",
"fumadocs-ui": "15.2.7",
"lucide-react": "^0.488.0",
"motion": "^12.9.1",
"next": "15.3.0",
"react": "^19.1.0",
"react-dom": "^19.1.0"
"react-dom": "^19.1.0",
"tailwind-merge": "^3.2.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.1.3",
@@ -27,6 +31,7 @@
"eslint-config-next": "15.3.0",
"postcss": "^8.5.3",
"tailwindcss": "^4.1.3",
"tw-animate-css": "^1.2.8",
"typescript": "^5.8.3"
}
}

View File

@@ -8,6 +8,12 @@ importers:
.:
dependencies:
class-variance-authority:
specifier: ^0.7.1
version: 0.7.1
clsx:
specifier: ^2.1.1
version: 2.1.1
fumadocs-core:
specifier: 15.2.7
version: 15.2.7(@types/react@19.1.2)(next@15.3.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
@@ -20,6 +26,9 @@ importers:
lucide-react:
specifier: ^0.488.0
version: 0.488.0(react@19.1.0)
motion:
specifier: ^12.9.1
version: 12.9.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
next:
specifier: 15.3.0
version: 15.3.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
@@ -29,6 +38,9 @@ importers:
react-dom:
specifier: ^19.1.0
version: 19.1.0(react@19.1.0)
tailwind-merge:
specifier: ^3.2.0
version: 3.2.0
devDependencies:
'@tailwindcss/postcss':
specifier: ^4.1.3
@@ -57,6 +69,9 @@ importers:
tailwindcss:
specifier: ^4.1.3
version: 4.1.4
tw-animate-css:
specifier: ^1.2.8
version: 1.2.8
typescript:
specifier: ^5.8.3
version: 5.8.3
@@ -1648,6 +1663,20 @@ packages:
resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==}
engines: {node: '>= 0.4'}
framer-motion@12.9.1:
resolution: {integrity: sha512-dZBp2TO0a39Cc24opshlLoM0/OdTZVKzcXWuhntfwy2Qgz3t9+N4sTyUqNANyHaRFiJUWbwwsXeDvQkEBPky+g==}
peerDependencies:
'@emotion/is-prop-valid': '*'
react: ^18.0.0 || ^19.0.0
react-dom: ^18.0.0 || ^19.0.0
peerDependenciesMeta:
'@emotion/is-prop-valid':
optional: true
react:
optional: true
react-dom:
optional: true
fs.realpath@1.0.0:
resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==}
@@ -2295,6 +2324,26 @@ packages:
minimist@1.2.8:
resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
motion-dom@12.9.1:
resolution: {integrity: sha512-xqXEwRLDYDTzOgXobSoWtytRtGlf7zdkRfFbrrdP7eojaGQZ5Go4OOKtgnx7uF8sAkfr1ZjMvbCJSCIT2h6fkQ==}
motion-utils@12.8.3:
resolution: {integrity: sha512-GYVauZEbca8/zOhEiYOY9/uJeedYQld6co/GJFKOy//0c/4lDqk0zB549sBYqqV2iMuX+uHrY1E5zd8A2L+1Lw==}
motion@12.9.1:
resolution: {integrity: sha512-amdtlwafU+XLPcrfSrOQ/S2sqiSw+UTywH+X/Yoqaz0qYEocqJKh8bs6M09CdRmkjZuKx2YM+BHodXjsqoTEag==}
peerDependencies:
'@emotion/is-prop-valid': '*'
react: ^18.0.0 || ^19.0.0
react-dom: ^18.0.0 || ^19.0.0
peerDependenciesMeta:
'@emotion/is-prop-valid':
optional: true
react:
optional: true
react-dom:
optional: true
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
@@ -2796,6 +2845,9 @@ packages:
tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
tw-animate-css@1.2.8:
resolution: {integrity: sha512-AxSnYRvyFnAiZCUndS3zQZhNfV/B77ZhJ+O7d3K6wfg/jKJY+yv6ahuyXwnyaYA9UdLqnpCwhTRv9pPTBnPR2g==}
type-check@0.4.0:
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
engines: {node: '>= 0.8.0'}
@@ -4324,8 +4376,8 @@ snapshots:
'@typescript-eslint/parser': 8.30.1(eslint@8.57.1)(typescript@5.8.3)
eslint: 8.57.1
eslint-import-resolver-node: 0.3.9
eslint-import-resolver-typescript: 3.10.0(eslint-plugin-import@2.31.0)(eslint@8.57.1)
eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.30.1(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-typescript@3.10.0)(eslint@8.57.1)
eslint-import-resolver-typescript: 3.10.0(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.30.1(eslint@8.57.1)(typescript@5.8.3))(eslint@8.57.1))(eslint@8.57.1)
eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.30.1(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-typescript@3.10.0(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.30.1(eslint@8.57.1)(typescript@5.8.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1)
eslint-plugin-jsx-a11y: 6.10.2(eslint@8.57.1)
eslint-plugin-react: 7.37.5(eslint@8.57.1)
eslint-plugin-react-hooks: 5.2.0(eslint@8.57.1)
@@ -4344,7 +4396,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
eslint-import-resolver-typescript@3.10.0(eslint-plugin-import@2.31.0)(eslint@8.57.1):
eslint-import-resolver-typescript@3.10.0(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.30.1(eslint@8.57.1)(typescript@5.8.3))(eslint@8.57.1))(eslint@8.57.1):
dependencies:
'@nolyfill/is-core-module': 1.0.39
debug: 4.4.0
@@ -4355,22 +4407,22 @@ snapshots:
tinyglobby: 0.2.12
unrs-resolver: 1.5.0
optionalDependencies:
eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.30.1(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-typescript@3.10.0)(eslint@8.57.1)
eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.30.1(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-typescript@3.10.0(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.30.1(eslint@8.57.1)(typescript@5.8.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1)
transitivePeerDependencies:
- supports-color
eslint-module-utils@2.12.0(@typescript-eslint/parser@8.30.1(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.0)(eslint@8.57.1):
eslint-module-utils@2.12.0(@typescript-eslint/parser@8.30.1(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.0(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.30.1(eslint@8.57.1)(typescript@5.8.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1):
dependencies:
debug: 3.2.7
optionalDependencies:
'@typescript-eslint/parser': 8.30.1(eslint@8.57.1)(typescript@5.8.3)
eslint: 8.57.1
eslint-import-resolver-node: 0.3.9
eslint-import-resolver-typescript: 3.10.0(eslint-plugin-import@2.31.0)(eslint@8.57.1)
eslint-import-resolver-typescript: 3.10.0(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.30.1(eslint@8.57.1)(typescript@5.8.3))(eslint@8.57.1))(eslint@8.57.1)
transitivePeerDependencies:
- supports-color
eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.30.1(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-typescript@3.10.0)(eslint@8.57.1):
eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.30.1(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-typescript@3.10.0(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.30.1(eslint@8.57.1)(typescript@5.8.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1):
dependencies:
'@rtsao/scc': 1.1.0
array-includes: 3.1.8
@@ -4381,7 +4433,7 @@ snapshots:
doctrine: 2.1.0
eslint: 8.57.1
eslint-import-resolver-node: 0.3.9
eslint-module-utils: 2.12.0(@typescript-eslint/parser@8.30.1(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.0)(eslint@8.57.1)
eslint-module-utils: 2.12.0(@typescript-eslint/parser@8.30.1(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.0(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.30.1(eslint@8.57.1)(typescript@5.8.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1)
hasown: 2.0.2
is-core-module: 2.16.1
is-glob: 4.0.3
@@ -4614,6 +4666,15 @@ snapshots:
dependencies:
is-callable: 1.2.7
framer-motion@12.9.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
dependencies:
motion-dom: 12.9.1
motion-utils: 12.8.3
tslib: 2.8.1
optionalDependencies:
react: 19.1.0
react-dom: 19.1.0(react@19.1.0)
fs.realpath@1.0.0: {}
fumadocs-core@15.2.7(@types/react@19.1.2)(next@15.3.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
@@ -5595,6 +5656,20 @@ snapshots:
minimist@1.2.8: {}
motion-dom@12.9.1:
dependencies:
motion-utils: 12.8.3
motion-utils@12.8.3: {}
motion@12.9.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
dependencies:
framer-motion: 12.9.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
tslib: 2.8.1
optionalDependencies:
react: 19.1.0
react-dom: 19.1.0(react@19.1.0)
ms@2.1.3: {}
nanoid@3.3.11: {}
@@ -6223,6 +6298,8 @@ snapshots:
tslib@2.8.1: {}
tw-animate-css@1.2.8: {}
type-check@0.4.0:
dependencies:
prelude-ls: 1.2.1

View File

@@ -1,7 +1,13 @@
import type { ReactNode } from 'react';
import { HomeLayout } from 'fumadocs-ui/layouts/home';
import { baseOptions } from '@/app/layout.config';
import type { ReactNode } from "react";
import { HomeLayout } from "fumadocs-ui/layouts/home";
import { baseOptions } from "@/app/layout.config";
import { Particles } from "@/components/magicui/particles";
export default function Layout({ children }: { children: ReactNode }) {
return <HomeLayout {...baseOptions}>{children}</HomeLayout>;
return (
<HomeLayout {...baseOptions}>
<Particles className="absolute w-full" />
{children}
</HomeLayout>
);
}

View File

@@ -1,19 +1,365 @@
import Link from 'next/link';
import {
type LucideIcon,
MousePointer,
UploadIcon,
Share2Icon,
GithubIcon,
} from "lucide-react";
import {
BatteryChargingIcon,
KeyboardIcon,
LayoutIcon,
PersonStandingIcon,
RocketIcon,
SearchIcon,
TimerIcon,
} from "lucide-react";
import Link from "next/link";
import type { ReactNode } from "react";
import { ThreeDMarquee } from "@/components/ui/3d-marquee";
import { AnimatedGridPattern } from "@/components/magicui/animated-grid-pattern";
import { TypingAnimation } from "@/components/magicui/typing-animation";
import { TextHoverEffect } from "@/components/ui/text-hover-effect";
import { PulsatingButton } from "@/components/magicui/pulsating-button";
import { RippleButton } from "@/components/magicui/ripple-button";
import { WordRotate } from "@/components/magicui/word-rotate";
const images = [
"https://assets.aceternity.com/cloudinary_bkp/3d-card.png",
"https://assets.aceternity.com/animated-modal.png",
"https://assets.aceternity.com/animated-testimonials.webp",
"https://assets.aceternity.com/cloudinary_bkp/Tooltip_luwy44.png",
"https://assets.aceternity.com/github-globe.png",
"https://assets.aceternity.com/glare-card.png",
"https://assets.aceternity.com/layout-grid.png",
"https://assets.aceternity.com/flip-text.png",
"https://assets.aceternity.com/hero-highlight.png",
"https://assets.aceternity.com/carousel.webp",
"https://assets.aceternity.com/placeholders-and-vanish-input.png",
"https://assets.aceternity.com/shooting-stars-and-stars-background.png",
"https://assets.aceternity.com/signup-form.png",
"https://assets.aceternity.com/cloudinary_bkp/stars_sxle3d.png",
"https://assets.aceternity.com/spotlight-new.webp",
"https://assets.aceternity.com/cloudinary_bkp/Spotlight_ar5jpr.png",
"https://assets.aceternity.com/cloudinary_bkp/Parallax_Scroll_pzlatw_anfkh7.png",
"https://assets.aceternity.com/tabs.png",
"https://assets.aceternity.com/cloudinary_bkp/Tracing_Beam_npujte.png",
"https://assets.aceternity.com/cloudinary_bkp/typewriter-effect.png",
"https://assets.aceternity.com/glowing-effect.webp",
"https://assets.aceternity.com/hover-border-gradient.png",
"https://assets.aceternity.com/cloudinary_bkp/Infinite_Moving_Cards_evhzur.png",
"https://assets.aceternity.com/cloudinary_bkp/Lamp_hlq3ln.png",
"https://assets.aceternity.com/macbook-scroll.png",
"https://assets.aceternity.com/cloudinary_bkp/Meteors_fye3ys.png",
"https://assets.aceternity.com/cloudinary_bkp/Moving_Border_yn78lv.png",
"https://assets.aceternity.com/multi-step-loader.png",
"https://assets.aceternity.com/vortex.png",
"https://assets.aceternity.com/wobble-card.png",
"https://assets.aceternity.com/world-map.webp",
];
const docsLink = "/docs/2.0.0-beta";
export default function HomePage() {
return (
<main className="flex flex-1 flex-col justify-center text-center">
<h1 className="mb-4 text-2xl font-bold">Hello World</h1>
<p className="text-fd-muted-foreground">
You can open{' '}
<Link
href="/docs"
className="text-fd-foreground font-semibold underline"
>
/docs
</Link>{' '}
and see the documentation.
</p>
</main>
<>
<main className="relative z-[2] w-full px-4 py-6 sm:px-6 lg:px-8">
<div className="relative mx-auto max-w-screen-xl bg-background">
<Hero />
<LogoShowcase />
<Feedback />
<Introduction />
<Architecture />
<FileSection />
<Highlights />
<End />
</div>
</main>
<FullWidthFooter />
</>
);
}
function Hero() {
return (
<section className="relative z-[2] flex flex-col border-x border-t px-6 pt-12 pb-10 md:px-12 md:pt-16 max-md:text-center">
<h1 className="mb-8 text-5xl font-bold">🌴 Palmr.</h1>
<h1 className="hidden text-4xl font-medium max-w-[600px] md:block mb-4">
Modern & efficient file sharing
</h1>
<p className="mb-8 text-fd-muted-foreground md:max-w-[80%] md:text-xl">
Palmr is a fast and secure platform for sharing files, built with
performance and privacy in mind.
</p>
<div className="hidden h-[10rem] lg:flex items-center justify-center absolute right-0 top-10">
<TextHoverEffect text="Palmr." />
</div>
<div className="inline-flex items-center gap-6 max-md:mx-auto mb-4">
<PulsatingButton>
<Link href={docsLink}>Get Started</Link>
</PulsatingButton>
<RippleButton>
<a
href="https://github.com/kyantech/Palmr"
target="_blank"
rel="noreferrer noopener"
className="flex gap-2 items-center"
>
<GithubIcon size={18} />
GitHub
</a>
</RippleButton>
</div>
</section>
);
}
function LogoShowcase() {
return (
<div className="z-[2] border-x bg-background">
<ThreeDMarquee images={images} className="rounded-none" />
</div>
);
}
function Feedback() {
return (
<section className="relative flex flex-col items-center overflow-hidden border-x border-t px-6 py-8 md:py-16">
<p className="text-xl font-medium flex items-center justify-center gap-2">
A modern way to share files
<WordRotate
duration={4000}
words={[
"efficiently",
"securely",
"privately",
"reliably",
"seamlessly",
]}
className="min-w-[100px] inline-block"
/>
</p>
</section>
);
}
export function Introduction() {
return (
<section className="grid grid-cols-1 border-t border-x md:grid-cols-2">
<div className="flex flex-col gap-4 border-r p-8 md:p-12">
<div className="flex gap-4 items-center">
<div className="flex items-center gap-3 text-muted-foreground border border-foreground w-fit p-3 rounded-lg">
<UploadIcon className="size-6 text-foreground" />
</div>
<h3 className="text-2xl font-semibold">Upload.</h3>
</div>
<p className="text-muted-foreground">
Send your files quickly and safely.
</p>
</div>
<div className="flex flex-col gap-4 border-r p-8 md:p-12">
<div className="flex gap-4 items-center">
<div className="flex items-center gap-3 text-muted-foreground border border-foreground w-fit p-3 rounded-lg">
<Share2Icon className="size-6 text-foreground" />
</div>
<h3 className="text-2xl font-semibold">Share.</h3>
</div>
<p className="text-muted-foreground">Easily share with anyone.</p>
</div>
</section>
);
}
function Architecture() {
return (
<section className="flex flex-col gap-4 border-x border-t px-8 py-16 md:py-24 lg:flex-row md:px-12">
<div className="flex-1 shrink-0 text-start">
<p className="mb-4 w-fit bg-fd-primary px-2 py-1 text-md font-bold font-mono text-fd-primary-foreground">
Carefully Built
</p>
<h2 className="mb-4 text-xl font-semibold sm:text-2xl">
A complete solution for file sharing.
</h2>
<p className="mb-6 text-fd-muted-foreground">
From the upload to the link generation, everything is designed to be
fast, reliable, and privacy-friendly.
<br />
<br />
Every feature was crafted to deliver the best possible experience.
</p>
</div>
</section>
);
}
function FileSection() {
return (
<section
className="relative overflow-hidden border-x border-t px-8 py-16 sm:py-24"
style={{
backgroundImage:
"radial-gradient(circle at center, var(--color-fd-secondary), var(--color-fd-background) 40%)",
}}
>
<h2 className="text-center text-2xl font-semibold sm:text-3xl">
File Sharing
<TypingAnimation className="text-center text-2xl font-semibold sm:text-3xl">
Free & Open Source
</TypingAnimation>
</h2>
<AnimatedGridPattern className="opacity-5" />
</section>
);
}
function Highlights() {
const features = [
{
icon: TimerIcon,
title: "Fast & Efficient",
text: "Optimized upload and download speeds.",
},
{
icon: LayoutIcon,
title: "Intuitive UI",
text: "Clean, modern, and easy to use.",
},
{
icon: RocketIcon,
title: "Modern Stack",
text: "Powered by Next.js, Fastify, MinIO, Postgres and the latest tech.",
},
{
icon: SearchIcon,
title: "Smart Search",
text: "Find shared files quickly.",
},
{
icon: KeyboardIcon,
title: "Open API",
text: "REST API endpoinds available for any integrations.",
},
{
icon: PersonStandingIcon,
title: "Customizable",
text: "Full control over all the system and configurations.",
},
];
return (
<section className="grid grid-cols-1 border-r md:grid-cols-2 lg:grid-cols-3">
<div className="col-span-full flex items-start justify-center border-l border-t p-8 pb-2 text-center">
<h2 className="bg-fd-primary px-1 text-2xl font-semibold text-fd-primary-foreground">
Highlights
</h2>
<MousePointer className="-ml-1 mt-8" />
</div>
{features.map(({ icon, title, text }, i) => (
<Highlight key={i} icon={icon} heading={title}>
{text}
</Highlight>
))}
</section>
);
}
function Highlight({
icon: Icon,
heading,
children,
}: {
icon: LucideIcon;
heading: ReactNode;
children: ReactNode;
}) {
return (
<div className="border-l border-t px-6 py-12">
<div className="mb-4 flex items-center gap-2 text-fd-muted-foreground">
<Icon className="size-6" />
<h2 className="text-sm font-medium">{heading}</h2>
</div>
<span className="font-medium">{children}</span>
</div>
);
}
function End() {
return (
<section className="flex w-full flex-1">
<div className="w-full flex flex-col gap-8 overflow-hidden border px-8 py-14">
<h2 className="text-3xl font-extrabold font-mono uppercase ">
Start Using Now. 🌴
</h2>
<ul className="mt-2 flex flex-col gap-6">
<ListItem icon={TimerIcon} title="Fast Setup">
Get up and running in minutes.
</ListItem>
<ListItem
icon={BatteryChargingIcon}
title="All under your own control"
>
Take full control of your file sharing infrastructure.
</ListItem>
</ul>
<div className="flex flex-wrap gap-2 border-t pt-14 pb-0 justify-end">
<RippleButton>
<Link href={docsLink}>Documentation</Link>
</RippleButton>
<RippleButton>
<a
href="https://github.com/kyantech/Palmr"
target="_blank"
rel="noreferrer noopener"
className="flex gap-2 items-center"
>
<GithubIcon size={18} />
GitHub
</a>
</RippleButton>
</div>
</div>
</section>
);
}
function ListItem({
icon: Icon,
title,
children,
}: {
icon: LucideIcon;
title: string;
children: ReactNode;
}) {
return (
<li>
<span className="flex items-center gap-3 font-medium">
<Icon className="size-8" />
{title}
</span>
<span className="mt-2 block text-sm text-fd-muted-foreground">
{children}
</span>
</li>
);
}
function FullWidthFooter() {
return (
<footer className="w-full flex items-center justify-center p-6 border-t font-light container max-w-7xl">
<div className="flex items-center gap-1 text-sm max-w-7xl">
<span>Powered by</span>
<Link
href="http://kyantech.com.br"
rel="noopener noreferrer"
target="_blank"
className="flex items-center hover:text-green-700 text-green-500 transition-colors font-light"
>
Kyantech Solutions ©
</Link>
</div>
</footer>
);
}

View File

@@ -1,4 +1,16 @@
import { source } from '@/lib/source';
import { createFromSource } from 'fumadocs-core/search/server';
export const { GET } = createFromSource(source);
export const { GET } = createFromSource(source, (page) => {
// Log the page URL for debugging
console.log('Page URL:', page.url);
return {
title: page.data.title,
description: page.data.description,
url: page.url,
id: page.url,
structuredData: page.data.structuredData,
tag: page.url.startsWith('/docs/2.0.0-beta') ? 'v2.0.0-beta' : 'v1.1.7-beta'
};
});

View File

@@ -1,6 +1,9 @@
@import "tailwindcss";
@import "fumadocs-ui/css/neutral.css";
@import "fumadocs-ui/css/preset.css";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
/* @import 'fumadocs-ui/css/black.css'; */
@source '../../node_modules/fumadocs-ui/dist/**/*.js';
@@ -18,3 +21,176 @@ h4 {
.prose h3 {
font-size: 1.25rem;
}
@theme inline {
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
--animate-ripple: ripple var(--duration,2s) ease calc(var(--i, 0)*.2s) infinite;
@keyframes ripple {
0%, 100% {
transform: translate(-50%, -50%) scale(1);
}
50% {
transform: translate(-50%, -50%) scale(0.9);
}
}
--animate-pulse: pulse var(--duration) ease-out infinite
;
@keyframes pulse {
0%, 100% {
boxShadow: 0 0 0 0 var(--pulse-color);
}
50% {
boxShadow: 0 0 0 8px var(--pulse-color);
}
}
--animate-rippling: rippling var(--duration) ease-out;
@keyframes rippling {
0% {
opacity: 1;
}
100% {
transform: scale(2);
opacity: 0;
}
}}
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}
@theme inline {
--animate-pulse: pulse var(--duration) ease-out infinite;
@keyframes pulse {
0%,
100% {
box-shadow: 0 0 0 0 var(--pulse-color);
}
50% {
box-shadow: 0 0 0 8px var(--pulse-color);
}
}
}
@theme inline {
--animate-rippling: rippling var(--duration) ease-out;
@keyframes rippling {
0% {
opacity: 1;
}
100% {
transform: scale(2);
opacity: 0;
}
}
}

View File

@@ -1,20 +1,45 @@
import { Banner } from 'fumadocs-ui/components/banner';
import './global.css';
import { RootProvider } from 'fumadocs-ui/provider';
import { Inter } from 'next/font/google';
import type { ReactNode } from 'react';
import Link from 'fumadocs-core/link';
import { Banner } from "fumadocs-ui/components/banner";
import "./global.css";
import { RootProvider } from "fumadocs-ui/provider";
import { Inter } from "next/font/google";
import type { ReactNode } from "react";
import Link from "fumadocs-core/link";
const inter = Inter({
subsets: ['latin'],
subsets: ["latin"],
});
export default function Layout({ children }: { children: ReactNode }) {
return (
<html lang="en" className={inter.className} suppressHydrationWarning>
<body className="flex flex-col min-h-screen">
<Banner variant="rainbow" id='banner-v-2' changeLayout={false}><Link href='/docs/2.0.0-beta' >Palmr. v2.0.0-beta has released!</Link></Banner>
<RootProvider>{children}</RootProvider>
<Banner variant="rainbow" id="banner-v-2" changeLayout={false}>
<Link href="/docs/2.0.0-beta">Palmr. v2.0.0-beta has released!</Link>
</Banner>
<RootProvider
search={{
options: {
defaultTag: "2.0.0-beta",
tags: [
{
name: "v1.1.7 Beta",
value: "1.1.7-beta",
},
{
name: "v2.0.0 Beta ✨",
value: "2.0.0-beta",
props: {
style: {
border: "1px solid rgba(0,165,80,0.2)",
},
},
},
],
},
}}
>
{children}
</RootProvider>
</body>
</html>
);

View File

@@ -0,0 +1,154 @@
"use client";
import { motion } from "motion/react";
import {
ComponentPropsWithoutRef,
useEffect,
useId,
useRef,
useState,
} from "react";
import { cn } from "@/lib/utils";
export interface AnimatedGridPatternProps
extends ComponentPropsWithoutRef<"svg"> {
width?: number;
height?: number;
x?: number;
y?: number;
strokeDasharray?: any;
numSquares?: number;
maxOpacity?: number;
duration?: number;
repeatDelay?: number;
}
export function AnimatedGridPattern({
width = 40,
height = 40,
x = -1,
y = -1,
strokeDasharray = 0,
numSquares = 50,
className,
maxOpacity = 0.5,
duration = 4,
repeatDelay = 0.5,
...props
}: AnimatedGridPatternProps) {
const id = useId();
const containerRef = useRef(null);
const [dimensions, setDimensions] = useState({ width: 0, height: 0 });
const [squares, setSquares] = useState(() => generateSquares(numSquares));
function getPos() {
return [
Math.floor((Math.random() * dimensions.width) / width),
Math.floor((Math.random() * dimensions.height) / height),
];
}
// Adjust the generateSquares function to return objects with an id, x, and y
function generateSquares(count: number) {
return Array.from({ length: count }, (_, i) => ({
id: i,
pos: getPos(),
}));
}
// Function to update a single square's position
const updateSquarePosition = (id: number) => {
setSquares((currentSquares) =>
currentSquares.map((sq) =>
sq.id === id
? {
...sq,
pos: getPos(),
}
: sq,
),
);
};
// Update squares to animate in
useEffect(() => {
if (dimensions.width && dimensions.height) {
setSquares(generateSquares(numSquares));
}
}, [dimensions, numSquares]);
// Resize observer to update container dimensions
useEffect(() => {
const resizeObserver = new ResizeObserver((entries) => {
for (let entry of entries) {
setDimensions({
width: entry.contentRect.width,
height: entry.contentRect.height,
});
}
});
if (containerRef.current) {
resizeObserver.observe(containerRef.current);
}
return () => {
if (containerRef.current) {
resizeObserver.unobserve(containerRef.current);
}
};
}, [containerRef]);
return (
<svg
ref={containerRef}
aria-hidden="true"
className={cn(
"pointer-events-none absolute inset-0 h-full w-full fill-gray-400/30 stroke-gray-400/30",
className,
)}
{...props}
>
<defs>
<pattern
id={id}
width={width}
height={height}
patternUnits="userSpaceOnUse"
x={x}
y={y}
>
<path
d={`M.5 ${height}V.5H${width}`}
fill="none"
strokeDasharray={strokeDasharray}
/>
</pattern>
</defs>
<rect width="100%" height="100%" fill={`url(#${id})`} />
<svg x={x} y={y} className="overflow-visible">
{squares.map(({ pos: [x, y], id }, index) => (
<motion.rect
initial={{ opacity: 0 }}
animate={{ opacity: maxOpacity }}
transition={{
duration,
repeat: 1,
delay: index * 0.1,
repeatType: "reverse",
}}
onAnimationComplete={() => updateSquarePosition(id)}
key={`${x}-${y}-${index}`}
width={width - 1}
height={height - 1}
x={x * width + 1}
y={y * height + 1}
fill="currentColor"
strokeWidth="0"
/>
))}
</svg>
</svg>
);
}

View File

@@ -0,0 +1,313 @@
"use client";
import { cn } from "@/lib/utils";
import React, {
ComponentPropsWithoutRef,
useEffect,
useRef,
useState,
} from "react";
interface MousePosition {
x: number;
y: number;
}
function MousePosition(): MousePosition {
const [mousePosition, setMousePosition] = useState<MousePosition>({
x: 0,
y: 0,
});
useEffect(() => {
const handleMouseMove = (event: MouseEvent) => {
setMousePosition({ x: event.clientX, y: event.clientY });
};
window.addEventListener("mousemove", handleMouseMove);
return () => {
window.removeEventListener("mousemove", handleMouseMove);
};
}, []);
return mousePosition;
}
interface ParticlesProps extends ComponentPropsWithoutRef<"div"> {
className?: string;
quantity?: number;
staticity?: number;
ease?: number;
size?: number;
refresh?: boolean;
color?: string;
vx?: number;
vy?: number;
}
function hexToRgb(hex: string): number[] {
hex = hex.replace("#", "");
if (hex.length === 3) {
hex = hex
.split("")
.map((char) => char + char)
.join("");
}
const hexInt = parseInt(hex, 16);
const red = (hexInt >> 16) & 255;
const green = (hexInt >> 8) & 255;
const blue = hexInt & 255;
return [red, green, blue];
}
type Circle = {
x: number;
y: number;
translateX: number;
translateY: number;
size: number;
alpha: number;
targetAlpha: number;
dx: number;
dy: number;
magnetism: number;
};
export const Particles: React.FC<ParticlesProps> = ({
className = "",
quantity = 100,
staticity = 50,
ease = 50,
size = 0.4,
refresh = false,
color = "#ffffff",
vx = 0,
vy = 0,
...props
}) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const canvasContainerRef = useRef<HTMLDivElement>(null);
const context = useRef<CanvasRenderingContext2D | null>(null);
const circles = useRef<Circle[]>([]);
const mousePosition = MousePosition();
const mouse = useRef<{ x: number; y: number }>({ x: 0, y: 0 });
const canvasSize = useRef<{ w: number; h: number }>({ w: 0, h: 0 });
const dpr = typeof window !== "undefined" ? window.devicePixelRatio : 1;
const rafID = useRef<number | null>(null);
const resizeTimeout = useRef<NodeJS.Timeout | null>(null);
useEffect(() => {
if (canvasRef.current) {
context.current = canvasRef.current.getContext("2d");
}
initCanvas();
animate();
const handleResize = () => {
if (resizeTimeout.current) {
clearTimeout(resizeTimeout.current);
}
resizeTimeout.current = setTimeout(() => {
initCanvas();
}, 200);
};
window.addEventListener("resize", handleResize);
return () => {
if (rafID.current != null) {
window.cancelAnimationFrame(rafID.current);
}
if (resizeTimeout.current) {
clearTimeout(resizeTimeout.current);
}
window.removeEventListener("resize", handleResize);
};
}, [color]);
useEffect(() => {
onMouseMove();
}, [mousePosition.x, mousePosition.y]);
useEffect(() => {
initCanvas();
}, [refresh]);
const initCanvas = () => {
resizeCanvas();
drawParticles();
};
const onMouseMove = () => {
if (canvasRef.current) {
const rect = canvasRef.current.getBoundingClientRect();
const { w, h } = canvasSize.current;
const x = mousePosition.x - rect.left - w / 2;
const y = mousePosition.y - rect.top - h / 2;
const inside = x < w / 2 && x > -w / 2 && y < h / 2 && y > -h / 2;
if (inside) {
mouse.current.x = x;
mouse.current.y = y;
}
}
};
const resizeCanvas = () => {
if (canvasContainerRef.current && canvasRef.current && context.current) {
canvasSize.current.w = canvasContainerRef.current.offsetWidth;
canvasSize.current.h = canvasContainerRef.current.offsetHeight;
canvasRef.current.width = canvasSize.current.w * dpr;
canvasRef.current.height = canvasSize.current.h * dpr;
canvasRef.current.style.width = `${canvasSize.current.w}px`;
canvasRef.current.style.height = `${canvasSize.current.h}px`;
context.current.scale(dpr, dpr);
// Clear existing particles and create new ones with exact quantity
circles.current = [];
for (let i = 0; i < quantity; i++) {
const circle = circleParams();
drawCircle(circle);
}
}
};
const circleParams = (): Circle => {
const x = Math.floor(Math.random() * canvasSize.current.w);
const y = Math.floor(Math.random() * canvasSize.current.h);
const translateX = 0;
const translateY = 0;
const pSize = Math.floor(Math.random() * 2) + size;
const alpha = 0;
const targetAlpha = parseFloat((Math.random() * 0.6 + 0.1).toFixed(1));
const dx = (Math.random() - 0.5) * 0.1;
const dy = (Math.random() - 0.5) * 0.1;
const magnetism = 0.1 + Math.random() * 4;
return {
x,
y,
translateX,
translateY,
size: pSize,
alpha,
targetAlpha,
dx,
dy,
magnetism,
};
};
const rgb = hexToRgb(color);
const drawCircle = (circle: Circle, update = false) => {
if (context.current) {
const { x, y, translateX, translateY, size, alpha } = circle;
context.current.translate(translateX, translateY);
context.current.beginPath();
context.current.arc(x, y, size, 0, 2 * Math.PI);
context.current.fillStyle = `rgba(${rgb.join(", ")}, ${alpha})`;
context.current.fill();
context.current.setTransform(dpr, 0, 0, dpr, 0, 0);
if (!update) {
circles.current.push(circle);
}
}
};
const clearContext = () => {
if (context.current) {
context.current.clearRect(
0,
0,
canvasSize.current.w,
canvasSize.current.h,
);
}
};
const drawParticles = () => {
clearContext();
const particleCount = quantity;
for (let i = 0; i < particleCount; i++) {
const circle = circleParams();
drawCircle(circle);
}
};
const remapValue = (
value: number,
start1: number,
end1: number,
start2: number,
end2: number,
): number => {
const remapped =
((value - start1) * (end2 - start2)) / (end1 - start1) + start2;
return remapped > 0 ? remapped : 0;
};
const animate = () => {
clearContext();
circles.current.forEach((circle: Circle, i: number) => {
// Handle the alpha value
const edge = [
circle.x + circle.translateX - circle.size, // distance from left edge
canvasSize.current.w - circle.x - circle.translateX - circle.size, // distance from right edge
circle.y + circle.translateY - circle.size, // distance from top edge
canvasSize.current.h - circle.y - circle.translateY - circle.size, // distance from bottom edge
];
const closestEdge = edge.reduce((a, b) => Math.min(a, b));
const remapClosestEdge = parseFloat(
remapValue(closestEdge, 0, 20, 0, 1).toFixed(2),
);
if (remapClosestEdge > 1) {
circle.alpha += 0.02;
if (circle.alpha > circle.targetAlpha) {
circle.alpha = circle.targetAlpha;
}
} else {
circle.alpha = circle.targetAlpha * remapClosestEdge;
}
circle.x += circle.dx + vx;
circle.y += circle.dy + vy;
circle.translateX +=
(mouse.current.x / (staticity / circle.magnetism) - circle.translateX) /
ease;
circle.translateY +=
(mouse.current.y / (staticity / circle.magnetism) - circle.translateY) /
ease;
drawCircle(circle, true);
// circle gets out of the canvas
if (
circle.x < -circle.size ||
circle.x > canvasSize.current.w + circle.size ||
circle.y < -circle.size ||
circle.y > canvasSize.current.h + circle.size
) {
// remove the circle from the array
circles.current.splice(i, 1);
// create a new circle
const newCircle = circleParams();
drawCircle(newCircle);
}
});
rafID.current = window.requestAnimationFrame(animate);
};
return (
<div
className={cn("pointer-events-none", className)}
ref={canvasContainerRef}
aria-hidden="true"
{...props}
>
<canvas ref={canvasRef} className="size-full" />
</div>
);
};

View File

@@ -0,0 +1,46 @@
import React from "react";
import { cn } from "@/lib/utils";
interface PulsatingButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
pulseColor?: string;
duration?: string;
}
export const PulsatingButton = React.forwardRef<
HTMLButtonElement,
PulsatingButtonProps
>(
(
{
className,
children,
pulseColor = "#808080",
duration = "1.5s",
...props
},
ref,
) => {
return (
<button
ref={ref}
className={cn(
"relative flex cursor-pointer items-center justify-center rounded-lg bg-primary px-4 py-2 text-center text-primary-foreground",
className,
)}
style={
{
"--pulse-color": pulseColor,
"--duration": duration,
} as React.CSSProperties
}
{...props}
>
<div className="relative z-10">{children}</div>
<div className="absolute left-1/2 top-1/2 size-full -translate-x-1/2 -translate-y-1/2 animate-pulse rounded-lg bg-inherit" />
</button>
);
},
);
PulsatingButton.displayName = "PulsatingButton";

View File

@@ -0,0 +1,91 @@
"use client";
import { cn } from "@/lib/utils";
import React, { MouseEvent, useEffect, useState } from "react";
interface RippleButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
rippleColor?: string;
duration?: string;
}
export const RippleButton = React.forwardRef<
HTMLButtonElement,
RippleButtonProps
>(
(
{
className,
children,
rippleColor = "#ffffff",
duration = "600ms",
onClick,
...props
},
ref,
) => {
const [buttonRipples, setButtonRipples] = useState<
Array<{ x: number; y: number; size: number; key: number }>
>([]);
const handleClick = (event: MouseEvent<HTMLButtonElement>) => {
createRipple(event);
onClick?.(event);
};
const createRipple = (event: MouseEvent<HTMLButtonElement>) => {
const button = event.currentTarget;
const rect = button.getBoundingClientRect();
const size = Math.max(rect.width, rect.height);
const x = event.clientX - rect.left - size / 2;
const y = event.clientY - rect.top - size / 2;
const newRipple = { x, y, size, key: Date.now() };
setButtonRipples((prevRipples) => [...prevRipples, newRipple]);
};
useEffect(() => {
if (buttonRipples.length > 0) {
const lastRipple = buttonRipples[buttonRipples.length - 1];
const timeout = setTimeout(() => {
setButtonRipples((prevRipples) =>
prevRipples.filter((ripple) => ripple.key !== lastRipple.key),
);
}, parseInt(duration));
return () => clearTimeout(timeout);
}
}, [buttonRipples, duration]);
return (
<button
className={cn(
"relative flex cursor-pointer items-center justify-center overflow-hidden rounded-lg border-2 bg-background px-4 py-2 text-center text-primary",
className,
)}
onClick={handleClick}
ref={ref}
{...props}
>
<div className="relative z-10">{children}</div>
<span className="pointer-events-none absolute inset-0">
{buttonRipples.map((ripple) => (
<span
className="absolute animate-rippling rounded-full bg-background opacity-30"
key={ripple.key}
style={{
width: `${ripple.size}px`,
height: `${ripple.size}px`,
top: `${ripple.y}px`,
left: `${ripple.x}px`,
backgroundColor: rippleColor,
transform: `scale(0)`,
}}
/>
))}
</span>
</button>
);
},
);
RippleButton.displayName = "RippleButton";

View File

@@ -0,0 +1,90 @@
"use client";
import { cn } from "@/lib/utils";
import { motion, MotionProps } from "motion/react";
import { useEffect, useRef, useState } from "react";
interface TypingAnimationProps extends MotionProps {
children: string;
className?: string;
duration?: number;
delay?: number;
as?: React.ElementType;
startOnView?: boolean;
}
export function TypingAnimation({
children,
className,
duration = 100,
delay = 0,
as: Component = "div",
startOnView = false,
...props
}: TypingAnimationProps) {
const MotionComponent = motion.create(Component, {
forwardMotionProps: true,
});
const [displayedText, setDisplayedText] = useState<string>("");
const [started, setStarted] = useState(false);
const elementRef = useRef<HTMLElement | null>(null);
useEffect(() => {
if (!startOnView) {
const startTimeout = setTimeout(() => {
setStarted(true);
}, delay);
return () => clearTimeout(startTimeout);
}
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setTimeout(() => {
setStarted(true);
}, delay);
observer.disconnect();
}
},
{ threshold: 0.1 },
);
if (elementRef.current) {
observer.observe(elementRef.current);
}
return () => observer.disconnect();
}, [delay, startOnView]);
useEffect(() => {
if (!started) return;
let i = 0;
const typingEffect = setInterval(() => {
if (i < children.length) {
setDisplayedText(children.substring(0, i + 1));
i++;
} else {
clearInterval(typingEffect);
}
}, duration);
return () => {
clearInterval(typingEffect);
};
}, [children, duration, started]);
return (
<MotionComponent
ref={elementRef}
className={cn(
"text-4xl font-bold leading-[5rem] tracking-[-0.02em]",
className,
)}
{...props}
>
{displayedText}
</MotionComponent>
);
}

View File

@@ -0,0 +1,50 @@
"use client";
import { AnimatePresence, motion, MotionProps } from "motion/react";
import { useEffect, useState } from "react";
import { cn } from "@/lib/utils";
interface WordRotateProps {
words: string[];
duration?: number;
motionProps?: MotionProps;
className?: string;
}
export function WordRotate({
words,
duration = 2500,
motionProps = {
initial: { opacity: 0, y: -50 },
animate: { opacity: 1, y: 0 },
exit: { opacity: 0, y: 50 },
transition: { duration: 0.25, ease: "easeOut" },
},
className,
}: WordRotateProps) {
const [index, setIndex] = useState(0);
useEffect(() => {
const interval = setInterval(() => {
setIndex((prevIndex) => (prevIndex + 1) % words.length);
}, duration);
// Clean up interval on unmount
return () => clearInterval(interval);
}, [words, duration]);
return (
<div className="overflow-hidden py-2">
<AnimatePresence mode="wait">
<motion.h1
key={words[index]}
className={cn(className)}
{...motionProps}
>
{words[index]}
</motion.h1>
</AnimatePresence>
</div>
);
}

View File

@@ -0,0 +1,142 @@
"use client";
import { motion } from "motion/react";
import { cn } from "@/lib/utils";
export const ThreeDMarquee = ({
images,
className,
}: {
images: string[];
className?: string;
}) => {
// Split the images array into 4 equal parts
const chunkSize = Math.ceil(images.length / 4);
const chunks = Array.from({ length: 4 }, (_, colIndex) => {
const start = colIndex * chunkSize;
return images.slice(start, start + chunkSize);
});
return (
<div
className={cn(
"mx-auto block h-[600px] overflow-hidden rounded-2xl max-sm:h-100",
className,
)}
>
<div className="flex size-full items-center justify-center">
<div className="size-[1720px] shrink-0 scale-50 sm:scale-75 lg:scale-100">
<div
style={{
transform: "rotateX(55deg) rotateY(0deg) rotateZ(-45deg)",
}}
className="relative top-96 right-[50%] grid size-full origin-top-left grid-cols-4 gap-8 transform-3d"
>
{chunks.map((subarray, colIndex) => (
<motion.div
animate={{ y: colIndex % 2 === 0 ? 100 : -100 }}
transition={{
duration: colIndex % 2 === 0 ? 10 : 15,
repeat: Infinity,
repeatType: "reverse",
}}
key={colIndex + "marquee"}
className="flex flex-col items-start gap-8"
>
<GridLineVertical className="-left-4" offset="80px" />
{subarray.map((image, imageIndex) => (
<div className="relative" key={imageIndex + image}>
<GridLineHorizontal className="-top-4" offset="20px" />
<motion.img
whileHover={{
y: -10,
}}
transition={{
duration: 0.3,
ease: "easeInOut",
}}
key={imageIndex + image}
src={image}
alt={`Image ${imageIndex + 1}`}
className="aspect-[970/700] rounded-lg object-cover ring ring-gray-950/5 hover:shadow-2xl"
width={970}
height={700}
/>
</div>
))}
</motion.div>
))}
</div>
</div>
</div>
</div>
);
};
const GridLineHorizontal = ({
className,
offset,
}: {
className?: string;
offset?: string;
}) => {
return (
<div
style={
{
"--background": "#ffffff",
"--color": "rgba(0, 0, 0, 0.2)",
"--height": "1px",
"--width": "5px",
"--fade-stop": "90%",
"--offset": offset || "200px", //-100px if you want to keep the line inside
"--color-dark": "rgba(255, 255, 255, 0.2)",
maskComposite: "exclude",
} as React.CSSProperties
}
className={cn(
"absolute left-[calc(var(--offset)/2*-1)] h-[var(--height)] w-[calc(100%+var(--offset))]",
"bg-[linear-gradient(to_right,var(--color),var(--color)_50%,transparent_0,transparent)]",
"[background-size:var(--width)_var(--height)]",
"[mask:linear-gradient(to_left,var(--background)_var(--fade-stop),transparent),_linear-gradient(to_right,var(--background)_var(--fade-stop),transparent),_linear-gradient(black,black)]",
"[mask-composite:exclude]",
"z-30",
"dark:bg-[linear-gradient(to_right,var(--color-dark),var(--color-dark)_50%,transparent_0,transparent)]",
className,
)}
></div>
);
};
const GridLineVertical = ({
className,
offset,
}: {
className?: string;
offset?: string;
}) => {
return (
<div
style={
{
"--background": "#ffffff",
"--color": "rgba(0, 0, 0, 0.2)",
"--height": "5px",
"--width": "1px",
"--fade-stop": "90%",
"--offset": offset || "150px", //-100px if you want to keep the line inside
"--color-dark": "rgba(255, 255, 255, 0.2)",
maskComposite: "exclude",
} as React.CSSProperties
}
className={cn(
"absolute top-[calc(var(--offset)/2*-1)] h-[calc(100%+var(--offset))] w-[var(--width)]",
"bg-[linear-gradient(to_bottom,var(--color),var(--color)_50%,transparent_0,transparent)]",
"[background-size:var(--width)_var(--height)]",
"[mask:linear-gradient(to_top,var(--background)_var(--fade-stop),transparent),_linear-gradient(to_bottom,var(--background)_var(--fade-stop),transparent),_linear-gradient(black,black)]",
"[mask-composite:exclude]",
"z-30",
"dark:bg-[linear-gradient(to_bottom,var(--color-dark),var(--color-dark)_50%,transparent_0,transparent)]",
className,
)}
></div>
);
};

View File

@@ -0,0 +1,134 @@
"use client";
import React, { useRef, useEffect, useState } from "react";
import { motion } from "motion/react";
export const TextHoverEffect = ({
text,
duration,
}: {
text: string;
duration?: number;
automatic?: boolean;
}) => {
const svgRef = useRef<SVGSVGElement>(null);
const [cursor, setCursor] = useState({ x: 0, y: 0 });
const [hovered, setHovered] = useState(false);
const [maskPosition, setMaskPosition] = useState({ cx: "50%", cy: "50%" });
useEffect(() => {
if (svgRef.current && cursor.x !== null && cursor.y !== null) {
const svgRect = svgRef.current.getBoundingClientRect();
const cxPercentage = ((cursor.x - svgRect.left) / svgRect.width) * 100;
const cyPercentage = ((cursor.y - svgRect.top) / svgRect.height) * 100;
setMaskPosition({
cx: `${cxPercentage}%`,
cy: `${cyPercentage}%`,
});
}
}, [cursor]);
return (
<svg
ref={svgRef}
width="100%"
height="100%"
viewBox="0 0 300 100"
xmlns="http://www.w3.org/2000/svg"
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
onMouseMove={(e) => setCursor({ x: e.clientX, y: e.clientY })}
className="select-none"
>
<defs>
<linearGradient
id="textGradient"
gradientUnits="userSpaceOnUse"
cx="50%"
cy="50%"
r="25%"
>
{hovered && (
<>
<stop offset="0%" stopColor="#eab308" />
<stop offset="25%" stopColor="#ef4444" />
<stop offset="50%" stopColor="#3b82f6" />
<stop offset="75%" stopColor="#06b6d4" />
<stop offset="100%" stopColor="#8b5cf6" />
</>
)}
</linearGradient>
<motion.radialGradient
id="revealMask"
gradientUnits="userSpaceOnUse"
r="20%"
initial={{ cx: "50%", cy: "50%" }}
animate={maskPosition}
transition={{ duration: duration ?? 0, ease: "easeOut" }}
// example for a smoother animation below
// transition={{
// type: "spring",
// stiffness: 300,
// damping: 50,
// }}
>
<stop offset="0%" stopColor="white" />
<stop offset="100%" stopColor="black" />
</motion.radialGradient>
<mask id="textMask">
<rect
x="0"
y="0"
width="100%"
height="100%"
fill="url(#revealMask)"
/>
</mask>
</defs>
<text
x="50%"
y="50%"
textAnchor="middle"
dominantBaseline="middle"
strokeWidth="0.3"
className="fill-transparent stroke-neutral-200 font-[helvetica] text-7xl font-bold dark:stroke-neutral-800"
style={{ opacity: hovered ? 0.7 : 0 }}
>
{text}
</text>
<motion.text
x="50%"
y="50%"
textAnchor="middle"
dominantBaseline="middle"
strokeWidth="0.3"
className="fill-transparent stroke-neutral-200 font-[helvetica] text-7xl font-bold dark:stroke-neutral-800"
initial={{ strokeDashoffset: 1000, strokeDasharray: 1000 }}
animate={{
strokeDashoffset: 0,
strokeDasharray: 1000,
}}
transition={{
duration: 4,
ease: "easeInOut",
}}
>
{text}
</motion.text>
<text
x="50%"
y="50%"
textAnchor="middle"
dominantBaseline="middle"
stroke="url(#textGradient)"
strokeWidth="0.3"
mask="url(#textMask)"
className="fill-transparent font-[helvetica] text-7xl font-bold"
>
{text}
</text>
</svg>
);
};

View File

@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}