feat(landing): merge landing app into monorepo (#4881)

This commit is contained in:
DIYgod 2026-02-26 11:33:16 +08:00 committed by GitHub
parent b89290af8d
commit 5e26d1d33d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
236 changed files with 21510 additions and 625 deletions

View File

@ -51,6 +51,9 @@ jobs:
- name: Copy WASM file
run: cp node_modules/@resvg/resvg-wasm/index_bg.wasm apps/ssr/dist/worker/resvg.wasm
- name: Build Landing Worker
run: pnpm exec turbo run @follow/landing#cf:build
- name: Deploy to Cloudflare (dev)
if: github.ref == 'refs/heads/dev'
uses: cloudflare/wrangler-action@v3
@ -60,6 +63,15 @@ jobs:
workingDirectory: apps/ssr
command: deploy --env dev
- name: Deploy Landing to Cloudflare (dev)
if: github.ref == 'refs/heads/dev'
uses: cloudflare/wrangler-action@v3
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
workingDirectory: apps/landing
command: deploy --env dev
- name: Deploy to Cloudflare (prod)
if: github.ref == 'refs/heads/main'
uses: cloudflare/wrangler-action@v3
@ -68,3 +80,12 @@ jobs:
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
workingDirectory: apps/ssr
command: deploy
- name: Deploy Landing to Cloudflare (prod)
if: github.ref == 'refs/heads/main'
uses: cloudflare/wrangler-action@v3
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
workingDirectory: apps/landing
command: deploy

3
.gitignore vendored
View File

@ -1,6 +1,9 @@
node_modules
dist
out
.next
.open-next
next-env.d.ts
.DS_Store
*.log*
.env

View File

@ -1,6 +1,7 @@
pnpm-lock.yaml
CHANGELOG.md
.context
apps/external/postcss.config.cjs

View File

@ -75,6 +75,7 @@
"pathe": "2.0.3",
"react": "19.0.0",
"react-dom": "19.0.0",
"tailwindcss": "3.4.17",
"tailwindcss-content-visibility": "1.0.2",
"tailwindcss-multi": "0.4.6",
"tar": "7.5.7",

View File

@ -0,0 +1,5 @@
import { factory } from '@innei/prettier'
export default factory({
importSort: false,
})

View File

@ -0,0 +1,25 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/styles/tailwind.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"aliases": {
"components": "~/components",
"utils": "~/lib/utils",
"ui": "~/components/ui",
"lib": "~/lib",
"hooks": "~/hooks"
},
"registries": {
"@animate-ui": "https://animate-ui.com/r/{name}.json",
"@magicui": "https://magicui.design/r/{name}.json"
}
}

View File

@ -0,0 +1,80 @@
// @ts-check
import { defineConfig } from 'eslint-config-hyoban'
import recursiveSort from './plugins/eslint-recursive-sort.mjs'
export default defineConfig(
{
formatting: false,
lessOpinionated: true,
ignores: ['dist/**'],
preferESM: false,
},
{
settings: {
tailwindcss: {
whitelist: ['center'],
},
},
rules: {
'tailwindcss/classnames-order': 'off',
'tailwindcss/no-custom-classname': 'off',
'unicorn/prefer-math-trunc': 'off',
'unicorn/expiring-todo-comments': 0,
'@eslint-react/no-clone-element': 0,
'@eslint-react/hooks-extra/no-direct-set-state-in-use-effect': 0,
// NOTE: Disable this temporarily
'react-compiler/react-compiler': 0,
'no-restricted-syntax': 0,
'package-json/valid-name': 0,
// disable react compiler rules for now
'react-hooks/no-unused-directives': 'off',
'react-hooks/static-components': 'off',
'react-hooks/use-memo': 'off',
'react-hooks/component-hook-factories': 'off',
'react-hooks/preserve-manual-memoization': 'off',
'react-hooks/immutability': 'off',
'react-hooks/globals': 'off',
'react-hooks/refs': 'off',
'react-hooks/set-state-in-effect': 'off',
'react-hooks/error-boundaries': 'off',
'react-hooks/purity': 'off',
'react-hooks/set-state-in-render': 'off',
'react-hooks/unsupported-syntax': 'off',
'react-hooks/config': 'off',
'react-hooks/gating': 'off',
'no-restricted-globals': [
'error',
{
name: 'location',
message:
"Since you don't use the same router instance in electron and browser, you can't use the global location to get the route info. \n\n" +
'You can use `useLocaltion` or `getReadonlyRoute` to get the route info.',
},
],
},
},
{
files: ['**/*.tsx'],
rules: {
'@stylistic/jsx-self-closing-comp': 'error',
},
},
{
files: ['locales/**/*.json'],
plugins: {
'recursive-sort': recursiveSort,
},
rules: {
'recursive-sort/recursive-sort': 'error',
},
},
{
files: ['package.json'],
rules: {
'package-json/valid-name': 0,
},
},
)

42
apps/landing/global.d.ts vendored Normal file
View File

@ -0,0 +1,42 @@
/* eslint-disable @typescript-eslint/no-empty-object-type */
/* eslint-disable @typescript-eslint/method-signature-style */
import type { FC, PropsWithChildren } from 'react'
declare global {
export type NextErrorProps = {
reset(): void
error: Error
}
export type NextPageParams<P extends {}, Props = {}> = PropsWithChildren<
{
params: P
} & Props
>
export type Component<P = {}> = FC<ComponentType & P>
export type ComponentType<P = {}> = {
className?: string
} & PropsWithChildren &
P
// TODO should remove in next TypeScript version
interface Document {
startViewTransition(callback?: () => void | Promise<void>): ViewTransition
}
interface ViewTransition {
finished: Promise<void>
ready: Promise<void>
updateCallbackDone: () => void
skipTransition(): void
}
}
declare module 'react' {
export interface AriaAttributes {
'data-hide-print'?: boolean
'data-event'?: string
'data-testid'?: string
}
}

View File

@ -0,0 +1,63 @@
import NextBundleAnalyzer from '@next/bundle-analyzer'
import { codeInspectorPlugin } from 'code-inspector-plugin'
import { config } from 'dotenv'
import type { NextConfig } from 'next'
import createNextIntlPlugin from 'next-intl/plugin'
process.title = 'Folo Landing (NextJS)'
const env = config().parsed || {}
const isProd = process.env.NODE_ENV === 'production'
const withNextIntl = createNextIntlPlugin('./src/i18n/request.ts')
let nextConfig: NextConfig = {
reactStrictMode: false,
productionBrowserSourceMaps: true,
output: 'standalone',
reactCompiler: true,
assetPrefix: isProd ? env.ASSETPREFIX || undefined : undefined,
compiler: {
// reactRemoveProperties: { properties: ['^data-id$', '^data-(\\w+)-id$'] },
},
images: {
remotePatterns: [
{
protocol: 'https',
hostname: '**',
},
],
dangerouslyAllowSVG: true,
contentSecurityPolicy:
"default-src 'self'; script-src 'none'; sandbox; style-src 'unsafe-inline';",
},
async rewrites() {
return {
beforeFiles: [
{ source: '/atom.xml', destination: '/feed' },
{ source: '/sitemap.xml', destination: '/sitemap' },
{
source: '/.well-known/apple-app-site-association',
destination: '/apple-app-site-association',
},
],
}
},
turbopack: {
rules: codeInspectorPlugin({
bundler: 'turbopack',
hotKeys: ['altKey'],
}),
},
}
if (process.env.ANALYZE === 'true') {
nextConfig = NextBundleAnalyzer({
enabled: true,
})(nextConfig)
}
export default withNextIntl(nextConfig)

View File

@ -0,0 +1,3 @@
import { defineCloudflareConfig } from '@opennextjs/cloudflare'
export default defineCloudflareConfig()

92
apps/landing/package.json Normal file
View File

@ -0,0 +1,92 @@
{
"name": "@follow/landing",
"type": "module",
"version": "0.1.0",
"private": true,
"scripts": {
"build": "cross-env NODE_ENV=production next build",
"cf:build": "pnpm exec opennextjs-cloudflare build",
"cf:deploy": "pnpm exec wrangler deploy",
"cf:deploy:dev": "pnpm exec wrangler deploy --env dev",
"dev": "cross-env NODE_ENV=development next dev -p 4399",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@base-ui-components/react": "1.0.0-beta.4",
"@floating-ui/react-dom": "2.1.6",
"@radix-ui/react-accordion": "1.2.12",
"@splinetool/react-spline": "4.1.0",
"@tanstack/query-async-storage-persister": "5.90.7",
"@tanstack/react-query": "5.90.5",
"@tanstack/react-query-persist-client": "5.90.7",
"@types/js-cookie": "3.0.6",
"ai": "5.0.68",
"axios": "1.13.0",
"clsx": "2.1.1",
"dayjs": "1.11.18",
"es-toolkit": "1.41.0",
"foxact": "0.2.49",
"idb-keyval": "6.2.2",
"immer": "10.2.0",
"jojoo": "0.3.0",
"jotai": "2.15.0",
"js-cookie": "3.0.5",
"motion": "12.23.24",
"next": "16.0.11",
"next-intl": "4.4.0",
"next-themes": "0.4.6",
"ogl": "1.0.11",
"progressive-blur": "1.0.0",
"radix-ui": "1.4.3",
"re-resizable": "6.11.2",
"react": "19.0.0",
"react-dom": "19.0.0",
"react-error-boundary": "6.0.0",
"react-intersection-observer": "9.16.0",
"react-markdown": "10.1.0",
"react-resizable-layout": "0.7.3",
"rehype-stringify": "10.0.1",
"remark-emoji": "5.0.2",
"remark-parse": "11.0.0",
"remark-rehype": "11.1.2",
"rough-notation": "0.5.1",
"sonner": "2.0.7",
"tailwind-merge": "3.3.1",
"unified": "11.0.5",
"usehooks-ts": "3.1.1",
"vaul": "1.1.2"
},
"devDependencies": {
"@egoist/tailwindcss-icons": "1.9.0",
"@iconify-json/lucide": "1.2.71",
"@iconify-json/mingcute": "1.2.5",
"@iconify-json/simple-icons": "1.2.56",
"@iconify/tailwind": "1.2.0",
"@innei/prettier": "1.0.0",
"@next/bundle-analyzer": "16.0.11",
"@opennextjs/cloudflare": "1.16.5",
"@tailwindcss/postcss": "4.1.16",
"@tailwindcss/typography": "0.5.19",
"@tanstack/react-query-devtools": "5.90.2",
"@types/node": "24.9.1",
"@types/react": "19.1.17",
"@types/react-dom": "19.2.3",
"autoprefixer": "10.4.21",
"babel-plugin-react-compiler": "1.0.0",
"code-inspector-plugin": "1.2.10",
"cross-env": "10.1.0",
"dotenv": "17.2.3",
"eslint": "9.38.0",
"eslint-config-hyoban": "4.0.10",
"postcss": "8.5.6",
"prettier": "3.6.2",
"rimraf": "6.0.1",
"tailwind-scrollbar": "4.0.2",
"tailwind-variants": "3.1.1",
"tailwindcss": "4.1.16",
"tailwindcss-animate": "1.0.7",
"tailwindcss-safe-area": "1.1.0",
"typescript": "catalog:",
"wrangler": "4.67.0"
}
}

View File

@ -0,0 +1,60 @@
const sortObjectKeys = (obj) => {
if (typeof obj !== 'object' || obj === null) {
return obj
}
if (Array.isArray(obj)) {
return obj.map((element) => sortObjectKeys(element))
}
return Object.keys(obj)
.sort()
.reduce((acc, key) => {
acc[key] = sortObjectKeys(obj[key])
return acc
}, {})
}
/**
* @type {import("eslint").ESLint.Plugin}
*/
export default {
rules: {
'recursive-sort': {
meta: {
type: 'layout',
fixable: 'code',
},
create(context) {
return {
Program(node) {
if (context.getFilename().endsWith('.json')) {
const sourceCode = context.getSourceCode()
const text = sourceCode.getText()
try {
const json = JSON.parse(text)
const sortedJson = sortObjectKeys(json)
const sortedText = JSON.stringify(sortedJson, null, 2)
if (text.trim() !== sortedText.trim()) {
context.report({
node,
message: 'JSON keys are not sorted recursively',
fix(fixer) {
return fixer.replaceText(node, sortedText)
},
})
}
} catch (error) {
context.report({
node,
message: `Invalid JSON: ${error.message}`,
})
}
}
},
}
},
},
},
}

View File

@ -0,0 +1,5 @@
export default {
plugins: {
'@tailwindcss/postcss': {},
},
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><title>Folo</title><path fill="#ff5c00" d="M5.382 0h13.236A5.37 5.37 0 0 1 24 5.383v13.235A5.37 5.37 0 0 1 18.618 24H5.382A5.37 5.37 0 0 1 0 18.618V5.383A5.37 5.37 0 0 1 5.382.001Z"/><path fill="#fff" d="M13.269 17.31a1.813 1.813 0 1 0-3.626.002 1.813 1.813 0 0 0 3.626-.002m-.535-6.527H7.213a1.813 1.813 0 1 0 0 3.624h5.521a1.813 1.813 0 1 0 0-3.624m4.417-4.712H8.87a1.813 1.813 0 1 0 0 3.625h8.283a1.813 1.813 0 1 0 0-3.624z"/></svg>

After

Width:  |  Height:  |  Size: 495 B

View File

@ -0,0 +1,16 @@
{
"theme_color": "#ff5c00",
"name": "Folo",
"icons": [
{
"src": "/icon-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/icon-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
]
}

BIN
apps/landing/public/og.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

View File

@ -0,0 +1,23 @@
'use server'
export async function getGithubStar() {
return await fetch(`https://api.github.com/repos/RSSNext/Folo`, {
headers: {
Authorization: process.env.GITHUB_TOKEN
? `Bearer ${process.env.GITHUB_TOKEN}`
: '',
},
})
.then((res) => res.json())
.then((data) => {
if (data.message) {
throw new Error(data.message)
}
return data.stargazers_count as number
})
.catch((e) => {
console.error(e)
return -1
})
}

View File

@ -0,0 +1,6 @@
'use client'
import { init } from './init'
init()
export const ClientInit = () => null

View File

@ -0,0 +1,9 @@
'use client'
import { init } from './init'
init()
export const InitInClient = () => {
return null
}

View File

@ -0,0 +1,32 @@
import type { Metadata } from 'next'
import { headers } from 'next/headers'
import { getTranslations } from 'next-intl/server'
import { DownloadHero } from '~/components/widgets/download/DownloadHero'
import { PlatformDownloads } from '~/components/widgets/download/PlatformDownloads'
import { detectPlatform } from '~/lib/platform'
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>
}): Promise<Metadata> {
const { locale } = await params
const t = await getTranslations({ locale, namespace: 'download.metadata' })
return {
title: t('title'),
description: t('description'),
}
}
export default async function DownloadPage() {
const ua = (await headers()).get('user-agent')?.toLowerCase()
return (
<>
<DownloadHero />
<PlatformDownloads detectedOS={detectPlatform(ua || '')} />
</>
)
}

View File

@ -0,0 +1,26 @@
'use client'
import { useTranslations } from 'next-intl'
import { useEffect } from 'react'
import { NormalContainer } from '~/components/layout/container/Normal'
import { Button } from '~/components/ui/button'
export default ({ error, reset }: any) => {
const errorT = useTranslations('common.error')
useEffect(() => {
console.error(error)
// captureException(error)
}, [error])
return (
<NormalContainer>
<div className="center flex min-h-[calc(100vh-10rem)] flex-col">
<h2 className="mb-5">{errorT('title')}</h2>
<Button variant="primary" onClick={reset}>
{errorT('action')}
</Button>
</div>
</NormalContainer>
)
}

View File

@ -0,0 +1,146 @@
import type { Metadata, Viewport } from 'next'
import { NextIntlClientProvider } from 'next-intl'
import { getMessages, getTranslations } from 'next-intl/server'
import { HydrationEndDetector } from '~/components/common/HydrationEndDetector'
import { ScrollTop } from '~/components/common/ScrollTop'
import { Root } from '~/components/layout/root/Root'
import { LightRays } from '~/components/ui/light-rays'
import { LandingHeader } from '~/components/widgets/landing/Header'
import { siteInfo } from '~/constants/site'
import { locales } from '~/i18n/routing'
import { sansFont } from '~/lib/fonts'
import { Providers } from '../../providers/root'
import { ClientInit } from '../ClientInit'
import { init } from '../init'
import { InitInClient } from '../InitInClient'
init()
export function generateStaticParams() {
return locales.map((locale) => ({ locale }))
}
export async function generateMetadata(
params: Promise<{ locale: string }>,
): Promise<Metadata> {
const { locale } = await params
const t = await getTranslations({ locale, namespace: 'metadata' })
const title = t('title', { defaultValue: siteInfo.title })
const description = t('description', {
defaultValue: siteInfo.description,
})
const keywordsString = t('keywords', {
defaultValue: siteInfo.seo.keywords.join(', '),
})
const keywords = keywordsString.split(',').map((keyword) => keyword.trim())
return {
metadataBase: new URL(siteInfo.webUrl),
title: {
template: `%s · ${title}`,
default: `${title}${description}`,
},
description,
keywords,
icons: [
{
rel: 'icon',
url: '/favicon.ico',
},
],
alternates: {
canonical: '/',
},
robots: {
index: true,
follow: true,
googleBot: {
index: true,
follow: true,
'max-video-preview': -1,
'max-image-preview': 'large',
'max-snippet': -1,
},
},
openGraph: {
title: {
default: `${title}${description}`,
template: `%s · ${title}`,
},
description,
siteName: title,
locale: locale === 'en' ? 'en_US' : locale,
type: 'website',
url: siteInfo.webUrl,
images: [{ url: '/og.png' }],
},
twitter: {
card: 'summary_large_image',
title: `${title}${description}`,
description,
images: ['/og.png'],
},
}
}
export function generateViewport(): Viewport {
return {
themeColor: [
{ media: '(prefers-color-scheme: dark)', color: '#000212' },
{ media: '(prefers-color-scheme: light)', color: '#fafafa' },
],
initialScale: 1,
viewportFit: 'cover',
width: 'device-width',
maximumScale: 1,
minimumScale: 1,
userScalable: false,
}
}
export default async function LocaleLayout({
children,
params,
}: {
children: React.ReactNode
params: Promise<{ locale: string }>
}) {
const { locale } = await params
const messages = await getMessages()
return (
<>
<ClientInit />
<html lang={locale} suppressHydrationWarning>
<head>
<HydrationEndDetector />
</head>
<body className={`${sansFont.variable} m-0 h-full p-0 font-sans`}>
<NextIntlClientProvider locale={locale} messages={messages}>
<Providers>
<div data-theme>
<Root>
<LightRays
length="600px"
className="absolute inset-x-0 -top-6 h-[750px]"
color="#ff5c0010"
/>
<LandingHeader />
{children}
</Root>
</div>
</Providers>
</NextIntlClientProvider>
<ScrollTop />
<InitInClient />
</body>
</html>
</>
)
}

View File

@ -0,0 +1,19 @@
import * as React from 'react'
import { BuiltOpen } from '~/components/widgets/landing/BuiltOpen'
import { Features } from '~/components/widgets/landing/Features'
import { LandingHero } from '~/components/widgets/landing/Hero'
import { SocialProof } from '~/components/widgets/landing/SocialProof'
export default async function Home() {
return (
<>
<LandingHero />
<Features />
{/* <ViewsShowcase /> */}
{/* <Audience /> */}
<SocialProof />
<BuiltOpen />
</>
)
}

View File

@ -0,0 +1,36 @@
import type { Metadata } from 'next/types'
import { getMarkdownContent, MarkdownContent } from '~/components/ui/markdown'
import { siteInfo } from '~/constants/site'
export const metadata: Metadata = {
title: 'Privacy Policy',
description:
"Read Folo's privacy policy to understand how we collect, use, and protect your personal information when using our next-generation information browser.",
robots: {
index: true,
follow: true,
},
openGraph: {
...siteInfo.seo.openGraph,
title: 'Privacy Policy - Folo',
description:
"Read Folo's privacy policy to understand how we protect your data and privacy.",
url: `${siteInfo.webUrl}/privacy-policy`,
},
twitter: {
...siteInfo.seo.twitter,
title: 'Privacy Policy - Folo',
description:
"Read Folo's privacy policy to understand how we protect your data and privacy.",
},
alternates: {
canonical: '/privacy-policy',
},
}
export default async function PrivacyPolicyPage() {
const { content } = await getMarkdownContent('legal/privacy.md')
return <MarkdownContent content={content} />
}

View File

