feat(landing): merge landing app into monorepo (#4881)
This commit is contained in:
parent
b89290af8d
commit
5e26d1d33d
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
node_modules
|
||||
dist
|
||||
out
|
||||
.next
|
||||
.open-next
|
||||
next-env.d.ts
|
||||
.DS_Store
|
||||
*.log*
|
||||
.env
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
pnpm-lock.yaml
|
||||
|
||||
CHANGELOG.md
|
||||
.context
|
||||
|
||||
apps/external/postcss.config.cjs
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,5 @@
|
|||
import { factory } from '@innei/prettier'
|
||||
|
||||
export default factory({
|
||||
importSort: false,
|
||||
})
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
import { defineCloudflareConfig } from '@opennextjs/cloudflare'
|
||||
|
||||
export default defineCloudflareConfig()
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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}`,
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 121 KiB |
|
|
@ -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
|
||||
})
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
'use client'
|
||||
|
||||
import { init } from './init'
|
||||
|
||||
init()
|
||||
export const ClientInit = () => null
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
'use client'
|
||||
|
||||
import { init } from './init'
|
||||
|
||||
init()
|
||||
|
||||
export const InitInClient = () => {
|
||||
return null
|
||||
}
|
||||
|
|
@ -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 || '')} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -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} />
|
||||
}
|
||||
|
|
@ -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} />
|
||||
}
|
||||
|
|
@ -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',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
@import '../styles/globals.css';
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
import 'dayjs/locale/zh-cn'
|
||||
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
export const init = () => {
|
||||
dayjs.locale('zh-cn')
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
import './globals.css'
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return children
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
import type { MetadataRoute } from 'next'
|
||||
|
||||
export default function robots(): MetadataRoute.Robots {
|
||||
return {
|
||||
rules: {
|
||||
userAgent: '*',
|
||||
allow: '/',
|
||||
disallow: ['/login/'],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
import { createAtomHooks } from 'jojoo/react'
|
||||
import { atom } from 'jotai'
|
||||
|
||||
export const [, , useIsPrintMode, , , setIsPrintMode] = createAtomHooks(
|
||||
atom(false),
|
||||
)
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
export * from './css-media'
|
||||
export * from './viewport'
|
||||
|
|
@ -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)
|
||||
|
|
@ -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,
|
||||
[],
|
||||
),
|
||||
)
|
||||
|
|
@ -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>
|
||||
)
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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}</>
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
@ -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}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -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,
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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} />
|
||||
}
|
||||
|
|
@ -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'
|
||||
|
|
@ -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} />
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
export * from './Content'
|
||||
|
|
@ -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'
|
||||
|
|
@ -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 />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 }),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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,
|
||||
})
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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'
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
export * from './Button'
|
||||
export * from './MotionButton'
|
||||
|
|
@ -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 }
|
||||
|
|
@ -0,0 +1 @@
|
|||
export * from './Checkbox'
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
export * from './CollapseCss'
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
export {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
type DialogContentProps,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from './Dialog'
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
export * from './Divider'
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -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'
|
||||
|
|
@ -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
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
export {
|
||||
HoverCard,
|
||||
HoverCardArrow,
|
||||
HoverCardContent,
|
||||
type HoverCardContentProps,
|
||||
type HoverCardProps,
|
||||
HoverCardTrigger,
|
||||
type HoverCardTriggerProps,
|
||||
} from './HoverCard'
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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 }
|
||||
|
|
@ -0,0 +1 @@
|
|||
export * from './Input'
|
||||
|
|
@ -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('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
|
||||
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
|
||||
}
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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)]" />
|
||||
)
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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'
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
import { createContext, use } from 'react'
|
||||
|
||||
export const ModalContext = createContext({
|
||||
dismiss: () => {},
|
||||
})
|
||||
|
||||
export const useModal = () => {
|
||||
return use(ModalContext)
|
||||
}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
export * from '../prompts/BasePrompt'
|
||||
export * from './ModalContainer'
|
||||
export * from './ModalManager'
|
||||
export * from './types'
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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)
|
||||
},
|
||||
})
|
||||
})
|
||||
},
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
export * from './BasePrompt'
|
||||
export * from './InputPrompt'
|
||||
export * from './Prompt'
|
||||
|
|
@ -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
Loading…
Reference in New Issue