@ -0,0 +1,34 @@
import type { Metadata } from 'next/types'
import { getMarkdownContent, MarkdownContent } from '~/components/ui/markdown'
import { siteInfo } from '~/constants/site'
export const metadata: Metadata = {
title: 'Terms of Service',
description:
"Read Folo's terms of service to understand the rules and guidelines for using our next-generation information browser platform.",
robots: {
index: true,
follow: true,
},
openGraph: {
...siteInfo.seo.openGraph,
title: 'Terms of Service - Folo',
description: "Read Folo's terms of service and usage guidelines.",
url: `${siteInfo.webUrl}/terms-of-service`,
},
twitter: {
...siteInfo.seo.twitter,
title: 'Terms of Service - Folo',
description: "Read Folo's terms of service and usage guidelines.",
},
alternates: {
canonical: '/terms-of-service',
},
}
export default async function TermsOfServicePage() {
const { content } = await getMarkdownContent('legal/tos.md')
return <MarkdownContent content={content} />
}

View File

@ -0,0 +1,9 @@
import { APPLE_APP_SITE_ASSOCIATION } from '~/lib/apple-app-site-association'
export function GET() {
return Response.json(APPLE_APP_SITE_ASSOCIATION, {
headers: {
'Cache-Control': 'public, max-age=300',
},
})
}

View File

@ -0,0 +1 @@
@import '../styles/globals.css';

View File

@ -0,0 +1,7 @@
import 'dayjs/locale/zh-cn'
import dayjs from 'dayjs'
export const init = () => {
dayjs.locale('zh-cn')
}

View File

@ -0,0 +1,9 @@
import './globals.css'
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return children
}

View File

@ -0,0 +1,11 @@
import type { MetadataRoute } from 'next'
export default function robots(): MetadataRoute.Robots {
return {
rules: {
userAgent: '*',
allow: '/',
disallow: ['/login/'],
},
}
}

View File

@ -0,0 +1,6 @@
import { createAtomHooks } from 'jojoo/react'
import { atom } from 'jotai'
export const [, , useIsPrintMode, , , setIsPrintMode] = createAtomHooks(
atom(false),
)

View File

@ -0,0 +1,2 @@
export * from './css-media'
export * from './viewport'

View File

@ -0,0 +1,10 @@
import { atom, useAtomValue } from 'jotai'
import { jotaiStore } from '~/lib/store'
const isInteractiveAtom = atom(false)
export const useIsInteractive = () => useAtomValue(isInteractiveAtom)
export const getIsInteractive = () => jotaiStore.get(isInteractiveAtom)
export const setIsInteractive = (value: boolean) =>
jotaiStore.set(isInteractiveAtom, value)

View File

@ -0,0 +1,54 @@
import type { ExtractAtomValue } from 'jotai'
import { atom, useAtomValue } from 'jotai'
import { selectAtom } from 'jotai/utils'
import { useCallback } from 'react'
export const viewportAtom = atom({
/**
* 640px
*/
sm: false,
/**
* 768px
*/
md: false,
/**
* 1024px
*/
lg: false,
/**
* 1280px
*/
xl: false,
/**
* 1536px
*/
'2xl': false,
h: 0,
w: 0,
})
export const useViewport = <T>(
selector: (value: ExtractAtomValue<typeof viewportAtom>) => T,
): T =>
useAtomValue(
// @ts-ignore
selectAtom(
viewportAtom,
useCallback((atomValue) => selector(atomValue), []),
),
)
export const useIsMobile = () =>
useViewport(
useCallback(
(v: ExtractAtomValue<typeof viewportAtom>) =>
(v.sm || v.md || !v.sm) && !v.lg,
[],
),
)

View File

@ -0,0 +1,20 @@
import * as React from 'react'
export const Folo = ({
ref,
...props
}: React.SVGProps<SVGSVGElement> & {
ref?: React.Ref<SVGSVGElement | null>
}) => (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
{...props}
ref={ref}
>
<path
fill="currentColor"
d="M.899 16.997c-.567 0-.899-.358-.899-.994v-7.77c0-.637.36-.996 1.01-.996h4.34c.595 0 .927.29.927.788 0 .497-.332.774-.926.774H1.797v2.336H5.06c.595 0 .927.263.927.76 0 .512-.332.775-.927.775H1.797v3.332c0 .636-.318.996-.898.996m9.035.125c-2.101 0-3.553-1.52-3.553-3.664 0-2.17 1.438-3.705 3.553-3.705 2.13 0 3.567 1.534 3.567 3.705 0 2.143-1.452 3.664-3.567 3.664m0-1.493c1.134 0 1.825-.899 1.825-2.185 0-1.3-.691-2.198-1.825-2.198s-1.797.899-1.797 2.198c0 1.286.663 2.185 1.797 2.185m5.266 1.367c-.553 0-.857-.359-.857-.967V7.845c0-.608.304-.968.857-.968s.857.36.857.968v8.185c0 .608-.29.967-.857.967m5.234.125c-2.102 0-3.553-1.52-3.553-3.664 0-2.17 1.438-3.705 3.553-3.705 2.129 0 3.566 1.534 3.566 3.704 0 2.143-1.452 3.664-3.567 3.664m0-1.493c1.134 0 1.825-.899 1.825-2.185 0-1.3-.691-2.198-1.825-2.198s-1.797.899-1.797 2.198c0 1.286.663 2.185 1.797 2.185"
/>
</svg>
)

View File

@ -0,0 +1,29 @@
import * as React from 'react'
export const Logo = ({
ref,
...props
}: React.SVGProps<SVGSVGElement> & {
ref?: React.Ref<SVGSVGElement | null>
accentColor?: string
}) => {
const { accentColor, ...rest } = props
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
{...rest}
ref={ref}
>
<title>Folo</title>
<path
fill={accentColor || '#ff5c00'}
d="M5.382 0h13.236A5.37 5.37 0 0 1 24 5.383v13.235A5.37 5.37 0 0 1 18.618 24H5.382A5.37 5.37 0 0 1 0 18.618V5.383A5.37 5.37 0 0 1 5.382.001Z"
/>
<path
fill="#fff"
d="M13.269 17.31a1.813 1.813 0 1 0-3.626.002 1.813 1.813 0 0 0 3.626-.002m-.535-6.527H7.213a1.813 1.813 0 1 0 0 3.624h5.521a1.813 1.813 0 1 0 0-3.624m4.417-4.712H8.87a1.813 1.813 0 1 0 0 3.625h8.283a1.813 1.813 0 1 0 0-3.624z"
/>
</svg>
)
}

View File

@ -0,0 +1,9 @@
'use client'
import { useIsClient } from '~/hooks/common/use-is-client'
export const ClientOnly: Component = (props) => {
const isClient = useIsClient()
if (!isClient) return null
return <>{props.children}</>
}

View File

@ -0,0 +1,37 @@
'use client'
import type { FC, PropsWithChildren } from 'react'
import { ErrorBoundary as ErrorBoundaryLib } from 'react-error-boundary'
import { Button } from '../ui/button'
const FallbackComponent = () => {
return (
<div className="center flex w-full flex-col py-6">
Something went wrong.
<Button
onClick={() => {
window.location.reload()
}}
>
Reload Page
</Button>
</div>
)
}
export const ErrorBoundary: FC<PropsWithChildren> = ({ children }) => {
return (
<ErrorBoundaryLib
FallbackComponent={FallbackComponent}
onError={(e) => {
console.error(e)
// TODO sentry
// captureException(e)
}}
>
{children}
</ErrorBoundaryLib>
)
}

View File

@ -0,0 +1,81 @@
export default function GithubTrending() {
return (
<svg
className="max-h-full"
data-date-format="longDate"
height="55"
width="250"
viewBox="0 0 250 53"
xmlns="http://www.w3.org/2000/svg"
>
<rect fill="#111111" height="53" rx="10" x="0.5" y="0.5" />
<svg
fill="currentColor"
height="45"
width="48"
version="1.1"
viewBox="0 0 80 80"
x="0"
xmlns="http://www.w3.org/2000/svg"
y="8"
>
<path
fill="currentColor"
stroke="currentColor"
d="M70.71,40.31C75.74,44.3,80,37.86,80,37.86s-5.64-2.17-8.55,0.61c0.59-1.62,1.02-3.31,1.28-5.01 c4.08,2.16,6.44-2.95,6.44-2.95s-4.41-0.97-6.26,1.4c0.08-0.91,0.12-1.82,0.1-2.73c-0.01-0.36-0.02-0.73-0.05-1.09 c2.96-3.68-1.73-6.99-1.73-6.99s-2.14,5.09,0.98,7.09c0.02,0.33,0.03,0.66,0.03,1c0.01,0.76-0.03,1.52-0.1,2.27 c-0.85-2.69-4.91-3.69-4.91-3.69s-0.13,5.78,4.68,5.48c-0.28,1.69-0.73,3.35-1.34,4.95c-0.19-4.03-5.79-6.33-5.79-6.33 s-1.33,7.55,5.01,8.16c-0.38,0.8-0.8,1.57-1.25,2.32c-0.56,0.95-1.21,1.84-1.89,2.71c0.97-3.99-3.96-7.72-3.96-7.72 s-3.18,6.94,2.73,9.15c-0.38,0.43-0.8,0.81-1.2,1.21c-0.21,0.2-0.43,0.38-0.64,0.58l-0.32,0.29c-0.11,0.09-0.22,0.18-0.33,0.27 l-0.67,0.54l-0.7,0.51c-0.08,0.05-0.16,0.11-0.23,0.16c1.62-3.42-2.07-7.77-2.07-7.77s-4.21,5.55,0.49,8.78 c-1.34,0.79-2.74,1.45-4.2,1.98c1.91-2.59-0.23-6.89-0.23-6.89s-4.66,3.77-1.52,7.46c-1.15,0.33-2.33,0.57-3.51,0.74 c1.46-1.68,0.55-4.83,0.55-4.83s-3.7,2.03-2.18,5c-0.52,0.03-1.05,0.07-1.57,0.06c-0.29,0-0.57,0.01-0.86,0l-0.86-0.04 c-0.85-0.06-1.7-0.15-2.54-0.28l0.68-0.27l0.42-0.17l0.41-0.19l0.82-0.38c0,0,0.01,0,0.01,0c0.39-0.18,0.55-0.65,0.37-1.03 c-0.18-0.39-0.65-0.55-1.03-0.37l-0.04,0.02l-0.77,0.37l-0.39,0.18l-0.39,0.16l-0.79,0.33l-0.8,0.29l-0.4,0.14l-0.41,0.12L40,53.6 l-0.51-0.15l-0.41-0.12l-0.4-0.14l-0.8-0.29l-0.79-0.33l-0.39-0.16l-0.39-0.18l-0.77-0.37l-0.04-0.02c0,0,0,0-0.01,0 c-0.39-0.18-0.85-0.01-1.03,0.38c-0.18,0.39-0.01,0.85,0.38,1.03l0.82,0.38l0.41,0.19l0.42,0.17l0.68,0.27 c-0.84,0.14-1.69,0.22-2.54,0.28l-0.86,0.04c-0.29,0.01-0.57,0-0.86,0c-0.53,0.01-1.05-0.03-1.57-0.06c1.51-2.98-2.18-5-2.18-5 s-0.92,3.15,0.55,4.83c-1.19-0.16-2.36-0.41-3.51-0.74c3.15-3.7-1.52-7.46-1.52-7.46s-2.14,4.31-0.23,6.89 c-1.46-0.53-2.86-1.19-4.2-1.98c4.7-3.22,0.49-8.78,0.49-8.78s-3.69,4.34-2.07,7.77c-0.08-0.05-0.16-0.1-0.23-0.16l-0.7-0.51 l-0.67-0.54c-0.11-0.09-0.23-0.18-0.33-0.27l-0.32-0.29c-0.21-0.19-0.43-0.38-0.64-0.58c-0.4-0.4-0.82-0.79-1.2-1.21 c5.91-2.21,2.73-9.15,2.73-9.15s-4.93,3.73-3.96,7.72c-0.68-0.86-1.33-1.76-1.89-2.71c-0.46-0.75-0.87-1.53-1.25-2.32 c6.33-0.61,5.01-8.16,5.01-8.16s-5.6,2.31-5.79,6.33c-0.61-1.6-1.06-3.26-1.34-4.95c4.81,0.3,4.68-5.48,4.68-5.48 s-4.05,0.99-4.91,3.69c-0.07-0.76-0.1-1.51-0.1-2.27c0-0.33,0.01-0.66,0.03-1c3.11-2.01,0.98-7.09,0.98-7.09s-4.69,3.31-1.73,6.99 C7,28.46,6.99,28.82,6.98,29.18c-0.02,0.91,0.01,1.82,0.1,2.73c-1.84-2.38-6.26-1.4-6.26-1.4s2.37,5.11,6.44,2.95 c0.26,1.71,0.69,3.39,1.28,5.01C5.64,35.69,0,37.86,0,37.86s4.26,6.43,9.29,2.45c0.39,0.87,0.83,1.72,1.31,2.54 c0.47,0.83,1.01,1.63,1.58,2.4C8.71,43.7,4.11,47,4.11,47s5.7,5.1,9.56,0.08c0.04,0.04,0.07,0.08,0.11,0.12 c0.39,0.45,0.82,0.87,1.24,1.3c0.21,0.21,0.44,0.41,0.66,0.61l0.33,0.3c0.11,0.1,0.23,0.19,0.34,0.29l0.69,0.57l0.23,0.17 c-3.34-0.34-6.58,3.29-6.58,3.29s6.19,3.47,8.69-1.83c1.2,0.75,2.47,1.41,3.78,1.96c-2.76,0.6-4.62,4.13-4.62,4.13 s5.89,1.62,6.98-3.26c1.03,0.32,2.07,0.58,3.13,0.78c-1.63,0.99-2.39,3.38-2.39,3.38s4.31,0.39,4.61-3.07 c0.07,0.01,0.14,0.02,0.21,0.02c0.6,0.04,1.2,0.1,1.8,0.09c0.3,0,0.6,0.02,0.9,0.01l0.9-0.03c1.2-0.07,2.41-0.18,3.59-0.42 l0.45-0.08c0.15-0.03,0.29-0.07,0.44-0.1L40,55.13l0.81,0.19c0.15,0.03,0.29,0.07,0.44,0.1l0.45,0.08c1.18,0.23,2.39,0.35,3.59,0.42 l0.9,0.03c0.3,0.01,0.6-0.01,0.9-0.01c0.6,0,1.2-0.06,1.8-0.09c0.07-0.01,0.14-0.02,0.21-0.02c0.31,3.45,4.61,3.07,4.61,3.07 s-0.76-2.39-2.39-3.38c1.06-0.2,2.11-0.46,3.13-0.78c1.09,4.88,6.98,3.26,6.98,3.26s-1.86-3.52-4.62-4.13 c1.31-0.55,2.57-1.21,3.78-1.96c2.5,5.3,8.69,1.83,8.69,1.83s-3.24-3.63-6.58-3.29l0.23-0.17l0.69-0.57 c0.11-0.1,0.23-0.19,0.34-0.29l0.33-0.3c0.22-0.2,0.45-0.4,0.66-0.61c0.42-0.43,0.85-0.84,1.24-1.3c0.03-0.04,0.07-0.08,0.11-0.12 C70.19,52.1,75.89,47,75.89,47s-4.6-3.31-8.08-1.75c0.57-0.77,1.11-1.56,1.58-2.4C69.88,42.03,70.31,41.18,70.71,40.31z"
/>
</svg>
<foreignObject
height="36"
width="48"
x="0"
y="9"
style={{
color: 'currentColor',
fontSize: '18px',
fontWeight: 'bold',
letterSpacing: 0,
lineHeight: 1.5,
textAlign: 'center',
}}
>
<div>1</div>
</foreignObject>
<foreignObject
height="17"
width="196"
x="54"
y="10"
style={{
color: 'currentColor',
fontSize: '9px',
fontWeight: 'normal',
letterSpacing: 0,
lineHeight: 1.5,
textAlign: 'left',
}}
>
<div>GITHUB TRENDING</div>
</foreignObject>
<foreignObject
style={{
color: 'currentColor',
fontWeight: 'bold',
fontSize: '12px',
letterSpacing: 0,
lineHeight: 1.5,
textAlign: 'left',
}}
height="31"
width="196"
x="54"
y="24"
>
<div>#1 Repository Of The Month</div>
</foreignObject>
</svg>
)
}

View File

@ -0,0 +1,23 @@
'use client'
import { atom } from 'jotai'
import { useEffect } from 'react'
import { jotaiStore } from '~/lib/store'
const hydrateEndAtom = atom(false)
/**
* To skip page transition when first load, improve LCP
*/
export const HydrationEndDetector = () => {
useEffect(() => {
// waiting for hydration end and animation end
setTimeout(() => {
jotaiStore.set(hydrateEndAtom, true)
}, 2000)
}, [])
return null
}
export const isHydrationEnded = () => jotaiStore.get(hydrateEndAtom)

View File

@ -0,0 +1,35 @@
'use client'
import type { FC, PropsWithChildren } from 'react'
import * as React from 'react'
import { useEffect } from 'react'
import type { IntersectionOptions } from 'react-intersection-observer'
import { useInView } from 'react-intersection-observer'
export type LazyLoadProps = {
offset?: number
placeholder?: React.ReactNode
} & IntersectionOptions
export const LazyLoad: FC<PropsWithChildren & LazyLoadProps> = (props) => {
const { placeholder = null, offset = 0, ...rest } = props
const { ref, inView } = useInView({
triggerOnce: true,
rootMargin: `${offset || 0}px`,
...rest,
})
const [isLoaded, setIsLoaded] = React.useState(false)
useEffect(() => {
if (inView) {
setIsLoaded(true)
}
}, [inView])
return (
<>
{!isLoaded && (
<span data-hide-print data-testid="lazyload-indicator" ref={ref} />
)}
{!inView ? placeholder : props.children}
</>
)
}

View File

@ -0,0 +1,581 @@
// https://reactbits.dev/backgrounds/light-rays
'use client'
import { Mesh, Program, Renderer, Triangle } from 'ogl'
import { useEffect, useRef, useState } from 'react'
import { useIsDark } from '~/hooks/common/use-is-dark'
import { clsxm } from '~/lib/cn'
export type RaysOrigin =
| 'top-center'
| 'top-left'
| 'top-right'
| 'right'
| 'left'
| 'bottom-center'
| 'bottom-right'
| 'bottom-left'
interface LightRaysProps {
raysOrigin?: RaysOrigin
raysColor?: string
raysSpeed?: number
lightSpread?: number
rayLength?: number
pulsating?: boolean
fadeDistance?: number
saturation?: number
followMouse?: boolean
mouseInfluence?: number
noiseAmount?: number
distortion?: number
// Edge feather amounts in CSS pixels
edgeFadeLeft?: number
edgeFadeRight?: number
edgeFadeTop?: number
edgeFadeBottom?: number
className?: string
}
const DEFAULT_COLOR = '#ffffff'
const hexToRgb = (hex: string): [number, number, number] => {
const m = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)
return m
? [
Number.parseInt(m[1], 16) / 255,
Number.parseInt(m[2], 16) / 255,
Number.parseInt(m[3], 16) / 255,
]
: [1, 1, 1]
}
const getAnchorAndDir = (
origin: RaysOrigin,
w: number,
h: number,
): { anchor: [number, number]; dir: [number, number] } => {
const outside = 0.2
switch (origin) {
case 'top-left': {
return { anchor: [0, -outside * h], dir: [0, 1] }
}
case 'top-right': {
return { anchor: [w, -outside * h], dir: [0, 1] }
}
case 'left': {
return { anchor: [-outside * w, 0.5 * h], dir: [1, 0] }
}
case 'right': {
return { anchor: [(1 + outside) * w, 0.5 * h], dir: [-1, 0] }
}
case 'bottom-left': {
return { anchor: [0, (1 + outside) * h], dir: [0, -1] }
}
case 'bottom-center': {
return { anchor: [0.5 * w, (1 + outside) * h], dir: [0, -1] }
}
case 'bottom-right': {
return { anchor: [w, (1 + outside) * h], dir: [0, -1] }
}
default: {
// "top-center"
return { anchor: [0.5 * w, -outside * h], dir: [0, 1] }
}
}
}
export const LightRays: React.FC<LightRaysProps> = ({
raysOrigin = 'top-center',
raysColor = DEFAULT_COLOR,
raysSpeed = 1,
lightSpread = 1,
rayLength = 2,
pulsating = false,
fadeDistance = 1,
saturation = 1,
followMouse = true,
mouseInfluence = 0.1,
noiseAmount = 0,
distortion = 0,
edgeFadeLeft = 40,
edgeFadeRight = 40,
edgeFadeTop = 20,
edgeFadeBottom = 160,
className = '',
}) => {
const containerRef = useRef<HTMLDivElement>(null)
const uniformsRef = useRef<any>(null)
const rendererRef = useRef<Renderer | null>(null)
const mouseRef = useRef({ x: 0.5, y: 0.5 })
const smoothMouseRef = useRef({ x: 0.5, y: 0.5 })
const animationIdRef = useRef<number | null>(null)
const meshRef = useRef<any>(null)
const cleanupFunctionRef = useRef<(() => void) | null>(null)
const [isVisible, setIsVisible] = useState(false)
const observerRef = useRef<IntersectionObserver | null>(null)
const isDark = useIsDark()
const effectiveRaysColor =
!isDark && raysColor === DEFAULT_COLOR ? '#ff5c00' : raysColor
useEffect(() => {
if (!containerRef.current) return
observerRef.current = new IntersectionObserver(
(entries) => {
const entry = entries[0]
setIsVisible(entry.isIntersecting)
},
{ threshold: 0.1 },
)
observerRef.current.observe(containerRef.current)
return () => {
if (observerRef.current) {
observerRef.current.disconnect()
observerRef.current = null
}
}
}, [])
useEffect(() => {
if (!isVisible || !containerRef.current) return
if (cleanupFunctionRef.current) {
cleanupFunctionRef.current()
cleanupFunctionRef.current = null
}
const initializeWebGL = async () => {
if (!containerRef.current) return
// Ensure layout is ready without lingering timers
await Promise.resolve()
if (!containerRef.current) return
const renderer = new Renderer({
dpr: Math.min(window.devicePixelRatio, 2),
alpha: true,
})
rendererRef.current = renderer
const { gl } = renderer
gl.canvas.style.width = '100%'
gl.canvas.style.height = '100%'
gl.canvas.style.backgroundColor = 'transparent'
gl.canvas.style.mixBlendMode = isDark ? 'normal' : 'plus-lighter'
while (containerRef.current.firstChild) {
containerRef.current.firstChild.remove()
}
containerRef.current.append(gl.canvas)
const vert = `
attribute vec2 position;
varying vec2 vUv;
void main() {
vUv = position * 0.5 + 0.5;
gl_Position = vec4(position, 0.0, 1.0);
}`
const frag = `precision highp float;
uniform float iTime;
uniform vec2 iResolution;
uniform vec2 rayPos;
uniform vec2 rayDir;
uniform vec3 raysColor;
uniform float raysSpeed;
uniform float lightSpread;
uniform float rayLength;
uniform float pulsating;
uniform float fadeDistance;
uniform float saturation;
uniform vec2 mousePos;
uniform float mouseInfluence;
uniform float noiseAmount;
uniform float distortion;
uniform float globalOpacity;
uniform float alphaCutoff;
// Per-edge feathering (in device pixels)
uniform float edgeFadeLeft;
uniform float edgeFadeRight;
uniform float edgeFadeTop;
uniform float edgeFadeBottom;
varying vec2 vUv;
float noise(vec2 st) {
return fract(sin(dot(st.xy, vec2(12.9898,78.233))) * 43758.5453123);
}
float rayStrength(vec2 raySource, vec2 rayRefDirection, vec2 coord,
float seedA, float seedB, float speed) {
vec2 sourceToCoord = coord - raySource;
vec2 dirNorm = normalize(sourceToCoord);
float cosAngle = dot(dirNorm, rayRefDirection);
float distortedAngle = cosAngle + distortion * sin(iTime * 2.0 + length(sourceToCoord) * 0.01) * 0.2;
float spreadFactor = pow(max(distortedAngle, 0.0), 1.0 / max(lightSpread, 0.001));
float distance = length(sourceToCoord);
float maxDistance = iResolution.x * rayLength;
float lengthFalloff = clamp((maxDistance - distance) / maxDistance, 0.0, 1.0);
// Allow rays to fully fade to 0 to avoid gray/dark wash in light themes
float fadeFalloff = clamp((iResolution.x * fadeDistance - distance) / (iResolution.x * fadeDistance), 0.0, 1.0);
float pulse = pulsating > 0.5 ? (0.8 + 0.2 * sin(iTime * speed * 3.0)) : 1.0;
float baseStrength = clamp(
(0.45 + 0.15 * sin(distortedAngle * seedA + iTime * speed)) +
(0.3 + 0.2 * cos(-distortedAngle * seedB + iTime * speed)),
0.0, 1.0
);
return baseStrength * lengthFalloff * fadeFalloff * spreadFactor * pulse;
}
void mainImage(out vec4 fragColor, in vec2 fragCoord) {
vec2 coord = vec2(fragCoord.x, iResolution.y - fragCoord.y);
vec2 finalRayDir = rayDir;
if (mouseInfluence > 0.0) {
vec2 mouseScreenPos = mousePos * iResolution.xy;
vec2 mouseDirection = normalize(mouseScreenPos - rayPos);
finalRayDir = normalize(mix(rayDir, mouseDirection, mouseInfluence));
}
vec4 rays1 = vec4(1.0) *
rayStrength(rayPos, finalRayDir, coord, 36.2214, 21.11349,
1.5 * raysSpeed);
vec4 rays2 = vec4(1.0) *
rayStrength(rayPos, finalRayDir, coord, 22.3991, 18.0234,
1.1 * raysSpeed);
fragColor = vec4(1.0);
float intensity = (rays1.a * 0.5 + rays2.a * 0.4);
fragColor.rgb = vec3(intensity);
fragColor.a = intensity;
if (noiseAmount > 0.0) {
float n = noise(coord * 0.01 + iTime * 0.1);
fragColor.rgb *= (1.0 - noiseAmount + noiseAmount * n);
}
// Subtle vertical brightening without color channel darkening
float brightness = 1.0 - (coord.y / iResolution.y);
float brightnessScale = mix(0.7, 1.0, brightness);
fragColor.rgb *= brightnessScale;
// Remove grayscale mixing which could introduce unintended dark hues
// Apply rays color as a tint while preserving brightness
fragColor.rgb = mix(fragColor.rgb, fragColor.rgb * raysColor, 0.8);
// Thin out low-intensity areas to avoid gray wash; then apply global opacity
float a = fragColor.a;
a = smoothstep(alphaCutoff, alphaCutoff + 0.2, a);
fragColor.a = a * globalOpacity;
// Per-edge feather to avoid harsh clipping at container boundaries
float fadeL = smoothstep(0.0, edgeFadeLeft, fragCoord.x);
float fadeR = smoothstep(0.0, edgeFadeRight, iResolution.x - fragCoord.x);
float fadeT = smoothstep(0.0, edgeFadeTop, fragCoord.y);
float fadeB = smoothstep(0.0, edgeFadeBottom, iResolution.y - fragCoord.y);
fragColor.a *= fadeL * fadeR * fadeT * fadeB;
// Keep additive look; no extra premultiplying which can dim highlights under plus-lighter
}
void main() {
vec4 color;
mainImage(color, gl_FragCoord.xy);
gl_FragColor = color;
}`
const baseOpacity = isDark ? 0.55 : 0.22
const baseCutoff = isDark ? 0.08 : 0.18
const uniforms = {
iTime: { value: 0 },
iResolution: { value: [1, 1] },
rayPos: { value: [0, 0] },
rayDir: { value: [0, 1] },
raysColor: { value: hexToRgb(effectiveRaysColor) },
raysSpeed: { value: raysSpeed },
lightSpread: { value: lightSpread },
rayLength: { value: rayLength },
pulsating: { value: pulsating ? 1 : 0 },
fadeDistance: { value: fadeDistance },
saturation: { value: saturation },
mousePos: { value: [0.5, 0.5] },
mouseInfluence: { value: mouseInfluence },
noiseAmount: { value: noiseAmount },
distortion: { value: distortion },
globalOpacity: { value: baseOpacity },
alphaCutoff: { value: baseCutoff },
edgeFadeLeft: { value: 40 },
edgeFadeRight: { value: 40 },
edgeFadeTop: { value: 20 },
edgeFadeBottom: { value: 160 },
}
uniformsRef.current = uniforms
const geometry = new Triangle(gl)
const program = new Program(gl, {
vertex: vert,
fragment: frag,
uniforms,
})
const mesh = new Mesh(gl, { geometry, program })
meshRef.current = mesh
const updatePlacement = () => {
if (!containerRef.current || !renderer) return
renderer.dpr = Math.min(window.devicePixelRatio, 2)
const { clientWidth: wCSS, clientHeight: hCSS } = containerRef.current
renderer.setSize(wCSS, hCSS)
const { dpr } = renderer
const w = wCSS * dpr
const h = hCSS * dpr
uniforms.iResolution.value = [w, h]
// Scale edge feather from CSS pixels to device pixels
uniforms.edgeFadeLeft.value = edgeFadeLeft * dpr
uniforms.edgeFadeRight.value = edgeFadeRight * dpr
uniforms.edgeFadeTop.value = edgeFadeTop * dpr
uniforms.edgeFadeBottom.value = edgeFadeBottom * dpr
const { anchor, dir } = getAnchorAndDir(raysOrigin, w, h)
uniforms.rayPos.value = anchor
uniforms.rayDir.value = dir
}
const loop = (t: number) => {
if (!rendererRef.current || !uniformsRef.current || !meshRef.current) {
return
}
uniforms.iTime.value = t * 0.001
if (followMouse && mouseInfluence > 0) {
const smoothing = 0.92
smoothMouseRef.current.x =
smoothMouseRef.current.x * smoothing +
mouseRef.current.x * (1 - smoothing)
smoothMouseRef.current.y =
smoothMouseRef.current.y * smoothing +
mouseRef.current.y * (1 - smoothing)
uniforms.mousePos.value = [
smoothMouseRef.current.x,
smoothMouseRef.current.y,
]
}
try {
renderer.render({ scene: mesh })
animationIdRef.current = requestAnimationFrame(loop)
} catch (error) {
console.warn('WebGL rendering error:', error)
return
}
}
updatePlacement()
animationIdRef.current = requestAnimationFrame(loop)
cleanupFunctionRef.current = () => {
if (animationIdRef.current) {
cancelAnimationFrame(animationIdRef.current)
animationIdRef.current = null
}
if (renderer) {
try {
const { canvas } = renderer.gl
const loseContextExt =
renderer.gl.getExtension('WEBGL_lose_context')
if (loseContextExt) {
loseContextExt.loseContext()
}
if (canvas && canvas.parentNode) {
canvas.remove()
}
} catch (error) {
console.warn('Error during WebGL cleanup:', error)
}
}
rendererRef.current = null
uniformsRef.current = null
meshRef.current = null
}
}
initializeWebGL()
return () => {
if (cleanupFunctionRef.current) {
cleanupFunctionRef.current()
cleanupFunctionRef.current = null
}
}
}, [
isVisible,
raysOrigin,
raysColor,
effectiveRaysColor,
isDark,
raysSpeed,
lightSpread,
rayLength,
pulsating,
fadeDistance,
saturation,
followMouse,
mouseInfluence,
noiseAmount,
distortion,
edgeFadeLeft,
edgeFadeRight,
edgeFadeTop,
edgeFadeBottom,
])
useEffect(() => {
const renderer = rendererRef.current
const uniforms = uniformsRef.current
if (!isVisible || !renderer || !containerRef.current || !uniforms) return
const handleResize = () => {
if (!containerRef.current) return
renderer.dpr = Math.min(window.devicePixelRatio, 2)
const { clientWidth: wCSS, clientHeight: hCSS } = containerRef.current
renderer.setSize(wCSS, hCSS)
const { dpr } = renderer
const w = wCSS * dpr
const h = hCSS * dpr
uniforms.iResolution.value = [w, h]
// Update edge feather values on resize (scale with DPR)
uniforms.edgeFadeLeft.value = edgeFadeLeft * dpr
uniforms.edgeFadeRight.value = edgeFadeRight * dpr
uniforms.edgeFadeTop.value = edgeFadeTop * dpr
uniforms.edgeFadeBottom.value = edgeFadeBottom * dpr
const { anchor, dir } = getAnchorAndDir(raysOrigin, w, h)
uniforms.rayPos.value = anchor
uniforms.rayDir.value = dir
}
window.addEventListener('resize', handleResize)
return () => window.removeEventListener('resize', handleResize)
}, [
isVisible,
raysOrigin,
edgeFadeLeft,
edgeFadeRight,
edgeFadeTop,
edgeFadeBottom,
])
useEffect(() => {
if (!uniformsRef.current || !containerRef.current || !rendererRef.current)
return
const u = uniformsRef.current
const renderer = rendererRef.current
u.raysColor.value = hexToRgb(effectiveRaysColor)
u.raysSpeed.value = raysSpeed
u.lightSpread.value = lightSpread
u.rayLength.value = rayLength
u.pulsating.value = pulsating ? 1 : 0
u.fadeDistance.value = fadeDistance
u.saturation.value = saturation
u.mouseInfluence.value = mouseInfluence
u.noiseAmount.value = noiseAmount
u.distortion.value = distortion
u.globalOpacity.value = isDark ? 0.55 : 0.22
u.alphaCutoff.value = isDark ? 0.08 : 0.18
// Edge feather uniforms (scale with DPR)
u.edgeFadeLeft.value = edgeFadeLeft * renderer.dpr
u.edgeFadeRight.value = edgeFadeRight * renderer.dpr
u.edgeFadeTop.value = edgeFadeTop * renderer.dpr
u.edgeFadeBottom.value = edgeFadeBottom * renderer.dpr
const { clientWidth: wCSS, clientHeight: hCSS } = containerRef.current
const { dpr } = renderer
const { anchor, dir } = getAnchorAndDir(raysOrigin, wCSS * dpr, hCSS * dpr)
u.rayPos.value = anchor
u.rayDir.value = dir
}, [
raysColor,
isDark,
effectiveRaysColor,
raysSpeed,
lightSpread,
raysOrigin,
rayLength,
pulsating,
fadeDistance,
saturation,
mouseInfluence,
noiseAmount,
distortion,
edgeFadeLeft,
edgeFadeRight,
edgeFadeTop,
edgeFadeBottom,
])
useEffect(() => {
if (!rendererRef.current) return
const { gl } = rendererRef.current
gl.canvas.style.mixBlendMode = isDark ? 'normal' : 'plus-lighter'
}, [isDark])
useEffect(() => {
const handleMouseMove = (e: MouseEvent) => {
if (!containerRef.current || !rendererRef.current) return
const rect = containerRef.current.getBoundingClientRect()
const x = (e.clientX - rect.left) / rect.width
const y = (e.clientY - rect.top) / rect.height
mouseRef.current = { x, y }
}
if (followMouse) {
window.addEventListener('mousemove', handleMouseMove)
return () => window.removeEventListener('mousemove', handleMouseMove)
}
}, [followMouse])
return (
<div
ref={containerRef}
className={clsxm(
'w-full h-full pointer-events-none overflow-hidden relative',
className,
)}
/>
)
}

View File

@ -0,0 +1,12 @@
'use client'
import type { JSX } from 'react'
import * as React from 'react'
export const ProviderComposer: Component<{
contexts: JSX.Element[]
}> = ({ contexts, children }) => {
return contexts.reduceRight((kids: any, parent: any) => {
return React.cloneElement(parent, { children: kids })
}, children)
}

View File

@ -0,0 +1,8 @@
'use client'
import type { HydrationBoundaryProps } from '@tanstack/react-query'
import { HydrationBoundary as RQHydrate } from '@tanstack/react-query'
export function QueryHydrate(props: HydrationBoundaryProps) {
return <RQHydrate {...props} />
}

View File

@ -0,0 +1,18 @@
'use client'
import { usePathname } from 'next/navigation'
import { memo, useEffect } from 'react'
import { isDev } from '~/lib/env'
import { springScrollToTop } from '~/lib/scroller'
export const ScrollTop = memo(() => {
const pathname = usePathname()
useEffect(() => {
if (isDev) return
springScrollToTop()
}, [pathname])
return null
})
ScrollTop.displayName = 'ScrollTop'

View File

@ -0,0 +1,13 @@
import type { FC, PropsWithChildren } from 'react'
import { useIsClientTransition } from '~/hooks/common/use-is-client'
export const withNoSSR = <P,>(
Component: FC<PropsWithChildren<P>>,
): FC<PropsWithChildren<P>> => {
return function NoSSRWrapper(props: PropsWithChildren<P>) {
const isClient = useIsClientTransition()
if (!isClient) return null
return <Component {...props} />
}
}

View File

@ -0,0 +1,17 @@
import { clsxm } from '~/lib/helper'
export const NormalContainer: Component = (props) => {
const { children, className } = props
return (
<div
className={clsxm(
'mx-auto mt-14 max-w-3xl px-2 lg:mt-[80px] lg:px-0 2xl:max-w-4xl',
'[&_header.prose]:mb-[80px]',
className,
)}
>
{children}
</div>
)
}

View File

@ -0,0 +1,7 @@
export const Content: Component = ({ children }) => {
return (
<main className="relative pb-24 z-[1] h-fit px-4 pt-[4.5rem] md:px-0">
{children}
</main>
)
}

View File

@ -0,0 +1 @@
export * from './Content'

View File

@ -0,0 +1,184 @@
'use client'
import Link from 'next/link'
import { Logo } from '~/components/brand/Logo'
import { cx, focusRing } from '~/lib/cn'
type LinkItem = { label: string; href: string; external?: boolean }
const productLinks: LinkItem[] = [
{ label: 'Web App', href: 'https://app.folo.is', external: true },
{ label: 'Download', href: '/download', external: false },
]
const communityLinks: LinkItem[] = [
{ label: 'Discord', href: 'https://discord.gg/followapp', external: true },
{
label: 'GitHub',
href: 'https://github.com/RSSNext/Folo',
external: true,
},
{ label: 'Twitter', href: 'https://x.com/folo_is', external: true },
]
const resourceLinks: LinkItem[] = [
{ label: 'For Researchers', href: '#researchers' },
{ label: 'For Builders', href: '#builders' },
{ label: 'For Creators', href: '#creators' },
{ label: 'For Investors', href: '#investors' },
// { label: 'API', href: '#api' },
]
const legalLinks: LinkItem[] = [
{ label: 'Privacy Policy', href: 'privacy-policy' },
{ label: 'Terms of Service', href: 'terms-of-service' },
// { label: 'Security', href: '#security' },
// { label: 'Cookie', href: '#cookie' },
]
const BrandBlock = () => (
<div className="max-w-md">
<Link href="/" className={cx('inline-flex items-center gap-3', focusRing)}>
<Logo className="size-10 shrink-0" aria-hidden />
<span className="text-2xl font-semibold tracking-tight">Folo</span>
</Link>
<p className="mt-6 text-base leading-relaxed text-text-secondary">
The next-generation information browser powered by AI. Transform how you
discover, consume, and interact with content across the web.
</p>
<p className="mt-4 text-sm text-text-tertiary">
Deep reading, contextual AI, and noise-free content discovery all in one
beautiful interface.
</p>
</div>
)
const Social = () => (
<div className="mt-6 flex items-center gap-6">
<Link
href="https://github.com/RSSNext/Folo"
target="_blank"
rel="noreferrer noopener"
className={cx(
'inline-flex text-text-secondary transition-colors hover:text-text',
focusRing,
)}
aria-label="GitHub"
>
<i className="i-simple-icons-github size-5" aria-hidden />
</Link>
<Link
href="https://x.com/folo_is"
target="_blank"
rel="noreferrer noopener"
className={cx(
'inline-flex text-text-secondary transition-colors hover:text-text',
focusRing,
)}
aria-label="Twitter / X"
>
<i className="i-simple-icons-x size-5" aria-hidden />
</Link>
<Link
href="https://discord.gg/followapp"
target="_blank"
rel="noreferrer noopener"
className={cx(
'inline-flex text-text-secondary transition-colors hover:text-text',
focusRing,
)}
aria-label="Discord"
>
<i className="i-simple-icons-discord size-5" aria-hidden />
</Link>
</div>
)
function LinkColumn({ title, links }: { title: string; links: LinkItem[] }) {
return (
<div>
<h3 className="text-base font-semibold text-text">{title}</h3>
<ul className="mt-5 space-y-3">
{links.map((link) => (
<li key={link.label}>
<Link
href={link.href}
target={link.external ? '_blank' : undefined}
rel={link.external ? 'noreferrer noopener' : undefined}
className={cx(
'text-base text-text-secondary transition-colors hover:text-text',
focusRing,
)}
>
{link.label}
</Link>
</li>
))}
</ul>
</div>
)
}
/** Props for Footer component */
export interface FooterProps {
className?: string
}
export const Footer: Component<FooterProps> = ({ className }) => {
const year = new Date().getFullYear()
return (
<footer
className={cx(
'relative border-t border-border/80 bg-background',
className,
)}
role="contentinfo"
>
<div className="relative mx-auto w-full max-w-[var(--container-max-width-2xl)] px-6 py-16 lg:px-8 lg:py-20">
{/* Main footer content */}
<div className="grid grid-cols-1 gap-12 lg:grid-cols-12 lg:gap-8">
{/* Brand section with stats */}
<div className="lg:col-span-5">
<BrandBlock />
<Social />
</div>
{/* Navigation columns */}
<div className="grid grid-cols-2 gap-8 sm:grid-cols-3 lg:col-span-7">
<LinkColumn title="Product" links={productLinks} />
<LinkColumn title="Community" links={communityLinks} />
<LinkColumn title="Resources" links={resourceLinks} />
</div>
</div>
{/* Bottom bar */}
<div className="mt-16 flex flex-col items-center justify-between gap-4 border-t border-border/60 pt-8 sm:flex-row">
<p className="text-sm text-text-secondary">
© {year} Folo. All rights reserved.
</p>
<div className="flex flex-wrap items-center gap-6">
{legalLinks.map((link) => (
<Link
key={link.label}
href={link.href}
className={cx(
'text-sm text-text-secondary transition-colors hover:text-text',
focusRing,
)}
>
{link.label}
</Link>
))}
</div>
</div>
</div>
</footer>
)
}
Footer.displayName = 'Footer'

View File

@ -0,0 +1,13 @@
import { Footer } from '~/components/layout/footer/Footer'
import { Content } from '../content/Content'
export const Root: Component = ({ children }) => {
return (
<>
<header />
<Content>{children}</Content>
<Footer />
</>
)
}

View File

@ -0,0 +1,23 @@
import { createElement, lazy, Suspense } from 'react'
import { ErrorBoundary } from 'react-error-boundary'
import { cn } from '~/lib/cn'
const AISplineLoader = lazy(() =>
import('./AISplineLoader').then((res) => ({ default: res.AISplineLoader })),
)
export const AISpline = ({ className }: { className?: string }) => {
return createElement(
ErrorBoundary,
null,
createElement(
Suspense,
{
fallback: createElement('div', {
className: cn('size-20 mx-auto', className),
}),
},
createElement(AISplineLoader, { className }),
),
)
}

View File

@ -0,0 +1,129 @@
import Spline from '@splinetool/react-spline'
import { useCallback, useRef } from 'react'
import { cn } from '~/lib/cn'
// TODO: use folo cdn
const resolvedAIIconUrl =
'https://prod.spline.design/n2hjp93nWReC-512/scene.splinecode'
const clamp = (value: number, min: number, max: number) =>
Math.min(Math.max(value, min), max)
export const AISplineLoader = ({ className }: { className?: string }) => {
const containerRef = useRef<HTMLDivElement>(null)
const headRef = useRef<any>(null)
// Angle conversion function: degrees to radians
const degToRad = (degrees: number) => degrees * (Math.PI / 180)
// Calculate the angle the head should look at
const calculateHeadRotation = useCallback(
(mouseX: number, mouseY: number, containerRect: DOMRect) => {
const containerCenterX = containerRect.left + containerRect.width / 2
const containerCenterY = containerRect.top + containerRect.height / 2
// Calculate mouse position relative to container center (-1 to 1)
const relativeX = (mouseX - containerCenterX) / (window.innerWidth / 2)
const relativeY = (mouseY - containerCenterY) / (window.innerHeight / 2)
// Clamp range
const clampedX = Math.max(-1, Math.min(1, relativeX))
const clampedY = Math.max(-1, Math.min(1, relativeY))
// Calculate head rotation angle based on relative position
// Y-axis rotation (left-right): -70 to 70 degrees
const headRotationY = clampedX * 20
// X-axis rotation (up-down): -60 to 60 degrees
const headRotationX = clampedY * 20
return {
x: degToRad(headRotationX),
y: degToRad(headRotationY),
}
},
[],
)
const handleLoad = useCallback(
(app: any) => {
const head = app.findObjectByName('Folo Character_V3')
if (!head) {
console.warn('Cannot find Head or Body object')
return
}
headRef.current = head
const onMove = (e: MouseEvent) => {
if (!containerRef.current || !headRef.current) return
const containerRect = containerRef.current.getBoundingClientRect()
// Calculate head rotation
const headRotation = calculateHeadRotation(
e.clientX,
e.clientY,
containerRect,
)
headRef.current.rotation.x = clamp(headRotation.x, -0.5, 0.5)
headRef.current.rotation.y = clamp(headRotation.y, -0.5, 0.5)
}
// Reset to default position when mouse leaves
const onMouseLeave = () => {
if (!headRef.current) return
// Smooth transition back to default position
const resetAnimation = () => {
if (!headRef.current) return
const currentHeadX = headRef.current.rotation.x
const currentHeadY = headRef.current.rotation.y
// Simple linear interpolation to smoothly return rotation to 0
headRef.current.rotation.x = currentHeadX * 0.9
headRef.current.rotation.y = currentHeadY * 0.9
// Continue animation if not fully returned to 0
if (Math.abs(currentHeadX) > 0.01 || Math.abs(currentHeadY) > 0.01) {
requestAnimationFrame(resetAnimation)
} else {
// Complete reset to 0
headRef.current.rotation.x = 0
headRef.current.rotation.y = 0
}
}
resetAnimation()
}
const onClick = () => {
app.emitEvent('mouseDown', 'Folo Character_V3')
}
onClick()
window.addEventListener('pointermove', onMove)
document.addEventListener('mouseleave', onMouseLeave)
window.addEventListener('click', onClick)
return () => {
window.removeEventListener('pointermove', onMove)
document.removeEventListener('mouseleave', onMouseLeave)
window.removeEventListener('click', onClick)
}
},
[calculateHeadRotation],
)
return (
<div ref={containerRef} className={cn('size-20', className)}>
<Spline
scene={resolvedAIIconUrl}
onLoad={handleLoad}
className="size-full"
/>
</div>
)
}

View File

@ -0,0 +1,251 @@
'use client'
/**
* @see https://www.zhangxinxu.com/wordpress/2024/06/css-transition-behavior/
* @see https://www.zhangxinxu.com/wordpress/2024/11/css-calc-interpolate-size/
*/
import * as AccordionPrimitive from '@radix-ui/react-accordion'
import type { FC } from 'react'
import * as React from 'react'
import { createContext, use, useState } from 'react'
import { cn } from '~/lib/cn'
interface CollapseContextValue {
openStates: Record<string, boolean>
setOpenState: (id: string, open: boolean) => void
}
const CollapseContext = createContext<CollapseContextValue | null>(null)
const useCollapseContext = () => {
const ctx = use(CollapseContext)
if (!ctx) {
throw new Error('useCollapseContext must be used within CollapseGroup')
}
return ctx
}
interface CollapseGroupProps {
defaultOpenId?: string
onOpenChange?: (state: Record<string, boolean>) => void
children: React.ReactNode
}
export const CollapseCssGroup: FC<CollapseGroupProps> = ({
children,
defaultOpenId,
onOpenChange,
}) => {
const [openStates, setOpenStates] = useState<Record<string, boolean>>(() => {
return defaultOpenId ? { [defaultOpenId]: true } : {}
})
const setOpenState = React.useCallback(
(id: string, open: boolean) => {
setOpenStates((prev) => {
const newState = { ...prev, [id]: open }
onOpenChange?.(newState)
return newState
})
},
[onOpenChange],
)
const ctxValue = React.useMemo<CollapseContextValue>(
() => ({
openStates,
setOpenState,
}),
[openStates, setOpenState],
)
return <CollapseContext value={ctxValue}>{children}</CollapseContext>
}
interface CollapseProps {
title: React.ReactNode
hideArrow?: boolean
defaultOpen?: boolean
isOpened?: boolean // For controlled usage
collapseId?: string
onOpenChange?: (isOpened: boolean) => void
contentClassName?: string
className?: string
children: React.ReactNode
innerClassName?: string
}
export const CollapseCss: FC<CollapseProps> = ({
title,
hideArrow,
defaultOpen = false,
isOpened: controlledIsOpened,
collapseId,
onOpenChange,
contentClassName,
className,
innerClassName,
children,
}) => {
const reactId = React.useId()
const id = collapseId ?? reactId
const { openStates, setOpenState } = useCollapseContext()
// Use controlled value if provided, otherwise use context state or defaultOpen
const isOpened = controlledIsOpened ?? openStates[id] ?? defaultOpen
const handleToggle = React.useCallback(() => {
const newOpened = !isOpened
// Only update context state if not controlled
if (controlledIsOpened === undefined) {
setOpenState(id, newOpened)
}
onOpenChange?.(newOpened)
}, [id, isOpened, controlledIsOpened, setOpenState, onOpenChange])
return (
<div
className={cn('flex flex-col', className)}
data-state={isOpened ? 'open' : 'hidden'}
>
<div
className="relative flex w-full cursor-pointer items-center justify-between"
onClick={controlledIsOpened === undefined ? handleToggle : undefined}
>
<span className="w-0 shrink grow truncate">{title}</span>
{!hideArrow && (
<div className="text-text-secondary mr-4 inline-flex shrink-0 items-center">
<i
className={cn(
'i-mingcute-down-line transition-transform duration-300 ease-in-out',
isOpened ? 'rotate-180' : '',
)}
/>
</div>
)}
</div>
<CollapseCssContent
isOpened={isOpened}
className={contentClassName}
innerClassName={innerClassName}
>
{children}
</CollapseCssContent>
</div>
)
}
interface CollapseContentProps {
isOpened: boolean
className?: string
children: React.ReactNode
innerClassName?: string
}
const CollapseCssContent: FC<CollapseContentProps> = ({
isOpened,
className,
children,
innerClassName,
}) => {
const contentRef = React.useRef<HTMLDivElement>(null)
return (
<div
ref={contentRef}
className={cn(
'overflow-hidden [transition-behavior:allow-discrete] [interpolate-size:allow-keywords]',
'transition-[height,opacity,display] duration-300 ease-in-out',
'[@starting-style]:h-0 [@starting-style]:opacity-0',
className,
isOpened
? 'block h-[calc-size(auto)] opacity-100'
: 'hidden h-0 opacity-0',
)}
data-state={isOpened ? 'open' : 'closed'}
>
<div
className={cn(
'transition-transform duration-300 ease-in-out',
'[@starting-style]:translate-y-[-8px]',
isOpened ? 'translate-y-0' : 'translate-y-[-8px]',
innerClassName,
)}
>
{children}
</div>
</div>
)
}
// Radix Accordion Components
const AccordionRoot = ({
ref,
className,
...props
}: React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Root> & {
ref?: React.RefObject<React.ElementRef<typeof AccordionPrimitive.Root> | null>
}) => <AccordionPrimitive.Root ref={ref} className={cn(className)} {...props} />
AccordionRoot.displayName = 'Accordion'
const AccordionItem = ({
ref,
className,
...props
}: React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item> & {
ref?: React.RefObject<React.ElementRef<typeof AccordionPrimitive.Item> | null>
}) => <AccordionPrimitive.Item ref={ref} className={cn(className)} {...props} />
AccordionItem.displayName = 'AccordionItem'
const AccordionTrigger = ({
ref,
className,
children,
...props
}: React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger> & {
ref?: React.RefObject<React.ElementRef<
typeof AccordionPrimitive.Trigger
> | null>
}) => (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
ref={ref}
className={cn(
'flex flex-1 items-center justify-between text-left font-medium transition-all [&[data-state=open]>i]:rotate-180',
className,
)}
{...props}
>
{children}
<i className="i-mingcute-down-line size-4 shrink-0 text-text-secondary transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
)
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName
const AccordionContent = ({
ref,
className,
children,
...props
}: React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content> & {
ref?: React.RefObject<React.ElementRef<
typeof AccordionPrimitive.Content
> | null>
}) => (
<AccordionPrimitive.Content
ref={ref}
className="overflow-hidden text-sm data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
{...props}
>
<div className={cn('pt-0', className)}>{children}</div>
</AccordionPrimitive.Content>
)
AccordionContent.displayName = AccordionPrimitive.Content.displayName
export const Accordion = Object.assign(AccordionRoot, {
Item: AccordionItem,
Trigger: AccordionTrigger,
Content: AccordionContent,
})

View File

@ -0,0 +1,106 @@
import type { MotionStyle, Transition } from 'motion/react'
import { m as motion } from 'motion/react'
import { cn } from '~/lib/cn'
interface BorderBeamProps {
/**
* The size of the border beam.
*/
size?: number
/**
* The duration of the border beam.
*/
duration?: number
/**
* The delay of the border beam.
*/
delay?: number
/**
* The color of the border beam from.
*/
colorFrom?: string
/**
* The color of the border beam to.
*/
colorTo?: string
/**
* The motion transition of the border beam.
*/
transition?: Transition
/**
* The class name of the border beam.
*/
className?: string
/**
* The style of the border beam.
*/
style?: React.CSSProperties
/**
* Whether to reverse the animation direction.
*/
reverse?: boolean
/**
* The initial offset position (0-100).
*/
initialOffset?: number
/**
* The border width of the beam.
*/
borderWidth?: number
}
export const BorderBeam = ({
className,
size = 50,
delay = 0,
duration = 6,
colorFrom = '#ffaa40',
colorTo = '#9c40ff',
transition,
style,
reverse = false,
initialOffset = 0,
borderWidth = 1,
}: BorderBeamProps) => {
return (
<div
className="pointer-events-none absolute inset-0 rounded-[inherit] border-(length:--border-beam-width) border-transparent [mask-image:linear-gradient(transparent,transparent),linear-gradient(#000,#000)] [mask-composite:intersect] [mask-clip:padding-box,border-box]"
style={
{
'--border-beam-width': `${borderWidth}px`,
} as React.CSSProperties
}
>
<motion.div
className={cn(
'absolute aspect-square',
'bg-gradient-to-l from-[var(--color-from)] via-[var(--color-to)] to-transparent',
className,
)}
style={
{
width: size,
offsetPath: `rect(0 auto auto 0 round ${size}px)`,
'--color-from': colorFrom,
'--color-to': colorTo,
...style,
} as MotionStyle
}
initial={{ offsetDistance: `${initialOffset}%` }}
animate={{
offsetDistance: reverse
? [`${100 - initialOffset}%`, `${-initialOffset}%`]
: [`${initialOffset}%`, `${100 + initialOffset}%`],
}}
transition={{
repeat: Infinity,
ease: 'linear',
duration,
delay: -delay,
...transition,
}}
/>
</div>
)
}

View File

@ -0,0 +1,154 @@
'use client'
// Tremor Button [v0.2.0]
import { Slot as RadixSlot } from 'radix-ui'
import * as React from 'react'
import type { VariantProps } from 'tailwind-variants'
import { tv } from 'tailwind-variants'
import { cx, focusRing } from '~/lib/cn'
const { Slot } = RadixSlot
const buttonVariants = tv({
base: [
// base - pill shape, spacing and glass-friendly shadow
'relative box-content inline-flex pointer-events-auto no-drag-region items-center justify-center whitespace-nowrap rounded-full border text-center font-medium shadow-sm transition-all duration-200 ease-out',
// disabled
'disabled:pointer-events-none disabled:shadow-none disabled:text-disabled-text',
// focus
focusRing,
],
variants: {
variant: {
primary: [
// border
'!border-transparent',
// text color
'text-accent-foreground',
// gradient accent
'bg-gradient-to-r from-[var(--color-accent)] to-[var(--color-accent-60)] border-0',
// hover state
'hover:brightness-110',
// active state
'active:scale-[0.98]',
// disabled
'disabled:bg-disabled-control',
],
secondary: [
// glass button
'border-border text-text bg-material-medium/60 backdrop-blur',
// hover / active
'hover:bg-fill-secondary shadow-none hover:shadow-sm active:bg-fill-tertiary active:scale-[0.98]',
// disabled
'disabled:bg-fill disabled:text-disabled-text disabled:border-border disabled:shadow-none',
],
light: [
// base
'shadow-none',
// border
'border-transparent',
// text color
'text-text',
// background color
'bg-fill',
// hover color
'hover:bg-fill-tertiary hover:shadow-sm',
// active state
'active:bg-fill-quaternary active:scale-[0.98]',
// disabled
'disabled:bg-fill disabled:text-disabled-text',
],
ghost: [
// base
'shadow-none',
// border
'border-transparent',
// text color
'text-text-secondary',
// hover color
'bg-transparent hover:bg-fill/80 hover:text-text',
// active state
'active:bg-fill active:scale-[0.98]',
// disabled
'disabled:text-disabled-text',
],
destructive: [
// text color
'text-background',
// border
'border-transparent',
// background color
'bg-red',
// hover color
'hover:bg-red/90 hover:shadow-md',
// active state
'active:bg-red/80 active:scale-[0.98]',
// disabled
'disabled:bg-red/50 disabled:text-background/70',
],
},
size: {
sm: ['px-4 py-1.5 text-sm rounded-full'],
md: ['px-4 py-2 text-sm rounded-full'],
lg: ['px-5 py-2.5 text-base rounded-full'],
},
},
defaultVariants: {
variant: 'primary',
size: 'md',
},
})
interface ButtonProps
extends
React.ComponentPropsWithoutRef<'button'>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
isLoading?: boolean
loadingText?: string
size?: 'sm' | 'md' | 'lg'
variant?: 'primary' | 'secondary' | 'light' | 'ghost' | 'destructive'
}
const Button = ({
ref: forwardedRef,
asChild,
isLoading = false,
loadingText,
className,
disabled,
variant,
size = 'md',
children,
...props
}: ButtonProps & { ref?: React.RefObject<HTMLButtonElement | null> }) => {
const Component = asChild ? Slot : 'button'
return (
<Component
ref={forwardedRef}
className={cx(buttonVariants({ variant, size }), className)}
disabled={disabled || isLoading}
tremor-id="tremor-raw"
{...props}
>
{isLoading ? (
<span className="pointer-events-none flex shrink-0 items-center justify-center gap-1.5">
<i
className="i-mingcute-loading-3-line size-4 shrink-0 animate-spin"
aria-hidden="true"
/>
{loadingText ?? children}
</span>
) : (
children
)}
</Component>
)
}
Button.displayName = 'Button'
export { Button, type ButtonProps }

View File

@ -0,0 +1,27 @@
'use client'
import type { HTMLMotionProps } from 'motion/react'
import { m } from 'motion/react'
export const MotionButtonBase = ({
ref,
children,
...rest
}: HTMLMotionProps<'button'> & {
ref?: React.Ref<HTMLButtonElement>
}) => {
return (
<m.button
initial={true}
whileFocus={{ scale: 1.02 }}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.95 }}
{...rest}
ref={ref}
>
{children}
</m.button>
)
}
MotionButtonBase.displayName = 'MotionButtonBase'

View File

@ -0,0 +1,2 @@
export * from './Button'
export * from './MotionButton'

View File

@ -0,0 +1,174 @@
'use client'
import type { HTMLMotionProps } from 'motion/react'
import { m as motion } from 'motion/react'
import { Checkbox as CheckboxPrimitive } from 'radix-ui'
import * as React from 'react'
import type { VariantProps } from 'tailwind-variants'
import { tv } from 'tailwind-variants'
import { clsxm } from '~/lib/cn'
const checkboxStyles = tv({
base: [
'peer flex items-center justify-center shrink-0 rounded-sm bg-gray9/10 transition-colors duration-500',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2',
'disabled:cursor-not-allowed disabled:opacity-50',
'data-[state=checked]:bg-accent data-[state=checked]:text-white',
],
variants: {
size: {
sm: 'size-4',
md: 'size-5',
},
},
defaultVariants: {
size: 'md',
},
})
const checkboxIndicatorStyles = tv({
variants: {
size: {
sm: 'size-2.5',
md: 'size-3.5',
},
},
defaultVariants: {
size: 'md',
},
})
type CheckboxProps = React.ComponentProps<typeof CheckboxPrimitive.Root> &
HTMLMotionProps<'button'> &
VariantProps<typeof checkboxStyles> & {
indeterminate?: boolean
}
function Checkbox({
className,
onCheckedChange,
indeterminate,
size = 'md',
...props
}: CheckboxProps) {
const [isChecked, setIsChecked] = React.useState(
props?.checked ?? props?.defaultChecked ?? false,
)
React.useEffect(() => {
if (props?.checked !== undefined) setIsChecked(props.checked)
}, [props?.checked])
// Determine the actual state including indeterminate
const checkboxState = indeterminate
? 'indeterminate'
: isChecked
? 'checked'
: 'unchecked'
const handleCheckedChange = React.useCallback(
(checked: boolean) => {
setIsChecked(checked)
onCheckedChange?.(checked)
},
[onCheckedChange],
)
return (
<CheckboxPrimitive.Root
{...props}
onCheckedChange={handleCheckedChange}
asChild
>
<motion.button
data-slot="checkbox"
className={clsxm(
checkboxStyles({ size }),
indeterminate && 'bg-accent text-white',
className,
)}
whileTap={{ scale: 0.95 }}
whileHover={{ scale: 1.05 }}
{...props}
>
<CheckboxPrimitive.Indicator forceMount asChild>
<motion.svg
data-slot="checkbox-indicator"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth="3.5"
stroke="currentColor"
className={checkboxIndicatorStyles({ size })}
initial={checkboxState}
animate={checkboxState}
>
{/* Checkmark path */}
<motion.path
strokeLinecap="round"
strokeLinejoin="round"
d="M4.5 12.75l6 6 9-13.5"
variants={{
checked: {
pathLength: 1,
opacity: 1,
transition: {
duration: 0.2,
delay: 0.2,
},
},
unchecked: {
pathLength: 0,
opacity: 0,
transition: {
duration: 0.2,
},
},
indeterminate: {
pathLength: 0,
opacity: 0,
transition: {
duration: 0.1,
},
},
}}
/>
{/* Indeterminate line */}
<motion.path
strokeLinecap="round"
strokeLinejoin="round"
d="M6 12h12"
variants={{
checked: {
pathLength: 0,
opacity: 0,
transition: {
duration: 0.1,
},
},
unchecked: {
pathLength: 0,
opacity: 0,
transition: {
duration: 0.1,
},
},
indeterminate: {
pathLength: 1,
opacity: 1,
transition: {
duration: 0.2,
delay: 0.1,
},
},
}}
/>
</motion.svg>
</CheckboxPrimitive.Indicator>
</motion.button>
</CheckboxPrimitive.Root>
)
}
export { Checkbox, type CheckboxProps }

View File

@ -0,0 +1 @@
export * from './Checkbox'

View File

@ -0,0 +1,188 @@
/**
* @see https://www.zhangxinxu.com/wordpress/2024/06/css-transition-behavior/
* @see https://www.zhangxinxu.com/wordpress/2024/11/css-calc-interpolate-size/
*/
import type { FC } from 'react'
import * as React from 'react'
import { createContext, use, useState } from 'react'
import { cn } from '~/lib/cn'
interface CollapseContextValue {
openStates: Record<string, boolean>
setOpenState: (id: string, open: boolean) => void
}
const CollapseContext = createContext<CollapseContextValue | null>(null)
const useCollapseContext = () => {
const ctx = use(CollapseContext)
if (!ctx) {
throw new Error('useCollapseContext must be used within CollapseGroup')
}
return ctx
}
interface CollapseGroupProps {
defaultOpenId?: string
onOpenChange?: (state: Record<string, boolean>) => void
children: React.ReactNode
}
export const CollapseCssGroup: FC<CollapseGroupProps> = ({
children,
defaultOpenId,
onOpenChange,
}) => {
const [openStates, setOpenStates] = useState<Record<string, boolean>>(() => {
return defaultOpenId ? { [defaultOpenId]: true } : {}
})
const setOpenState = React.useCallback(
(id: string, open: boolean) => {
setOpenStates((prev) => {
const newState = { ...prev, [id]: open }
onOpenChange?.(newState)
return newState
})
},
[onOpenChange],
)
const ctxValue = React.useMemo<CollapseContextValue>(
() => ({
openStates,
setOpenState,
}),
[openStates, setOpenState],
)
return <CollapseContext value={ctxValue}>{children}</CollapseContext>
}
interface CollapseProps {
title: React.ReactNode
hideArrow?: boolean
defaultOpen?: boolean
isOpened?: boolean // For controlled usage
collapseId?: string
onOpenChange?: (isOpened: boolean) => void
contentClassName?: string
className?: string
children: React.ReactNode
innerClassName?: string
ref?: React.Ref<CollapseCssRef>
}
export interface CollapseCssRef {
setIsOpened: (isOpened: boolean) => void
}
export const CollapseCss: FC<CollapseProps> = ({
title,
hideArrow,
defaultOpen = false,
isOpened: controlledIsOpened,
collapseId,
onOpenChange,
contentClassName,
className,
innerClassName,
children,
ref,
}) => {
const reactId = React.useId()
const id = collapseId ?? reactId
const { openStates, setOpenState } = useCollapseContext()
// Use controlled value if provided, otherwise use context state or defaultOpen
const isOpened = controlledIsOpened ?? openStates[id] ?? defaultOpen
const handleToggle = React.useCallback(() => {
const newOpened = !isOpened
// Only update context state if not controlled
if (controlledIsOpened === undefined) {
setOpenState(id, newOpened)
}
onOpenChange?.(newOpened)
}, [id, isOpened, controlledIsOpened, setOpenState, onOpenChange])
React.useImperativeHandle(ref, () => ({
setIsOpened: (isOpened: boolean) => {
setOpenState(id, isOpened)
},
}))
return (
<div
className={cn('flex flex-col', className)}
data-state={isOpened ? 'open' : 'hidden'}
>
<div
className="relative flex w-full cursor-pointer items-center justify-between"
onClick={controlledIsOpened === undefined ? handleToggle : undefined}
>
<span className="w-0 shrink grow truncate">{title}</span>
{!hideArrow && (
<div className="text-text-secondary inline-flex shrink-0 items-center">
<i
className={cn(
'i-mingcute-down-line transition-transform duration-300 ease-in-out',
isOpened ? 'rotate-180' : '',
)}
/>
</div>
)}
</div>
<CollapseCssContent
isOpened={isOpened}
className={contentClassName}
innerClassName={innerClassName}
>
{children}
</CollapseCssContent>
</div>
)
}
interface CollapseContentProps {
isOpened: boolean
className?: string
children: React.ReactNode
innerClassName?: string
}
const CollapseCssContent: FC<CollapseContentProps> = ({
isOpened,
className,
children,
innerClassName,
}) => {
const contentRef = React.useRef<HTMLDivElement>(null)
return (
<div
ref={contentRef}
className={cn(
'overflow-hidden [interpolate-size:allow-keywords] [transition-behavior:allow-discrete]',
'transition-[height,opacity,display] duration-300 ease-in-out',
'[@starting-style]:h-0 [@starting-style]:opacity-0',
className,
isOpened
? 'block h-[calc-size(auto)] opacity-100'
: 'hidden h-0 opacity-0',
)}
data-state={isOpened ? 'open' : 'closed'}
>
<div
className={cn(
'transition-transform duration-300 ease-in-out',
'[@starting-style]:translate-y-[-8px]',
isOpened ? 'translate-y-0' : 'translate-y-[-8px]',
innerClassName,
)}
>
{children}
</div>
</div>
)
}

View File

@ -0,0 +1,15 @@
import type { PrimitiveAtom } from 'jotai'
import { createContext, use } from 'react'
export interface CollapseContextValue {
currentOpenCollapseIdAtom: PrimitiveAtom<string | null>
collapseGroupItemStateAtom: PrimitiveAtom<Record<string, boolean>>
}
export const CollaspeContext = createContext<CollapseContextValue>(null!)
export const useCollapseContext = () => {
const ctx = use(CollaspeContext)
if (!ctx) {
throw new Error('CollapseContext not found')
}
return ctx
}

View File

@ -0,0 +1 @@
export * from './CollapseCss'

View File

@ -0,0 +1,267 @@
'use client'
import type { HTMLMotionProps, Transition } from 'motion/react'
import { AnimatePresence, m as motion } from 'motion/react'
import { Dialog as DialogPrimitive } from 'radix-ui'
import * as React from 'react'
import { cn } from '~/lib/cn'
import { stopPropagation } from '~/lib/dom'
type DialogContextType = {
isOpen: boolean
}
const DialogContext = React.createContext<DialogContextType | undefined>(
undefined,
)
const useDialog = (): DialogContextType => {
const context = React.use(DialogContext)
if (!context) {
throw new Error('useDialog must be used within a Dialog')
}
return context
}
type DialogProps = React.ComponentProps<typeof DialogPrimitive.Root>
function Dialog({ children, ...props }: DialogProps) {
const [isOpen, setIsOpen] = React.useState(
props?.open ?? props?.defaultOpen ?? false,
)
React.useEffect(() => {
if (props?.open !== undefined) setIsOpen(props.open)
}, [props?.open])
const handleOpenChange = React.useCallback(
(open: boolean) => {
setIsOpen(open)
props.onOpenChange?.(open)
},
[props],
)
return (
<DialogContext value={React.useMemo(() => ({ isOpen }), [isOpen])}>
<DialogPrimitive.Root
data-slot="dialog"
{...props}
onOpenChange={handleOpenChange}
>
{children}
</DialogPrimitive.Root>
</DialogContext>
)
}
type DialogTriggerProps = React.ComponentProps<typeof DialogPrimitive.Trigger>
function DialogTrigger(props: DialogTriggerProps) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
type DialogPortalProps = React.ComponentProps<typeof DialogPrimitive.Portal>
function DialogPortal(props: DialogPortalProps) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
type DialogCloseProps = React.ComponentProps<typeof DialogPrimitive.Close>
function DialogClose(props: DialogCloseProps) {
return (
<DialogPrimitive.Close
data-slot="dialog-close"
{...props}
className={cn('contents', props.className)}
/>
)
}
type DialogOverlayProps = React.ComponentProps<typeof DialogPrimitive.Overlay>
function DialogOverlay({ className, ...props }: DialogOverlayProps) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
'bg-material-medium data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50',
className,
)}
{...props}
/>
)
}
export type DialogContentProps = React.ComponentProps<
typeof DialogPrimitive.Content
> &
HTMLMotionProps<'div'> & {
transition?: Transition
showCloseButton?: boolean
disableOverlayClickToClose?: boolean
disableTransition?: boolean
}
const contentTransition: Transition = {
type: 'spring',
stiffness: 300,
damping: 30,
}
function DialogContent({
className,
children,
transition = contentTransition,
showCloseButton = true,
disableOverlayClickToClose = false,
disableTransition = false,
...props
}: DialogContentProps) {
const { isOpen } = useDialog()
const transitionVariants = React.useMemo(() => {
if (disableTransition) {
return {
initial: { opacity: 0.96 },
animate: { opacity: 1 },
exit: { opacity: 0 },
}
}
return {
initial: { opacity: 0, scale: 0.95, y: -20 },
animate: { opacity: 1, scale: 1, y: 0 },
exit: { opacity: 0, scale: 0.95, y: -20 },
}
}, [disableTransition])
return (
<AnimatePresence>
{isOpen && (
<DialogPortal forceMount data-slot="dialog-portal">
<DialogOverlay asChild forceMount>
<motion.div
key="dialog-overlay"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2, ease: 'easeInOut' }}
onClick={stopPropagation}
/>
</DialogOverlay>
<DialogPrimitive.Content asChild forceMount {...props}>
<motion.div
key="dialog-content"
data-slot="dialog-content"
initial={transitionVariants.initial}
animate={transitionVariants.animate}
exit={transitionVariants.exit}
transition={transition}
className={cn(
'border-border bg-background fixed top-[50%] left-[50%] z-50 grid max-h-[calc(100svh-3rem)] w-[calc(100%-2rem)] max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 rounded-xl border p-4 shadow-lg',
disableOverlayClickToClose
? 'pointer-events-none [&_*]:pointer-events-auto'
: '',
className,
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close className="focus:bg-fill data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 flex size-6 items-center justify-center rounded-sm focus:outline-none disabled:pointer-events-none">
<i className="i-mingcute-close-line size-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</motion.div>
</DialogPrimitive.Content>
</DialogPortal>
)}
</AnimatePresence>
)
}
type DialogHeaderProps = React.ComponentProps<'div'>
function DialogHeader({ className, ...props }: DialogHeaderProps) {
return (
<div
data-slot="dialog-header"
className={cn(
'flex flex-col space-y-1.5 text-center sm:text-left',
className,
)}
{...props}
/>
)
}
type DialogFooterProps = React.ComponentProps<'div'>
function DialogFooter({ className, ...props }: DialogFooterProps) {
return (
<div
data-slot="dialog-footer"
className={cn(
'flex flex-col-reverse gap-2 sm:flex-row sm:justify-end',
className,
)}
{...props}
/>
)
}
type DialogTitleProps = React.ComponentProps<typeof DialogPrimitive.Title>
function DialogTitle({ className, ...props }: DialogTitleProps) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn(
'text-lg leading-none font-semibold tracking-tight',
className,
)}
{...props}
/>
)
}
type DialogDescriptionProps = React.ComponentProps<
typeof DialogPrimitive.Description
>
function DialogDescription({ className, ...props }: DialogDescriptionProps) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn('text-muted-foreground text-sm', className)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
type DialogCloseProps,
DialogContent,
type DialogContextType,
DialogDescription,
type DialogDescriptionProps,
DialogFooter,
type DialogFooterProps,
DialogHeader,
type DialogHeaderProps,
DialogOverlay,
type DialogOverlayProps,
DialogPortal,
type DialogPortalProps,
type DialogProps,
DialogTitle,
type DialogTitleProps,
DialogTrigger,
type DialogTriggerProps,
}

View File

@ -0,0 +1,12 @@
export {
Dialog,
DialogClose,
DialogContent,
type DialogContentProps,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogTitle,
DialogTrigger,
} from './Dialog'

View File

@ -0,0 +1,36 @@
import type { DetailedHTMLProps, FC, HTMLAttributes } from 'react'
import * as React from 'react'
import { clsxm } from '~/lib/helper'
export const Divider: FC<
DetailedHTMLProps<HTMLAttributes<HTMLHRElement>, HTMLHRElement>
> = (props) => {
const { className, ...rest } = props
return (
<hr
className={clsxm(
'bg-always-black dark:bg-always-white my-4 h-[0.5px] border-0 !bg-opacity-30',
className,
)}
{...rest}
/>
)
}
export const DividerVertical: FC<
DetailedHTMLProps<HTMLAttributes<HTMLSpanElement>, HTMLSpanElement>
> = (props) => {
const { className, ...rest } = props
return (
<span
className={clsxm(
'bg-always-black dark:bg-always-white mx-4 inline-block h-full w-[0.5px] select-none !bg-opacity-30 text-transparent',
className,
)}
{...rest}
>
w
</span>
)
}

View File

@ -0,0 +1 @@
export * from './Divider'

View File

@ -0,0 +1,258 @@
'use client'
import { DropdownMenu as DropdownMenuPrimitive } from 'radix-ui'
import * as React from 'react'
import { clsxm } from '~/lib/cn'
import { RootPortal } from '../portal'
const DropdownMenu = DropdownMenuPrimitive.Root
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
const DropdownMenuGroup = DropdownMenuPrimitive.Group
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
const DropdownMenuSub = DropdownMenuPrimitive.Sub
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
const DropdownMenuSubTrigger = ({
ref,
className,
inset,
children,
...props
}: React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
} & {
ref?: React.Ref<React.ElementRef<
typeof DropdownMenuPrimitive.SubTrigger
> | null>
}) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={clsxm(
'cursor-menu focus:bg-accent data-[state=open]:bg-accent flex items-center rounded-[5px] px-2.5 py-1 outline-none select-none focus:text-white data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
'text-sm focus-within:outline-transparent',
'h-[28px] w-full',
inset && 'pl-8',
className,
)}
{...props}
>
{children}
<i className="i-mingcute-right-line ml-auto size-3" />
</DropdownMenuPrimitive.SubTrigger>
)
DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName
const DropdownMenuSubContent = ({
ref,
className,
...props
}: React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent> & {
ref?: React.Ref<React.ElementRef<
typeof DropdownMenuPrimitive.SubContent
> | null>
}) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={clsxm(
'bg-material-medium backdrop-blur-background text-text border-border z-[60] min-w-32 overflow-hidden rounded-[6px] border p-1',
'shadow-context-menu',
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className,
)}
{...props}
/>
)
DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName
const DropdownMenuContent = ({
ref,
className,
sideOffset = 4,
...props
}: React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content> & {
ref?: React.Ref<React.ElementRef<typeof DropdownMenuPrimitive.Content> | null>
}) => (
<RootPortal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={clsxm(
'bg-material-medium backdrop-blur-background text-text border-border z-[60] min-w-32 overflow-hidden rounded-[6px] border p-1',
'shadow-context-menu',
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className,
)}
{...props}
/>
</RootPortal>
)
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
const DropdownMenuItem = ({
ref,
className,
inset,
...props
}: React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
} & {
ref?: React.Ref<React.ElementRef<typeof DropdownMenuPrimitive.Item> | null>
}) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={clsxm(
'cursor-menu focus:bg-accent relative flex items-center rounded-[5px] px-2.5 py-1 transition-colors outline-none select-none focus:text-white data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
'text-sm focus-within:outline-transparent',
'h-[28px] w-full',
inset && 'pl-8',
className,
)}
{...props}
/>
)
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
const DropdownMenuCheckboxItem = ({
ref,
className,
children,
checked,
...props
}: React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem> & {
ref?: React.Ref<React.ElementRef<
typeof DropdownMenuPrimitive.CheckboxItem
> | null>
}) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={clsxm(
'cursor-menu focus:bg-accent relative flex items-center rounded-[5px] py-1 pr-2.5 pl-8 transition-colors outline-none select-none focus:text-white data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
'text-sm focus-within:outline-transparent',
'h-[28px] w-full',
className,
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<i className="i-mingcute-check-fill size-3" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
)
DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName
const DropdownMenuRadioItem = ({
ref,
className,
children,
...props
}: React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem> & {
ref?: React.Ref<React.ElementRef<
typeof DropdownMenuPrimitive.RadioItem
> | null>
}) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={clsxm(
'cursor-menu focus:bg-accent relative flex items-center rounded-[5px] py-1 pr-2.5 pl-8 transition-colors outline-none select-none focus:text-white data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
'text-sm focus-within:outline-transparent',
'h-[28px] w-full',
className,
)}
{...props}
>
<span className="absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<i className="i-mingcute-check-fill size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
)
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
const DropdownMenuLabel = ({
ref,
className,
inset,
...props
}: React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
} & {
ref?: React.Ref<React.ElementRef<typeof DropdownMenuPrimitive.Label> | null>
}) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={clsxm(
'text-text px-2 py-1.5 font-semibold',
inset && 'pl-8',
className,
)}
{...props}
/>
)
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
const DropdownMenuSeparator = ({
ref,
className,
...props
}: React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator> & {
ref?: React.Ref<React.ElementRef<
typeof DropdownMenuPrimitive.Separator
> | null>
}) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={clsxm('backdrop-blur-background mx-2 my-1 h-px', className)}
{...props}
/>
)
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
const DropdownMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={clsxm(
'text-text-secondary ml-auto text-xs tracking-widest opacity-60',
className,
)}
{...props}
/>
)
}
DropdownMenuShortcut.displayName = 'DropdownMenuShortcut'
export {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuPortal,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
}

View File

@ -0,0 +1,51 @@
import * as React from 'react'
type GridGuidesProps = React.PropsWithChildren<{
className?: string
/** Show horizontal baseline guides, default: true on md+ */
showRows?: boolean
/** Number of columns for vertical guides (desktop). Default 12 */
cols?: number
}>
/**
* Subtle design guide lines overlay for sections.
* Uses background gradients for vertical (columns) and horizontal (baseline) guides.
* Non-interactive; fades toward edges via mask-image.
*/
export function GridGuides({
className,
showRows,
cols = 12,
}: GridGuidesProps) {
// Tailwind arbitrary values for background-size require constants at build time.
// We keep `cols` configurable for potential future extension, but today use 12.
const colSize = `calc(100%/${cols}) 100%`
const rowSize = `100% 24px`
return (
<div
aria-hidden
className={[
'pointer-events-none absolute inset-0 -z-10 hidden lg:block',
'opacity-30 md:opacity-40',
className,
]
.filter(Boolean)
.join(' ')}
style={{
backgroundImage:
'linear-gradient(to right, var(--color-border, hsl(0 0% 100% / 0.12)) 1px, transparent 1px),\
linear-gradient(to bottom, var(--color-border, hsl(0 0% 100% / 0.12)) 1px, transparent 1px)',
backgroundSize: `${colSize}, ${showRows !== false ? rowSize : '0 0'}`,
backgroundPosition: '0 0, 0 0',
maskImage:
'radial-gradient(60% 60% at 50% 40%, black 55%, transparent 100%)',
WebkitMaskImage:
'radial-gradient(60% 60% at 50% 40%, black 55%, transparent 100%)',
}}
/>
)
}
GridGuides.displayName = 'GridGuides'

View File

@ -0,0 +1,64 @@
'use client'
import { m } from 'motion/react'
import * as React from 'react'
type ParticlesAuraProps = {
className?: string
color?: string // CSS color, default accent
count?: number // 6-12 reasonable
}
/**
* Lightweight, decorative particles for CTA aura.
* Rendered as blurred dots with subtle float animation.
*/
export function ParticlesAura({
className,
color = 'var(--color-accent)',
count = 8,
}: ParticlesAuraProps) {
const items = React.useMemo(() => Array.from({ length: count }), [count])
return (
<div
className={['pointer-events-none absolute inset-0 -z-10', className].join(
' ',
)}
aria-hidden
>
{items.map((_, i) => {
const size = 4 + ((i * 17) % 8) // 4-11px
const left = (i * 137) % 100 // pseudo-random
const top = (i * 73) % 100
const delay = (i * 0.22) % 2
const duration = 2.5 + ((i * 0.37) % 2)
return (
<m.span
key={i}
className="absolute rounded-full blur-[2px]"
style={{
width: size,
height: size,
left: `${left}%`,
top: `${top}%`,
background: color,
opacity: 0.35,
filter: 'drop-shadow(0 0 8px rgba(255,92,0,0.45))',
}}
initial={{ y: 0, opacity: 0.2 }}
animate={{ y: -8, opacity: 0.4 }}
transition={{
repeat: Infinity,
repeatType: 'mirror',
ease: 'easeInOut',
duration,
delay,
}}
/>
)
})}
</div>
)
}
export default ParticlesAura

View File

@ -0,0 +1,106 @@
'use client'
import { m, useMotionValue, useSpring, useTransform } from 'motion/react'
import * as React from 'react'
type TiltCardProps = React.PropsWithChildren<{
className?: string
intensity?: number // degrees, default 12
glare?: boolean
}>
/**
* Glass-friendly 3D tilt container with smooth springs and optional glare.
* Enhanced visual feedback with more pronounced tilt and glare effects.
*/
export function TiltCard({
className,
children,
intensity = 12,
glare = true,
}: TiltCardProps) {
const ref = React.useRef<HTMLDivElement | null>(null)
const rx = useMotionValue(0)
const ry = useMotionValue(0)
const px = useMotionValue(0)
const py = useMotionValue(0)
// responsive springs with slightly more bounce
const srx = useSpring(rx, { stiffness: 140, damping: 14, mass: 0.3 })
const sry = useSpring(ry, { stiffness: 140, damping: 14, mass: 0.3 })
const rotateX = useTransform(sry, (v) => `${v}deg`)
const rotateY = useTransform(srx, (v) => `${v}deg`)
const spotlightX = useSpring(px, { stiffness: 150, damping: 18 })
const spotlightY = useSpring(py, { stiffness: 150, damping: 18 })
const onPointerMove = (e: React.PointerEvent) => {
const el = ref.current
if (!el) return
const rect = el.getBoundingClientRect()
const x = e.clientX - rect.left
const y = e.clientY - rect.top
const cx = rect.width / 2
const cy = rect.height / 2
const dx = (x - cx) / cx
const dy = (y - cy) / cy
rx.set(dx * intensity)
ry.set(-dy * intensity)
px.set(x)
py.set(y)
}
const reset = () => {
rx.set(0)
ry.set(0)
}
return (
<m.div
ref={ref}
className={className}
style={{
perspective: 1200,
transformStyle: 'preserve-3d',
}}
onPointerMove={onPointerMove}
onPointerLeave={reset}
>
<m.div
style={{ rotateX, rotateY, transformStyle: 'preserve-3d' }}
className="will-change-transform relative rounded-xl"
>
{glare ? (
<>
{/* Main spotlight effect with brand color */}
<m.span
aria-hidden
className="tilt-spotlight pointer-events-none absolute inset-0 z-10 rounded-[inherit] mix-blend-overlay"
style={{
// @ts-expect-error CSS variable MotionValues
'--mx': spotlightX,
'--my': spotlightY,
background:
'radial-gradient(300px 300px at calc(var(--mx) * 1px) calc(var(--my) * 1px), color-mix(in oklab,var(--color-accent),transparent 80%), transparent 40%)',
}}
/>
{/* Secondary white glow */}
<m.span
aria-hidden
className="tilt-spotlight pointer-events-none absolute inset-0 z-10 rounded-[inherit] mix-blend-soft-light"
style={{
// @ts-expect-error CSS variable MotionValues
'--mx': spotlightX,
'--my': spotlightY,
background:
'radial-gradient(250px 250px at calc(var(--mx) * 1px) calc(var(--my) * 1px), color-mix(in oklab,var(--color-background),transparent 75%), transparent 45%)',
}}
/>
</>
) : null}
{children}
</m.div>
</m.div>
)
}

View File

@ -0,0 +1,390 @@
// https://reactbits.dev/components/glass-surface
import * as React from 'react'
import { useEffect, useId, useRef } from 'react'
import { useIsDark } from '~/hooks/common/use-is-dark'
import { cn } from '~/lib/cn'
export interface GlassSurfaceProps {
children?: React.ReactNode
width?: number | string
height?: number | string
borderRadius?: number
borderWidth?: number
brightness?: number
opacity?: number
blur?: number
displace?: number
backgroundOpacity?: number
saturation?: number
distortionScale?: number
redOffset?: number
greenOffset?: number
blueOffset?: number
xChannel?: 'R' | 'G' | 'B'
yChannel?: 'R' | 'G' | 'B'
mixBlendMode?:
| 'normal'
| 'multiply'
| 'screen'
| 'overlay'
| 'darken'
| 'lighten'
| 'color-dodge'
| 'color-burn'
| 'hard-light'
| 'soft-light'
| 'difference'
| 'exclusion'
| 'hue'
| 'saturation'
| 'color'
| 'luminosity'
| 'plus-darker'
| 'plus-lighter'
className?: string
style?: React.CSSProperties
}
export const GlassSurface: React.FC<GlassSurfaceProps> = ({
children,
width = 200,
height = 80,
borderRadius = 20,
borderWidth = 0.07,
brightness = 50,
opacity = 0.93,
blur = 11,
displace = 0,
backgroundOpacity = 0,
saturation = 1,
distortionScale = -180,
redOffset = 0,
greenOffset = 10,
blueOffset = 20,
xChannel = 'R',
yChannel = 'G',
mixBlendMode = 'difference',
className = '',
style = {},
}) => {
const uniqueId = useId().replaceAll(':', '-')
const filterId = `glass-filter-${uniqueId}`
const redGradId = `red-grad-${uniqueId}`
const blueGradId = `blue-grad-${uniqueId}`
const containerRef = useRef<HTMLDivElement>(null)
const feImageRef = useRef<SVGFEImageElement>(null)
const redChannelRef = useRef<SVGFEDisplacementMapElement>(null)
const greenChannelRef = useRef<SVGFEDisplacementMapElement>(null)
const blueChannelRef = useRef<SVGFEDisplacementMapElement>(null)
const gaussianBlurRef = useRef<SVGFEGaussianBlurElement>(null)
const isDarkMode = useIsDark()
const generateDisplacementMap = () => {
const rect = containerRef.current?.getBoundingClientRect()
const actualWidth = rect?.width || 400
const actualHeight = rect?.height || 200
const edgeSize = Math.min(actualWidth, actualHeight) * (borderWidth * 0.5)
const svgContent = `
<svg viewBox="0 0 ${actualWidth} ${actualHeight}" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="${redGradId}" x1="100%" y1="0%" x2="0%" y2="0%">
<stop offset="0%" stop-color="${isDarkMode ? '#fff' : '#000'}"/>
<stop offset="100%" stop-color="red"/>
</linearGradient>
<linearGradient id="${blueGradId}" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stop-color="${isDarkMode ? '#fff' : '#000'}"/>
<stop offset="100%" stop-color="blue"/>
</linearGradient>
</defs>
<rect x="0" y="0" width="${actualWidth}" height="${actualHeight}" fill="${isDarkMode ? '#000' : '#fff'}"></rect>
<rect x="0" y="0" width="${actualWidth}" height="${actualHeight}" rx="${borderRadius}" fill="url(#${redGradId})" />
<rect x="0" y="0" width="${actualWidth}" height="${actualHeight}" rx="${borderRadius}" fill="url(#${blueGradId})" style="mix-blend-mode: ${mixBlendMode}" />
<rect x="${edgeSize}" y="${edgeSize}" width="${actualWidth - edgeSize * 2}" height="${actualHeight - edgeSize * 2}" rx="${borderRadius}" fill="hsl(0 0% ${brightness}% / ${opacity})" style="filter:blur(${blur}px)" />
</svg>
`
return `data:image/svg+xml,${encodeURIComponent(svgContent)}`
}
const updateDisplacementMap = () => {
feImageRef.current?.setAttribute('href', generateDisplacementMap())
}
useEffect(() => {
updateDisplacementMap()
;[
{ ref: redChannelRef, offset: redOffset },
{ ref: greenChannelRef, offset: greenOffset },
{ ref: blueChannelRef, offset: blueOffset },
].forEach(({ ref, offset }) => {
if (ref.current) {
ref.current.setAttribute('scale', (distortionScale + offset).toString())
ref.current.setAttribute('xChannelSelector', xChannel)
ref.current.setAttribute('yChannelSelector', yChannel)
}
})
gaussianBlurRef.current?.setAttribute('stdDeviation', displace.toString())
}, [
width,
height,
borderRadius,
borderWidth,
brightness,
opacity,
blur,
displace,
distortionScale,
redOffset,
greenOffset,
blueOffset,
xChannel,
yChannel,
mixBlendMode,
isDarkMode,
])
useEffect(() => {
if (!containerRef.current) return
const resizeObserver = new ResizeObserver(() => {
setTimeout(updateDisplacementMap, 0)
})
resizeObserver.observe(containerRef.current)
return () => {
resizeObserver.disconnect()
}
}, [])
useEffect(() => {
if (!containerRef.current) return
const resizeObserver = new ResizeObserver(() => {
setTimeout(updateDisplacementMap, 0)
})
resizeObserver.observe(containerRef.current)
return () => {
resizeObserver.disconnect()
}
}, [])
useEffect(() => {
setTimeout(updateDisplacementMap, 0)
}, [width, height, isDarkMode])
const supportsSVGFilters = () => {
const isWebkit =
/Safari/.test(navigator.userAgent) && !/Chrome/.test(navigator.userAgent)
const isFirefox = /Firefox/.test(navigator.userAgent)
if (isWebkit || isFirefox) {
return false
}
const div = document.createElement('div')
div.style.backdropFilter = `url(#${filterId})`
return div.style.backdropFilter !== ''
}
const supportsBackdropFilter = () => {
if (typeof window === 'undefined') return false
return CSS.supports('backdrop-filter', 'blur(10px)')
}
const getContainerStyles = (): React.CSSProperties => {
const baseStyles: React.CSSProperties = {
...style,
width: typeof width === 'number' ? `${width}px` : width,
height: typeof height === 'number' ? `${height}px` : height,
borderRadius: `${borderRadius}px`,
'--glass-frost': backgroundOpacity,
'--glass-saturation': saturation,
} as React.CSSProperties
const svgSupported = supportsSVGFilters()
const backdropFilterSupported = supportsBackdropFilter()
if (svgSupported) {
return {
...baseStyles,
background: isDarkMode
? `hsl(0 0% 0% / ${backgroundOpacity})`
: `hsl(0 0% 100% / ${backgroundOpacity})`,
backdropFilter: `url(#${filterId}) saturate(${saturation})`,
boxShadow: isDarkMode
? `0 0 2px 1px color-mix(in oklch, white, transparent 65%) inset,
0 0 10px 4px color-mix(in oklch, white, transparent 85%) inset,
0px 4px 16px rgba(17, 17, 26, 0.05),
0px 8px 24px rgba(17, 17, 26, 0.05),
0px 16px 56px rgba(17, 17, 26, 0.05),
0px 4px 16px rgba(17, 17, 26, 0.05) inset,
0px 8px 24px rgba(17, 17, 26, 0.05) inset,
0px 16px 56px rgba(17, 17, 26, 0.05) inset`
: `0 0 2px 1px color-mix(in oklch, oklch(85% 0.1 70), transparent 75%) inset,
0 0 10px 4px color-mix(in oklch, oklch(90% 0.1 70), transparent 85%) inset,
0px 4px 16px rgba(255, 180, 120, 0.08),
0px 8px 24px rgba(255, 180, 120, 0.08),
0px 16px 56px rgba(255, 180, 120, 0.08),
0px 4px 16px rgba(255, 180, 120, 0.08) inset,
0px 8px 24px rgba(255, 180, 120, 0.08) inset,
0px 16px 56px rgba(255, 180, 120, 0.08) inset`,
}
} else {
if (isDarkMode) {
if (!backdropFilterSupported) {
return {
...baseStyles,
background: 'rgba(0, 0, 0, 0.4)',
border: '1px solid rgba(255, 255, 255, 0.2)',
boxShadow: `inset 0 1px 0 0 rgba(255, 255, 255, 0.2),
inset 0 -1px 0 0 rgba(255, 255, 255, 0.1)`,
}
} else {
return {
...baseStyles,
background: 'rgba(255, 255, 255, 0.1)',
backdropFilter: 'blur(12px) saturate(1.8) brightness(1.2)',
WebkitBackdropFilter: 'blur(12px) saturate(1.8) brightness(1.2)',
border: '1px solid rgba(255, 255, 255, 0.2)',
boxShadow: `inset 0 1px 0 0 rgba(255, 255, 255, 0.2),
inset 0 -1px 0 0 rgba(255, 255, 255, 0.1)`,
}
}
} else {
if (!backdropFilterSupported) {
return {
...baseStyles,
background: 'rgba(255, 255, 255, 0.4)',
border: '1px solid rgba(255, 255, 255, 0.3)',
boxShadow: `inset 0 1px 0 0 rgba(255, 255, 255, 0.5),
inset 0 -1px 0 0 rgba(255, 255, 255, 0.3)`,
}
} else {
return {
...baseStyles,
background: 'rgba(255, 255, 255, 0.25)',
backdropFilter: 'blur(12px) saturate(1.8) brightness(1.1)',
WebkitBackdropFilter: 'blur(12px) saturate(1.8) brightness(1.1)',
border: '1px solid rgba(255, 255, 255, 0.3)',
boxShadow: `0 8px 32px 0 rgba(31, 38, 135, 0.2),
0 2px 16px 0 rgba(31, 38, 135, 0.1),
inset 0 1px 0 0 rgba(255, 255, 255, 0.4),
inset 0 -1px 0 0 rgba(255, 255, 255, 0.2)`,
}
}
}
}
}
const glassSurfaceClasses =
'relative flex items-center justify-center overflow-hidden transition-opacity duration-[260ms] ease-out'
const focusVisibleClasses = isDarkMode
? 'focus-visible:outline-2 focus-visible:outline-[#0A84FF] focus-visible:outline-offset-2'
: 'focus-visible:outline-2 focus-visible:outline-[#007AFF] focus-visible:outline-offset-2'
return (
<div
ref={containerRef}
className={cn(glassSurfaceClasses, focusVisibleClasses, className)}
style={getContainerStyles()}
>
<svg
className="size-full pointer-events-none absolute inset-0 opacity-0 -z-10"
xmlns="http://www.w3.org/2000/svg"
>
<defs>
<filter
id={filterId}
colorInterpolationFilters="sRGB"
x="0%"
y="0%"
width="100%"
height="100%"
>
<feImage
ref={feImageRef}
x="0"
y="0"
width="100%"
height="100%"
preserveAspectRatio="none"
result="map"
/>
<feDisplacementMap
ref={redChannelRef}
in="SourceGraphic"
in2="map"
id="redchannel"
result="dispRed"
/>
<feColorMatrix
in="dispRed"
type="matrix"
values="1 0 0 0 0
0 0 0 0 0
0 0 0 0 0
0 0 0 1 0"
result="red"
/>
<feDisplacementMap
ref={greenChannelRef}
in="SourceGraphic"
in2="map"
id="greenchannel"
result="dispGreen"
/>
<feColorMatrix
in="dispGreen"
type="matrix"
values="0 0 0 0 0
0 1 0 0 0
0 0 0 0 0
0 0 0 1 0"
result="green"
/>
<feDisplacementMap
ref={blueChannelRef}
in="SourceGraphic"
in2="map"
id="bluechannel"
result="dispBlue"
/>
<feColorMatrix
in="dispBlue"
type="matrix"
values="0 0 0 0 0
0 0 0 0 0
0 0 1 0 0
0 0 0 1 0"
result="blue"
/>
<feBlend in="red" in2="green" mode="screen" result="rg" />
<feBlend in="rg" in2="blue" mode="screen" result="output" />
<feGaussianBlur
ref={gaussianBlurRef}
in="output"
stdDeviation="0.7"
/>
</filter>
</defs>
</svg>
<div className="size-full flex items-center justify-center p-2 rounded-[inherit] relative z-10">
{children}
</div>
</div>
)
}

View File

@ -0,0 +1,101 @@
import { useInView } from 'motion/react'
import type * as React from 'react'
import { useEffect, useRef } from 'react'
import { annotate } from 'rough-notation'
import type { RoughAnnotation } from 'rough-notation/lib/model'
type AnnotationAction =
| 'highlight'
| 'underline'
| 'box'
| 'circle'
| 'strike-through'
| 'crossed-off'
| 'bracket'
interface HighlighterProps {
children: React.ReactNode
action?: AnnotationAction
color?: string
strokeWidth?: number
animationDuration?: number
iterations?: number
padding?: number
multiline?: boolean
isView?: boolean
}
export function Highlighter({
children,
action = 'highlight',
color = '#ffd1dc',
strokeWidth = 1.5,
animationDuration = 600,
iterations = 2,
padding = 2,
multiline = true,
isView = false,
}: HighlighterProps) {
const elementRef = useRef<HTMLSpanElement>(null)
const annotationRef = useRef<RoughAnnotation | null>(null)
const isInView = useInView(elementRef, {
once: true,
margin: '-10%',
})
// If isView is false, always show. If isView is true, wait for inView
const shouldShow = !isView || isInView
useEffect(() => {
if (!shouldShow) return
const element = elementRef.current
if (!element) return
const annotationConfig = {
type: action,
color,
strokeWidth,
animationDuration,
iterations,
padding,
multiline,
}
const annotation = annotate(element, annotationConfig)
annotationRef.current = annotation
annotationRef.current.show()
const resizeObserver = new ResizeObserver(() => {
annotation.hide()
annotation.show()
})
resizeObserver.observe(element)
resizeObserver.observe(document.body)
return () => {
if (element) {
annotate(element, { type: action }).remove()
resizeObserver.disconnect()
}
}
}, [
shouldShow,
action,
color,
strokeWidth,
animationDuration,
iterations,
padding,
multiline,
])
return (
<span ref={elementRef} className="relative inline-block bg-transparent">
{children}
</span>
)
}

View File

@ -0,0 +1,77 @@
'use client'
import { HoverCard as HoverCardPrimitive } from 'radix-ui'
import * as React from 'react'
import { clsxm } from '~/lib/cn'
import { RootPortal } from '../portal'
type HoverCardProps = React.ComponentProps<typeof HoverCardPrimitive.Root>
type HoverCardTriggerProps = React.ComponentProps<
typeof HoverCardPrimitive.Trigger
>
type HoverCardContentProps = React.ComponentProps<
typeof HoverCardPrimitive.Content
>
const HoverCard = HoverCardPrimitive.Root
const HoverCardTrigger = HoverCardPrimitive.Trigger
const HoverCardContent = ({
ref,
className,
align = 'center',
sideOffset = 8,
...props
}: React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Content> & {
ref?: React.RefObject<React.ElementRef<
typeof HoverCardPrimitive.Content
> | null>
}) => (
<RootPortal>
<HoverCardPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={clsxm(
'bg-material-high/95 backdrop-blur-background text-text border-border z-[60] w-[320px] max-w-[calc(100vw-2rem)] rounded-[12px] border p-4 shadow-lg',
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
'data-[side=bottom]:slide-in-from-top-2 data-[side=top]:slide-in-from-bottom-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2',
className,
)}
{...props}
/>
</RootPortal>
)
HoverCardContent.displayName = HoverCardPrimitive.Content.displayName
const HoverCardArrow = ({
ref,
className,
...props
}: React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Arrow> & {
ref?: React.RefObject<React.ElementRef<
typeof HoverCardPrimitive.Arrow
> | null>
}) => (
<HoverCardPrimitive.Arrow
ref={ref}
className={clsxm('fill-border/80 text-border/80', className)}
{...props}
/>
)
HoverCardArrow.displayName = HoverCardPrimitive.Arrow.displayName
export {
HoverCard,
HoverCardArrow,
HoverCardContent,
type HoverCardContentProps,
type HoverCardProps,
HoverCardTrigger,
type HoverCardTriggerProps,
}

View File

@ -0,0 +1,9 @@
export {
HoverCard,
HoverCardArrow,
HoverCardContent,
type HoverCardContentProps,
type HoverCardProps,
HoverCardTrigger,
type HoverCardTriggerProps,
} from './HoverCard'

View File

@ -0,0 +1,253 @@
'use client'
// Tremor Input [v2.0.0]
import * as React from 'react'
import type { VariantProps } from 'tailwind-variants'
import { tv } from 'tailwind-variants'
import { useInputComposition } from '~/hooks/common/use-input-composition'
import { clsxm, focusInput, focusRing, hasErrorInput } from '~/lib/cn'
const inputStyles = tv({
base: [
// base
'relative block w-full appearance-none border shadow-xs outline-hidden transition rounded-full',
// electron
'no-drag-region',
// border color
'border-border',
// text color
'text-text',
// placeholder color
'placeholder:text-placeholder-text',
// background color (slightly translucent for glass feel)
'bg-background/80 backdrop-blur',
// disabled
'disabled:border-border disabled:bg-disabled-control disabled:text-disabled-text',
// file
[
'file:cursor-pointer file:rounded-l-[999px] file:rounded-r-none file:border-0 file:px-3 file:outline-hidden focus:outline-hidden disabled:pointer-events-none file:disabled:pointer-events-none',
'file:border-solid file:border-border file:bg-fill file:text-placeholder-text file:hover:bg-fill-secondary',
'file:[border-inline-end-width:1px] file:[margin-inline-end:0.75rem]',
'file:disabled:bg-disabled-control file:disabled:text-disabled-text',
],
// focus
focusInput,
// invalid (optional)
'aria-invalid:ring-2 aria-invalid:ring-red/20 aria-invalid:border-red invalid:ring-2 invalid:ring-red/20 invalid:border-red',
// remove search cancel button (optional)
'[&::-webkit-search-cancel-button]:hidden [&::-webkit-search-decoration]:hidden',
],
variants: {
hasError: {
true: hasErrorInput,
},
// number input
enableStepper: {
false:
'[appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none',
},
size: {
sm: [
'px-4 py-1.5 text-sm rounded-full',
'file:-my-1.5 file:-ml-4 file:py-1.5',
],
md: [
'px-3 text-sm rounded-full h-10',
'file:-my-2 file:-ml-2.5 file:py-2',
],
},
},
defaultVariants: {
size: 'md',
},
})
interface InputProps
extends
Omit<React.InputHTMLAttributes<HTMLInputElement>, 'size'>,
VariantProps<typeof inputStyles> {
inputClassName?: string
/**
* Optional node to render at the end (right side) of the input.
* Useful for inline actions like Save/Clear.
*/
endAdornment?: React.ReactNode
/**
* Visibility strategy for endAdornment.
* - 'focus': show only when input is focused (default)
* - 'always': always visible
*/
endAdornmentVisibility?: 'focus' | 'always'
}
const Input = ({
ref: forwardedRef,
className,
inputClassName,
hasError,
enableStepper = true,
size = 'md',
type,
endAdornment,
endAdornmentVisibility = 'focus',
onFocus,
onBlur,
disabled,
style,
...props
}: InputProps & { ref?: React.RefObject<HTMLInputElement | null> }) => {
const [typeState, setTypeState] = React.useState(type)
const [focused, setFocused] = React.useState(false)
const isPassword = type === 'password'
const isSearch = type === 'search'
const inputProps = useInputComposition(props)
const showEndAdornment =
Boolean(endAdornment) &&
!disabled &&
(endAdornmentVisibility === 'always' || focused)
const rightControlsRef = React.useRef<HTMLDivElement | null>(null)
const [rightControlsWidth, setRightControlsWidth] = React.useState(0)
React.useEffect(() => {
if (!(isPassword || showEndAdornment)) {
setRightControlsWidth(0)
}
}, [isPassword, showEndAdornment])
React.useLayoutEffect(() => {
if (!rightControlsRef.current) return
const node = rightControlsRef.current
const updateWidth = () => {
const { width } = node.getBoundingClientRect()
setRightControlsWidth(width)
}
updateWidth()
const observer = new ResizeObserver(updateWidth)
observer.observe(node)
return () => {
observer.disconnect()
}
}, [isPassword, showEndAdornment])
const computedStyle = React.useMemo(() => {
if (rightControlsWidth > 0) {
const padding = rightControlsWidth + 8
const paddingValue = `${padding}px`
if (
style &&
Object.prototype.hasOwnProperty.call(style, 'paddingRight')
) {
return style
}
return { ...style, paddingRight: paddingValue }
}
return style
}, [style, rightControlsWidth])
return (
<div className={clsxm('relative w-full', className)} tremor-id="tremor-raw">
<input
ref={forwardedRef}
type={isPassword ? typeState : type}
className={clsxm(
inputStyles({ hasError, enableStepper, size }),
{
'pl-8': isSearch,
},
inputClassName,
)}
disabled={disabled}
style={computedStyle}
{...props}
onFocus={(e) => {
setFocused(true)
onFocus?.(e)
}}
onBlur={(e) => {
setFocused(false)
onBlur?.(e)
}}
{...inputProps}
/>
{isSearch && (
<div
className={clsxm(
// base
'pointer-events-none absolute bottom-0 left-2 flex h-full items-center justify-center',
// text color
'text-placeholder-text',
)}
>
<i
className="i-mingcute-search-line size-[1.125rem] shrink-0"
aria-hidden="true"
/>
</div>
)}
{(isPassword || showEndAdornment) && (
<div
className={clsxm(
'absolute inset-y-0 right-0 flex items-center gap-1 px-2',
)}
ref={rightControlsRef}
>
{/* Inline actions / custom adornment */}
{showEndAdornment ? (
<div className="pointer-events-auto flex items-center gap-1">
{endAdornment}
</div>
) : null}
{/* Password visibility toggle */}
{isPassword && (
<button
aria-label="Change password visibility"
className={clsxm(
// base
'flex h-full w-fit items-center rounded-xs outline-hidden transition-all',
// text
'text-placeholder-text',
// hover
'hover:text-text',
focusRing,
)}
type="button"
onClick={() => {
setTypeState(typeState === 'password' ? 'text' : 'password')
}}
>
<span className="sr-only">
{typeState === 'password' ? 'Show password' : 'Hide password'}
</span>
{typeState === 'password' ? (
<i
className="i-mingcute-eye-line size-5 shrink-0"
aria-hidden="true"
/>
) : (
<i
className="i-mingcute-eye-close-line size-5 shrink-0"
aria-hidden="true"
/>
)}
</button>
)}
</div>
)}
</div>
)
}
Input.displayName = 'Input'
export { Input, type InputProps }

View File

@ -0,0 +1,53 @@
'use client'
// Tremor Textarea [v1.0.0]
import * as React from 'react'
import { useInputComposition } from '~/hooks/common/use-input-composition'
import { cx, focusInput, hasErrorInput } from '~/lib/cn'
interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {
hasError?: boolean
}
const Textarea = ({
ref: forwardedRef,
className,
hasError,
...props
}: TextareaProps & { ref?: React.RefObject<HTMLTextAreaElement | null> }) => {
const inputProps = useInputComposition<HTMLTextAreaElement>(props)
return (
<textarea
ref={forwardedRef}
className={cx(
// base
'flex min-h-[4rem] w-full rounded-md border px-3 py-1.5 shadow-xs outline-hidden transition-colors sm:text-sm',
// text color
'text-text',
// border color
'border-border',
// background color
'bg-background',
// placeholder color
'placeholder:text-placeholder-text',
// disabled
'disabled:border-border disabled:bg-disabled-control disabled:text-disabled-text',
// focus
focusInput,
// error
hasError ? hasErrorInput : '',
// invalid (optional)
// "dark:aria-invalid:ring-red-400/20 aria-invalid:ring-2 aria-invalid:ring-red-200 aria-invalid:border-red-500 invalid:ring-2 invalid:ring-red-200 invalid:border-red-500"
className,
)}
tremor-id="tremor-raw"
{...props}
{...inputProps}
/>
)
}
Textarea.displayName = 'Textarea'
export { Textarea, type TextareaProps }

View File

@ -0,0 +1 @@
export * from './Input'

View File

@ -0,0 +1,422 @@
import * as React from 'react'
import { cn } from '~/lib/cn'
export interface JsonHighlighterProps {
/** JSON string to highlight */
json: string
/** Additional CSS class name */
className?: string
/** Whether to show indentation */
showIndentation?: boolean
/** Whether to show line numbers */
showLineNumbers?: boolean
/** Maximum height before scrolling */
maxHeight?: string
}
/**
* A lightweight JSON syntax highlighter component that uses regex matching
* and TailwindCSS for styling without external highlighting libraries.
*/
export const JsonHighlighter = ({
ref,
json,
className,
showIndentation = true,
showLineNumbers = false,
maxHeight,
...props
}: JsonHighlighterProps & { ref?: React.RefObject<HTMLPreElement | null> }) => {
const highlightedJson = React.useMemo(() => {
try {
// Try to parse and format the JSON first
const parsed = JSON.parse(json)
const formatted = JSON.stringify(parsed, null, showIndentation ? 2 : 0)
return highlightJson(formatted)
} catch {
// If parsing fails, highlight the raw string
return highlightJson(json)
}
}, [json, showIndentation])
const lines = React.useMemo(() => {
return highlightedJson.split('\n')
}, [highlightedJson])
return (
<pre
ref={ref}
className={cn(
'bg-material-ultra-thin text-text overflow-auto rounded-md border p-4 text-sm',
'font-mono leading-relaxed',
className,
)}
style={{ maxHeight }}
{...props}
>
<code className="block">
{showLineNumbers ? (
<div className="flex">
<div className="text-text-tertiary border-fill mr-4 select-none border-r pr-4">
{lines.map((_, index) => (
<div key={index} className="text-right">
{index + 1}
</div>
))}
</div>
<div className="flex-1">
{lines.map((line, index) => (
<div key={index} dangerouslySetInnerHTML={{ __html: line }} />
))}
</div>
</div>
) : (
lines.map((line, index) => (
<div key={index} dangerouslySetInnerHTML={{ __html: line }} />
))
)}
</code>
</pre>
)
}
JsonHighlighter.displayName = 'JsonHighlighter'
/**
* Token types for JSON highlighting
*/
interface Token {
type:
| 'key'
| 'string'
| 'number'
| 'boolean'
| 'null'
| 'punctuation'
| 'whitespace'
value: string
start: number
end: number
}
/**
* Highlights JSON syntax using precise tokenization and UIKit colors
*/
function highlightJson(jsonString: string): string {
// Escape HTML entities first
const escaped = jsonString
?.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
const tokens = tokenizeJson(escaped)
return renderTokens(tokens, escaped)
}
/**
* Tokenizes JSON string into semantic tokens
*/
function tokenizeJson(json: string): Token[] {
const tokens: Token[] = []
let i = 0
// Track context to distinguish keys from string values
const contextStack: ('object' | 'array')[] = []
let expectingKey = false
while (i < json.length) {
const char = json[i]!
// Skip whitespace but track it for proper rendering
if (/\s/.test(char)) {
const start = i
while (i < json.length && /\s/.test(json[i]!)) {
i++
}
tokens.push({
type: 'whitespace',
value: json.slice(start, i),
start,
end: i,
})
continue
}
// Handle strings (keys and values)
if (char === '"') {
const start = i
i++ // Skip opening quote
let value = '"'
// Parse string content, handling escapes
while (i < json.length) {
const current = json[i]
value += current
if (current === '"') {
i++
break
}
// Handle escape sequences
if (current === '\\' && i + 1 < json.length) {
i++
value += json[i]
}
i++
}
// Determine if this is a key or string value
const isKey =
expectingKey ||
(contextStack.at(-1) === 'object' && isFollowedByColon(json, i))
tokens.push({
type: isKey ? 'key' : 'string',
value,
start,
end: i,
})
if (isKey) {
expectingKey = false
}
continue
}
// Handle numbers
if (/[-\d]/.test(char)) {
const start = i
let value = ''
// Handle negative sign
if (char === '-') {
value += char
i++
}
// Parse integer part
if (i < json.length && /\d/.test(json[i]!)) {
while (i < json.length && /\d/.test(json[i]!)) {
value += json[i]!
i++
}
// Parse decimal part
if (i < json.length && json[i] === '.') {
value += json[i]!
i++
while (i < json.length && /\d/.test(json[i]!)) {
value += json[i]!
i++
}
}
// Parse exponent part
if (i < json.length && /e/i.test(json[i]!)) {
value += json[i]!
i++
if (i < json.length && /[+-]/.test(json[i]!)) {
value += json[i]!
i++
}
while (i < json.length && /\d/.test(json[i]!)) {
value += json[i]!
i++
}
}
tokens.push({
type: 'number',
value,
start,
end: i,
})
continue
} else {
// Not a valid number, treat as punctuation
tokens.push({
type: 'punctuation',
value: char,
start,
end: i + 1,
})
i++
continue
}
}
// Handle boolean and null literals
if (/[tfn]/.test(char)) {
const start = i
// Check for 'true'
if (json.slice(i, i + 4) === 'true') {
tokens.push({
type: 'boolean',
value: 'true',
start,
end: i + 4,
})
i += 4
continue
}
// Check for 'false'
if (json.slice(i, i + 5) === 'false') {
tokens.push({
type: 'boolean',
value: 'false',
start,
end: i + 5,
})
i += 5
continue
}
// Check for 'null'
if (json.slice(i, i + 4) === 'null') {
tokens.push({
type: 'null',
value: 'null',
start,
end: i + 4,
})
i += 4
continue
}
}
// Handle punctuation
if (/[{}[\],:]/.test(char)) {
// Update context stack
switch (char) {
case '{': {
contextStack.push('object')
expectingKey = true
break
}
case '[': {
contextStack.push('array')
break
}
case '}':
case ']': {
contextStack.pop()
expectingKey = contextStack.at(-1) === 'object'
break
}
case ',': {
expectingKey = contextStack.at(-1) === 'object'
break
}
case ':': {
expectingKey = false
break
}
// No default
}
tokens.push({
type: 'punctuation',
value: char,
start: i,
end: i + 1,
})
i++
continue
}
// Unknown character, skip
i++
}
return tokens
}
/**
* Checks if a string token is followed by a colon (indicating it's a key)
*/
function isFollowedByColon(json: string, startIndex: number): boolean {
let i = startIndex
// Skip whitespace
while (i < json.length && /\s/.test(json[i]!)) {
i++
}
return i < json.length && json[i] === ':'
}
/**
* Renders tokens with appropriate UIKit colors
*/
function renderTokens(tokens: Token[], originalJson: string): string {
let result = ''
let lastEnd = 0
for (const token of tokens) {
// Add any characters between tokens (shouldn't happen with proper tokenization)
if (token.start > lastEnd) {
result += originalJson.slice(lastEnd, token.start)
}
// Apply semantic coloring with enhanced Tailwind colors
switch (token.type) {
case 'key': {
result += `<span class="text-sky-600 dark:text-sky-400 font-semibold">${token.value}</span>`
break
}
case 'string': {
result += `<span class="text-emerald-600 dark:text-emerald-400">${token.value}</span>`
break
}
case 'number': {
result += `<span class="text-amber-600 dark:text-amber-400">${token.value}</span>`
break
}
case 'boolean': {
result += `<span class="text-violet-600 dark:text-violet-400 font-medium">${token.value}</span>`
break
}
case 'null': {
result += `<span class="text-slate-500 dark:text-slate-400 italic">${token.value}</span>`
break
}
case 'punctuation': {
// Use different colors for different punctuation types
if (token.value === ':') {
result += `<span class="text-slate-600 dark:text-slate-300">${token.value}</span>`
} else if (/[{}[\]]/.test(token.value)) {
result += `<span class="text-indigo-600 dark:text-indigo-400 font-semibold">${token.value}</span>`
} else {
result += `<span class="text-slate-500 dark:text-slate-400">${token.value}</span>`
}
break
}
case 'whitespace': {
result += token.value
break
}
default: {
result += token.value
break
}
}
lastEnd = token.end
}
// Add any remaining characters
if (lastEnd < originalJson.length) {
result += originalJson.slice(lastEnd)
}
return result
}

View File

@ -0,0 +1,48 @@
// Tremor Label [v0.0.2]
import { Label as LabelPrimitives } from 'radix-ui'
import * as React from 'react'
import { tv } from 'tailwind-variants'
interface LabelProps extends React.ComponentPropsWithoutRef<
typeof LabelPrimitives.Root
> {
disabled?: boolean
variant?: 'form' | 'default'
}
const styles = tv({
base: 'text-sm leading-none text-text',
variants: {
variant: {
form: 'text-sm leading-none text-text pl-2.5 pb-1 block',
default: 'text-sm leading-none text-text',
},
disabled: {
true: 'text-disabled-text',
},
},
})
const Label = ({
ref: forwardedRef,
className,
disabled,
variant = 'default',
...props
}: LabelProps & {
ref?: React.RefObject<React.ElementRef<typeof LabelPrimitives.Root> | null>
}) => (
<LabelPrimitives.Root
ref={forwardedRef}
className={styles({ variant, disabled, className })}
aria-disabled={disabled}
tremor-id="tremor-raw"
{...props}
/>
)
Label.displayName = 'Label'
export { Label }

View File

@ -0,0 +1,149 @@
'use client'
import { m as motion } from 'motion/react'
import type { CSSProperties } from 'react'
import { useEffect, useState } from 'react'
import { cn } from '~/lib/cn'
interface LightRaysProps extends React.HTMLAttributes<HTMLDivElement> {
ref?: React.Ref<HTMLDivElement>
count?: number
color?: string
blur?: number
speed?: number
length?: string
}
type LightRay = {
id: string
left: number
rotate: number
width: number
swing: number
delay: number
duration: number
intensity: number
}
const createRays = (count: number, cycle: number): LightRay[] => {
if (count <= 0) return []
return Array.from({ length: count }, (_, index) => {
const left = 8 + Math.random() * 84
const rotate = -28 + Math.random() * 56
const width = 160 + Math.random() * 160
const swing = 0.8 + Math.random() * 1.8
const delay = Math.random() * cycle
const duration = cycle * (0.75 + Math.random() * 0.5)
const intensity = 0.6 + Math.random() * 0.5
return {
id: `${index}-${Math.round(left * 10)}`,
left,
rotate,
width,
swing,
delay,
duration,
intensity,
}
})
}
const Ray = ({
left,
rotate,
width,
swing,
delay,
duration,
intensity,
}: LightRay) => {
return (
<motion.div
className="pointer-events-none absolute -top-[12%] left-[var(--ray-left)] h-[var(--light-rays-length)] w-[var(--ray-width)] origin-top -translate-x-1/2 rounded-full bg-gradient-to-b from-[color-mix(in_srgb,var(--light-rays-color)_70%,transparent)] to-transparent opacity-0 mix-blend-screen blur-[var(--light-rays-blur)]"
style={
{
'--ray-left': `${left}%`,
'--ray-width': `${width}px`,
} as CSSProperties
}
initial={{ rotate }}
animate={{
opacity: [0, intensity, 0],
rotate: [rotate - swing, rotate + swing, rotate - swing],
}}
transition={{
duration,
repeat: Infinity,
ease: 'easeInOut',
delay,
repeatDelay: duration * 0.1,
}}
/>
)
}
export function LightRays({
className,
style,
count = 7,
color = 'rgba(160, 210, 255, 0.2)',
blur = 36,
speed = 14,
length = '70vh',
ref,
...props
}: LightRaysProps) {
const [rays, setRays] = useState<LightRay[]>([])
const cycleDuration = Math.max(speed, 0.1)
useEffect(() => {
setRays(createRays(count, cycleDuration))
}, [count, cycleDuration])
return (
<div
ref={ref}
className={cn(
'pointer-events-none absolute inset-0 isolate overflow-hidden rounded-[inherit]',
className,
)}
style={
{
'--light-rays-color': color,
'--light-rays-blur': `${blur}px`,
'--light-rays-length': length,
...style,
} as CSSProperties
}
{...props}
>
<div className="absolute inset-0 overflow-hidden">
<div
aria-hidden
className="absolute inset-0 opacity-60"
style={
{
background:
'radial-gradient(circle at 20% 15%, color-mix(in srgb, var(--light-rays-color) 45%, transparent), transparent 70%)',
} as CSSProperties
}
/>
<div
aria-hidden
className="absolute inset-0 opacity-60"
style={
{
background:
'radial-gradient(circle at 80% 10%, color-mix(in srgb, var(--light-rays-color) 35%, transparent), transparent 75%)',
} as CSSProperties
}
/>
{rays.map((ray) => (
<Ray key={ray.id} {...ray} />
))}
</div>
</div>
)
}

View File

@ -0,0 +1,34 @@
import * as React from 'react'
import { clsxm } from '~/lib/helper'
export type LoadingProps = {
loadingText?: string
useDefaultLoadingText?: boolean
}
const defaultLoadingText = '别着急,坐和放宽'
export const Loading: Component<LoadingProps> = ({
loadingText,
className,
useDefaultLoadingText = false,
}) => {
const nextLoadingText = useDefaultLoadingText
? defaultLoadingText
: loadingText
return (
<div
data-hide-print
className={clsxm('center flex my-20 flex-col', className)}
>
<span className="loading loading-ball loading-lg" />
{!!nextLoadingText && (
<span className="mt-6 block">{nextLoadingText}</span>
)}
</div>
)
}
export const FullPageLoading = () => (
<Loading useDefaultLoadingText className="h-[calc(100vh-6.5rem-10rem)]" />
)

View File

@ -0,0 +1,103 @@
'use client'
import { m, useMotionTemplate, useMotionValue } from 'motion/react'
import * as React from 'react'
import { useCallback, useEffect } from 'react'
import { cn } from '~/lib/cn'
interface MagicCardProps {
children?: React.ReactNode
className?: string
gradientSize?: number
gradientColor?: string
gradientOpacity?: number
gradientFrom?: string
gradientTo?: string
}
export function MagicCard({
children,
className,
gradientSize = 200,
gradientColor = '#262626',
gradientOpacity = 0.8,
gradientFrom = '#9E7AFF',
gradientTo = '#FE8BBB',
}: MagicCardProps) {
const mouseX = useMotionValue(-gradientSize)
const mouseY = useMotionValue(-gradientSize)
const reset = useCallback(() => {
mouseX.set(-gradientSize)
mouseY.set(-gradientSize)
}, [gradientSize, mouseX, mouseY])
const handlePointerMove = useCallback(
(e: React.PointerEvent<HTMLDivElement>) => {
const rect = e.currentTarget.getBoundingClientRect()
mouseX.set(e.clientX - rect.left)
mouseY.set(e.clientY - rect.top)
},
[mouseX, mouseY],
)
useEffect(() => {
reset()
}, [reset])
useEffect(() => {
const handleGlobalPointerOut = (e: PointerEvent) => {
if (!e.relatedTarget) {
reset()
}
}
const handleVisibility = () => {
if (document.visibilityState !== 'visible') {
reset()
}
}
window.addEventListener('pointerout', handleGlobalPointerOut)
window.addEventListener('blur', reset)
document.addEventListener('visibilitychange', handleVisibility)
return () => {
window.removeEventListener('pointerout', handleGlobalPointerOut)
window.removeEventListener('blur', reset)
document.removeEventListener('visibilitychange', handleVisibility)
}
}, [reset])
return (
<div
className={cn('group relative rounded-[inherit]', className)}
onPointerMove={handlePointerMove}
onPointerLeave={reset}
onPointerEnter={reset}
>
<m.div
className="pointer-events-none absolute inset-0 rounded-[inherit] bg-border duration-300 group-hover:opacity-100"
style={{
background: useMotionTemplate`
radial-gradient(${gradientSize}px circle at ${mouseX}px ${mouseY}px,
${gradientFrom},
${gradientTo},
var(--border) 100%
)
`,
}}
/>
<div className="absolute inset-px rounded-[inherit] bg-background" />
<m.div
className="pointer-events-none absolute inset-px rounded-[inherit] opacity-0 transition-opacity duration-300 group-hover:opacity-100"
style={{
background: useMotionTemplate`
radial-gradient(${gradientSize}px circle at ${mouseX}px ${mouseY}px, ${gradientColor}, transparent 100%)
`,
opacity: gradientOpacity,
}}
/>
<div className="relative h-full">{children}</div>
</div>
)
}

View File

@ -0,0 +1,51 @@
import fs from 'node:fs'
import { join } from 'pathe'
import rehypeStringify from 'rehype-stringify'
import remarkParse from 'remark-parse'
import remarkRehype from 'remark-rehype'
import { unified } from 'unified'
interface MarkdownContentProps {
content: string
}
export function MarkdownContent({ content }: MarkdownContentProps) {
return (
<div className="container">
<div className="mx-auto max-w-4xl">
{/* Content */}
<div className="prose prose-lg prose-gray dark:prose-invert prose-headings:font-semibold prose-headings:text-gray-900 dark:prose-headings:text-gray-100 prose-p:text-gray-700 dark:prose-p:text-gray-300 prose-a:text-orange-600 dark:prose-a:text-orange-400 prose-strong:text-gray-900 dark:prose-strong:text-gray-100 prose-code:text-orange-600 dark:prose-code:text-orange-400 prose-pre:bg-gray-100 dark:prose-pre:bg-gray-800 prose-blockquote:border-orange-300 dark:prose-blockquote:border-orange-600 prose-li:text-gray-700 dark:prose-li:text-gray-300 max-w-none">
<div
className="leading-relaxed"
dangerouslySetInnerHTML={{ __html: content }}
/>
</div>
</div>
</div>
)
}
// Utility function to read and process markdown files
export async function getMarkdownContent(filePath: string) {
try {
const fullPath = join(process.cwd(), 'src', filePath)
const fileContents = fs.readFileSync(fullPath, 'utf8')
// Process markdown to HTML using unified
const processedContent = await unified()
.use(remarkParse)
.use(remarkRehype, { allowDangerousHtml: true })
.use(rehypeStringify, { allowDangerousHtml: true })
.process(fileContents)
return {
content: processedContent.toString(),
}
} catch (error) {
console.error('Error reading markdown file:', error)
return {
content: '<p>Error loading content. Please try again later.</p>',
}
}
}

View File

@ -0,0 +1,161 @@
'use client'
import { useAtomValue } from 'jotai'
import { AnimatePresence, useDragControls } from 'motion/react'
import type { PointerEventHandler } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useEventCallback } from 'usehooks-ts'
import { Dialog, DialogContent } from '~/components/ui/dialog'
import { cn } from '~/lib/cn'
import { Spring } from '~/lib/spring'
import { jotaiStore } from '~/lib/store'
import { ModalContext } from './hooks'
import type { ModalItem } from './ModalManager'
import { Modal, modalItemsAtom } from './ModalManager'
import type { ModalComponent } from './types'
export const ModalContainer = () => {
const items = useAtomValue(modalItemsAtom)
return (
<div id="global-modal-container">
<AnimatePresence initial={false}>
{items.map((item) => (
<ModalWrapper key={item.id} item={item} />
))}
</AnimatePresence>
</div>
)
}
const ModalWrapper = ({ item }: { item: ModalItem }) => {
const [open, setOpen] = useState(true)
const modalRef = useRef<HTMLDivElement>(null)
useEffect(() => {
Modal.__registerCloser(item.id, () => setOpen(false))
return () => {
Modal.__unregisterCloser(item.id)
}
}, [item.id])
const dismiss = useMemo(
() => () => {
setOpen(false)
},
[],
)
const handleOpenChange = (o: boolean) => {
setOpen(o)
}
// After exit animation, remove from store
const handleAnimationComplete = useEventCallback(() => {
if (!open) {
const items = jotaiStore.get(modalItemsAtom)
jotaiStore.set(
modalItemsAtom,
items.filter((m) => m.id !== item.id),
)
}
})
// Calculate dynamic drag constraints based on actual modal size
const getDragConstraints = useCallback(() => {
if (!modalRef.current) {
return {
left: -window.innerWidth / 2 + 200,
right: window.innerWidth / 2 - 200,
top: -window.innerHeight / 2 + 150,
bottom: window.innerHeight / 2 - 150,
}
}
const modalRect = modalRef.current.getBoundingClientRect()
const viewportWidth = window.innerWidth
const viewportHeight = window.innerHeight
const padding = 20
// Calculate constraints to keep modal within viewport bounds
const maxLeft = -(viewportWidth / 2 - modalRect.width / 2 - padding)
const maxRight = viewportWidth / 2 - modalRect.width / 2 - padding
const maxTop = -(viewportHeight / 2 - modalRect.height / 2 - padding)
const maxBottom = viewportHeight / 2 - modalRect.height / 2 - padding
return {
left: maxLeft,
right: maxRight,
top: maxTop,
bottom: maxBottom,
}
}, [])
// Handle drag start to update constraints
const handleDragStart = useCallback(() => {
// Update constraints when drag starts to ensure they're based on current modal size
if (modalRef.current) {
return getDragConstraints()
}
}, [getDragConstraints])
const Component = item.component as ModalComponent<any>
const {
contentProps,
contentClassName,
showCloseButton,
disableDrag,
disableOverlayClickToClose,
disableTransition,
} = Component
const contextValue = useMemo(() => ({ dismiss }), [dismiss])
const dragControls = useDragControls()
const handleDrag: PointerEventHandler<HTMLDivElement> = useCallback(
(e) => {
dragControls.start(e)
},
[dragControls],
)
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent
ref={modalRef}
onInteractOutside={(e) => {
if (disableOverlayClickToClose) e.preventDefault()
}}
dragElastic={0}
dragListener={false}
dragMomentum={false}
className={cn('w-full max-w-md', contentClassName)}
transition={Spring.smooth(0.2, 0.1)}
onAnimationComplete={handleAnimationComplete}
drag={!disableDrag}
dragControls={dragControls}
dragConstraints={getDragConstraints()}
onDragStart={handleDragStart}
showCloseButton={showCloseButton}
disableOverlayClickToClose={disableOverlayClickToClose}
disableTransition={disableTransition}
{...contentProps}
{...item.modalContent}
>
<ModalContext value={contextValue}>
<div
className="absolute inset-x-0 top-0 h-6"
onPointerDownCapture={handleDrag}
/>
<Component
modalId={item.id}
dismiss={dismiss}
{...(item.props as any)}
/>
</ModalContext>
</DialogContent>
</Dialog>
)
}

View File

@ -0,0 +1,72 @@
'use client'
import { atom } from 'jotai'
import { jotaiStore } from '~/lib/store'
import type { ModalComponent, ModalContentConfig, ModalItem } from './types'
export const modalItemsAtom = atom<ModalItem[]>([])
const modalCloseRegistry = new Map<string, () => void>()
export const Modal = {
present<P = unknown>(
Component: ModalComponent<P>,
props?: P,
modalContent?: ModalContentConfig,
): string {
const items = jotaiStore.get(modalItemsAtom)
// Enforce single instance per ModalComponent. If an instance exists,
// move it to the top and update its props/content, returning its id.
const existingIndex = items.findIndex(
(m) => m.component === (Component as ModalComponent<any>),
)
if (existingIndex !== -1) {
const existing = items[existingIndex]
const updated = {
...existing,
// Update props/content when re-invoked
props: (props as any) ?? existing.props,
modalContent: modalContent ?? existing.modalContent,
}
const next = items.filter((_, i) => i !== existingIndex)
next.push(updated)
jotaiStore.set(modalItemsAtom, next)
return existing.id
}
const id = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`
jotaiStore.set(modalItemsAtom, [
...items,
{ id, component: Component as ModalComponent<any>, props, modalContent },
])
return id
},
dismiss(id: string): void {
const closer = modalCloseRegistry.get(id)
if (closer) {
closer()
return
}
// Fallback: remove immediately if closer not registered yet
const items = jotaiStore.get(modalItemsAtom)
jotaiStore.set(
modalItemsAtom,
items.filter((m) => m.id !== id),
)
},
/** Internal: used by container to manage close hooks */
__registerCloser(id: string, fn: () => void) {
modalCloseRegistry.set(id, fn)
},
__unregisterCloser(id: string) {
modalCloseRegistry.delete(id)
},
}
export { type ModalItem } from './types'

View File

@ -0,0 +1,9 @@
import { createContext, use } from 'react'
export const ModalContext = createContext({
dismiss: () => {},
})
export const useModal = () => {
return use(ModalContext)
}

View File

@ -0,0 +1,4 @@
export * from '../prompts/BasePrompt'
export * from './ModalContainer'
export * from './ModalManager'
export * from './types'

View File

@ -0,0 +1,26 @@
import type { FC } from 'react'
import type { DialogContentProps } from '../dialog'
export type ModalComponentProps = {
modalId: string
dismiss: () => void
}
export type ModalComponent<P = unknown> = FC<ModalComponentProps & P> & {
contentProps?: Partial<DialogContentProps>
contentClassName?: string
showCloseButton?: boolean
disableDrag?: boolean
disableOverlayClickToClose?: boolean
disableTransition?: boolean
}
export type ModalContentConfig = Partial<DialogContentProps>
export type ModalItem = {
id: string
component: ModalComponent<any>
props?: unknown
modalContent?: ModalContentConfig
}

View File

@ -0,0 +1,47 @@
import * as React from 'react'
import { cn } from '~/lib/cn'
export const PanelSplitter = (
props: React.DetailedHTMLProps<
React.HTMLAttributes<HTMLDivElement>,
HTMLDivElement
> & {
isDragging?: boolean
cursor?: string
tooltip?: React.ReactNode
},
) => {
const { isDragging, cursor, tooltip, className, ...rest } = props
React.useEffect(() => {
if (!isDragging) return
const $css = document.createElement('style')
$css.innerHTML = `
* {
cursor: ${cursor} !important;
}
`
document.head.append($css)
return () => {
$css.remove()
}
}, [cursor, isDragging])
return (
<div className="relative h-full w-0 shrink-0 z-3" data-hide-in-print>
<div
tabIndex={-1}
{...rest}
className={cn(
'active:bg-accent! absolute inset-0 z-3 w-[2px] -translate-x-1/2 cursor-ew-resize bg-transparent hover:bg-gray-400 hover:dark:bg-neutral-500',
isDragging ? 'bg-accent' : '',
className,
)}
/>
</div>
)
}

View File

@ -0,0 +1,20 @@
import type { FC, PropsWithChildren } from 'react'
import { createPortal } from 'react-dom'
import { useIsClient } from '~/hooks/common/use-is-client'
import { useRootPortal } from './provider'
export const RootPortal: FC<
{
to?: HTMLElement
} & PropsWithChildren
> = (props) => {
const isClient = useIsClient()
const to = useRootPortal()
if (!isClient) {
return null
}
return createPortal(props.children, props.to || to || document.body)
}

View File

@ -0,0 +1,19 @@
import { createContext, use } from 'react'
import { isClientSide } from '~/lib/env'
export const useRootPortal = () => {
const ctx = use(RootPortalContext)
if (!isClientSide) {
return null
}
return ctx.to || document.body
}
const RootPortalContext = createContext<{
to?: HTMLElement | undefined
}>({
to: undefined,
})
export const RootPortalProvider = RootPortalContext.Provider

View File

@ -0,0 +1,100 @@
'use client'
import { useState } from 'react'
import { Button } from '~/components/ui/button/Button'
import { Modal } from '~/components/ui/modal/ModalManager'
import type {
ModalComponent,
ModalComponentProps,
} from '~/components/ui/modal/types'
import {
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '../dialog'
type PromptVariant = 'danger' | 'info'
export type PromptOptions = {
title: string
description?: string
variant?: PromptVariant
onConfirmText?: string
onCancelText?: string
onConfirm?: () => void | Promise<void>
onCancel?: () => void | Promise<void>
content?: React.ReactNode
}
export const BasePrompt: ModalComponent<PromptOptions> = ({
modalId,
dismiss,
title,
description,
variant = 'info',
onConfirmText = 'Confirm',
onCancelText = 'Cancel',
onConfirm,
onCancel,
content,
}: ModalComponentProps & PromptOptions) => {
const [submitting, setSubmitting] = useState(false)
const handleCancel = async () => {
try {
await onCancel?.()
} finally {
dismiss()
}
}
const handleConfirm = async () => {
try {
setSubmitting(true)
await onConfirm?.()
} finally {
setSubmitting(false)
Modal.dismiss(modalId)
}
}
return (
<div>
<DialogHeader className="mb-2">
<DialogTitle>{title}</DialogTitle>
{description ? (
<DialogDescription className="text-text-secondary">
{description}
</DialogDescription>
) : null}
</DialogHeader>
{content != null ? <div className="mt-4">{content}</div> : null}
<DialogFooter className="mt-4">
<Button
size="sm"
variant="secondary"
onClick={handleCancel}
disabled={submitting}
>
{onCancelText}
</Button>
<Button
size="sm"
variant={variant === 'danger' ? 'destructive' : 'primary'}
onClick={handleConfirm}
isLoading={submitting}
loadingText={onConfirmText}
>
{onConfirmText}
</Button>
</DialogFooter>
</div>
)
}
BasePrompt.contentClassName = 'max-w-sm'
export type { PromptVariant }

View File

@ -0,0 +1,125 @@
'use client'
import { useState } from 'react'
import { Button } from '~/components/ui/button/Button'
import { Modal } from '~/components/ui/modal/ModalManager'
import type {
ModalComponent,
ModalComponentProps,
} from '~/components/ui/modal/types'
import {
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '../dialog'
import { Input } from '../input'
type InputPromptVariant = 'danger' | 'info'
export type InputPromptOptions = {
title: string
description?: string
defaultValue?: string
placeholder?: string
variant?: InputPromptVariant
type?: 'password' | 'text'
onConfirmText?: string
onCancelText?: string
onConfirm?: (value: string) => void | Promise<void>
onCancel?: () => void | Promise<void>
}
export const InputPrompt: ModalComponent<InputPromptOptions> = ({
modalId,
dismiss,
title,
description,
defaultValue = '',
placeholder,
variant = 'info',
type = 'text',
onConfirmText = 'Confirm',
onCancelText = 'Cancel',
onConfirm,
onCancel,
}: ModalComponentProps & InputPromptOptions) => {
const [inputValue, setInputValue] = useState(defaultValue)
const [submitting, setSubmitting] = useState(false)
const handleCancel = async () => {
try {
await onCancel?.()
} finally {
dismiss()
}
}
const handleConfirm = async () => {
try {
setSubmitting(true)
await onConfirm?.(inputValue)
} finally {
setSubmitting(false)
Modal.dismiss(modalId)
}
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
e.preventDefault()
handleConfirm()
} else if (e.key === 'Escape') {
e.preventDefault()
handleCancel()
}
}
return (
<div>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
{description ? (
<DialogDescription className="text-text-secondary">
{description}
</DialogDescription>
) : null}
</DialogHeader>
<div className="mt-4">
<Input
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
placeholder={placeholder}
onKeyDown={handleKeyDown}
autoFocus
type={type}
/>
</div>
<DialogFooter className="mt-4">
<Button
size="sm"
variant="secondary"
onClick={handleCancel}
disabled={submitting}
>
{onCancelText}
</Button>
<Button
size="sm"
variant={variant === 'danger' ? 'destructive' : 'primary'}
onClick={handleConfirm}
isLoading={submitting}
loadingText={onConfirmText}
>
{onConfirmText}
</Button>
</DialogFooter>
</div>
)
}
InputPrompt.contentClassName = 'max-w-sm'
export type { InputPromptVariant }

View File

@ -0,0 +1,26 @@
import { Modal } from '../modal'
import type { PromptOptions } from './BasePrompt'
import { BasePrompt } from './BasePrompt'
import type { InputPromptOptions } from './InputPrompt'
import { InputPrompt } from './InputPrompt'
export const Prompt = {
prompt(options: PromptOptions) {
return Modal.present(BasePrompt, options)
},
input(options: InputPromptOptions): Promise<string | null> {
return new Promise((resolve) => {
Modal.present(InputPrompt, {
...options,
onConfirm: async (value: string) => {
await options.onConfirm?.(value)
resolve(value)
},
onCancel: async () => {
await options.onCancel?.()
resolve(null)
},
})
})
},
}

View File

@ -0,0 +1,3 @@
export * from './BasePrompt'
export * from './InputPrompt'
export * from './Prompt'

View File

@ -0,0 +1,137 @@
'use client'
// Tremor RadioGroup [v1.0.0]
import type { HTMLMotionProps } from 'motion/react'
import { m as motion } from 'motion/react'
import { RadioGroup as RadioGroupPrimitives } from 'radix-ui'
import * as React from 'react'
import { cx, focusRing } from '~/lib/cn'
const RadioGroup = ({
ref: forwardedRef,
className,
...props
}: React.ComponentPropsWithoutRef<typeof RadioGroupPrimitives.Root> & {
ref?: React.RefObject<React.ElementRef<
typeof RadioGroupPrimitives.Root
> | null>
}) => {
return (
<RadioGroupPrimitives.Root
ref={forwardedRef}
className={cx('grid gap-2', className)}
tremor-id="tremor-raw"
{...props}
/>
)
}
RadioGroup.displayName = 'RadioGroup'
const RadioGroupIndicator = ({
ref: forwardedRef,
className,
...props
}: React.ComponentPropsWithoutRef<typeof RadioGroupPrimitives.Indicator> & {
ref?: React.RefObject<React.ElementRef<
typeof RadioGroupPrimitives.Indicator
> | null>
}) => {
return (
<RadioGroupPrimitives.Indicator
ref={forwardedRef}
className={cx('flex items-center justify-center', className)}
{...props}
asChild
>
<motion.div
className={cx(
// base
'size-1.5 shrink-0 rounded-full',
// indicator
'bg-white',
// disabled
'group-data-disabled:bg-disabled-control',
)}
initial={{ scale: 0, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0, opacity: 0 }}
transition={{
type: 'spring',
stiffness: 300,
damping: 30,
duration: 0.2,
}}
/>
</RadioGroupPrimitives.Indicator>
)
}
RadioGroupIndicator.displayName = 'RadioGroupIndicator'
const RadioGroupItem = ({
ref: forwardedRef,
className,
...props
}: React.ComponentPropsWithoutRef<typeof RadioGroupPrimitives.Item> &
HTMLMotionProps<'button'> & {
ref?: React.RefObject<React.ElementRef<
typeof RadioGroupPrimitives.Item
> | null>
}) => {
return (
<RadioGroupPrimitives.Item
ref={forwardedRef}
className={cx(
'group relative flex size-4 appearance-none items-center justify-center outline-hidden',
className,
)}
{...props}
asChild
>
<motion.button
whileTap={{ scale: 0.95 }}
whileHover={{ scale: 1.05 }}
transition={{
type: 'spring',
stiffness: 400,
damping: 25,
}}
>
<motion.div
className={cx(
// base
'flex size-4 shrink-0 items-center justify-center rounded-full border shadow-xs transition-colors duration-200',
// border color
'border-border',
// background color
'bg-background',
// checked
'group-data-[state=checked]:bg-accent group-data-[state=checked]:border-0 group-data-[state=checked]:border-transparent',
// disabled
'group-data-disabled:border',
'group-data-disabled:border-border group-data-disabled:bg-disabled-control group-data-disabled:text-disabled-text',
// focus
focusRing,
)}
animate={{
scale: props.checked ? 1.1 : 1,
}}
transition={{
type: 'spring',
stiffness: 300,
damping: 20,
duration: 0.15,
}}
>
<RadioGroupIndicator />
</motion.div>
</motion.button>
</RadioGroupPrimitives.Item>
)
}
RadioGroupItem.displayName = 'RadioGroupItem'
export { RadioGroup, RadioGroupItem }

Some files were not shown because too many files have changed in this diff Show More