Merge branch 'main' of https://github.com/MkSaaSHQ/mksaas-template into dev/credits

This commit is contained in:
javayhu 2025-06-28 23:12:38 +08:00
commit 82d0fa1061
37 changed files with 2429 additions and 71 deletions

View File

@ -25,20 +25,21 @@ If you found anything that could be improved, please let me know.
## Repositories
By default, you should have access to all four repositories. If you find that youre unable to access any of them, please dont hesitate to reach out to me, and Ill assist you in resolving the issue.
By default, you should have access to all 5 repositories. If you find that youre unable to access any of them, please dont hesitate to reach out to me, and Ill assist you in resolving the issue.
- [mksaas-template (ready)](https://github.com/MkSaaSHQ/mksaas-template): https://demo.mksaas.com
- [mksaas-blog (ready)](https://github.com/MkSaaSHQ/mksaas-blog): https://mksaas.me
- [mksaas-haitang (ready)](https://github.com/MkSaaSHQ/mksaas-haitang): https://haitang.app
- [mksaas-outfiai (ready)](https://github.com/MkSaaSHQ/mksaas-outfiai)
- [mksaas-app (WIP)](https://github.com/MkSaaSHQ/mksaas-app): https://mksaas.app
## Notice
> If you have any questions, please [submit an issue](https://github.com/MkSaaSHQ/mksaas-template/issues/new), or contact me at [support@mksaas.com](mailto:support@mksaas.com).
> If you have any questions, please [submit an issue](https://github.com/MkSaaSHQ/mksaas-template/issues/new), or contact me at [support@mksaas.com](mailto:support@mksaas.com), or join our [discord community](https://mksaas.link/discord) and ask for help there.
> If you want to receive notifications whenever code changes, please click `Watch` button in the top right.
> When submitting any content to the issues or discussions of the repository, please use **English** as the main Language, so that everyone can read it and help you, thank you for your supports.
> When submitting any content to the issues of the repository, please use **English** as the main Language, so that everyone can read it and help you, thank you for your supports.
## License

View File

@ -119,8 +119,8 @@ NEXT_PUBLIC_SELINE_TOKEN=""
# DataFast Analytics (https://datafa.st)
# https://mksaas.com/docs/analytics#datafast
# -----------------------------------------------------------------------------
NEXT_PUBLIC_DATAFAST_ANALYTICS_ID=""
NEXT_PUBLIC_DATAFAST_ANALYTICS_DOMAIN=""
NEXT_PUBLIC_DATAFAST_WEBSITE_ID=""
NEXT_PUBLIC_DATAFAST_DOMAIN=""
# -----------------------------------------------------------------------------
@ -148,3 +148,19 @@ NEXT_PUBLIC_AFFILIATE_AFFONSO_ID=""
# https://www.promotekit.com/
# -----------------------------------------------------------------------------
NEXT_PUBLIC_AFFILIATE_PROMOTEKIT_ID=""
# -----------------------------------------------------------------------------
# Captcha (Cloudflare Turnstile)
# https://mksaas.com/docs/captcha#setup
# -----------------------------------------------------------------------------
NEXT_PUBLIC_TURNSTILE_SITE_KEY=""
TURNSTILE_SECRET_KEY=""
# -----------------------------------------------------------------------------
# AI
# https://mksaas.com/docs/ai
# -----------------------------------------------------------------------------
FAL_API_KEY=""
FIREWORKS_API_KEY=""
OPENAI_API_KEY=""
REPLICATE_API_TOKEN=""

View File

@ -202,7 +202,9 @@
"hidePassword": "Hide password",
"nameRequired": "Please enter your name",
"emailRequired": "Please enter your email",
"passwordRequired": "Please enter your password"
"passwordRequired": "Please enter your password",
"captchaInvalid": "Captcha verification failed",
"captchaError": "Captcha verification error"
},
"forgotPassword": {
"title": "Forgot Password",

View File

@ -203,7 +203,9 @@
"hidePassword": "隐藏密码",
"nameRequired": "请输入姓名",
"emailRequired": "请输入邮箱",
"passwordRequired": "请输入密码"
"passwordRequired": "请输入密码",
"captchaInvalid": "验证码验证失败",
"captchaError": "验证码验证出错"
},
"forgotPassword": {
"title": "忘记密码",

View File

@ -24,7 +24,11 @@
"knip": "knip"
},
"dependencies": {
"@ai-sdk/fal": "^0.1.12",
"@ai-sdk/fireworks": "^0.2.14",
"@ai-sdk/google-vertex": "^2.2.24",
"@ai-sdk/openai": "^1.1.13",
"@ai-sdk/replicate": "^0.2.8",
"@base-ui-components/react": "1.0.0-beta.0",
"@better-fetch/fetch": "^1.1.18",
"@dnd-kit/core": "^6.3.1",
@ -32,6 +36,7 @@
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@hookform/resolvers": "^4.1.0",
"@marsidev/react-turnstile": "^1.1.0",
"@next/third-parties": "^15.3.0",
"@openpanel/nextjs": "^1.0.7",
"@orama/orama": "^3.1.4",

301
pnpm-lock.yaml generated
View File

@ -8,9 +8,21 @@ importers:
.:
dependencies:
'@ai-sdk/fal':
specifier: ^0.1.12
version: 0.1.12(zod@3.25.64)
'@ai-sdk/fireworks':
specifier: ^0.2.14
version: 0.2.14(zod@3.25.64)
'@ai-sdk/google-vertex':
specifier: ^2.2.24
version: 2.2.24(zod@3.25.64)
'@ai-sdk/openai':
specifier: ^1.1.13
version: 1.1.13(zod@3.25.64)
'@ai-sdk/replicate':
specifier: ^0.2.8
version: 0.2.8(zod@3.25.64)
'@base-ui-components/react':
specifier: 1.0.0-beta.0
version: 1.0.0-beta.0(@types/react@19.0.9)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
@ -32,6 +44,9 @@ importers:
'@hookform/resolvers':
specifier: ^4.1.0
version: 4.1.0(react-hook-form@7.54.2(react@19.0.0))
'@marsidev/react-turnstile':
specifier: ^1.1.0
version: 1.1.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
'@next/third-parties':
specifier: ^15.3.0
version: 15.3.0(next@15.2.1(@babel/core@7.24.5)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0)
@ -351,6 +366,42 @@ importers:
packages:
'@ai-sdk/anthropic@1.2.12':
resolution: {integrity: sha512-YSzjlko7JvuiyQFmI9RN1tNZdEiZxc+6xld/0tq/VkJaHpEzGAb1yiNxxvmYVcjvfu/PcvCxAAYXmTYQQ63IHQ==}
engines: {node: '>=18'}
peerDependencies:
zod: ^3.0.0
'@ai-sdk/fal@0.1.12':
resolution: {integrity: sha512-Z0pUUR3qwLTj4HXgGJSes5fwjUbSowsMiKbpYKGl6V51sQeUk2EjZctdN4+a+GBuDNCP6Y32Wi8ejM54OMee+w==}
engines: {node: '>=18'}
peerDependencies:
zod: ^3.0.0
'@ai-sdk/fireworks@0.2.14':
resolution: {integrity: sha512-0xlh95Y+L9ccc7hwrjdFKi4u8dirx24FLc70ySXA53u1zZP6R1W35TBYGaLzFpTVhhBhDTOca0mE+/EjJihcxw==}
engines: {node: '>=18'}
peerDependencies:
zod: ^3.0.0
'@ai-sdk/google-vertex@2.2.24':
resolution: {integrity: sha512-zi1ZN6jQEBRke/WMbZv0YkeqQ3nOs8ihxjVh/8z1tUn+S1xgRaYXf4+r6+Izh2YqVHIMNwjhUYryQRBGq20cgQ==}
engines: {node: '>=18'}
peerDependencies:
zod: ^3.0.0
'@ai-sdk/google@1.2.19':
resolution: {integrity: sha512-Xgl6eftIRQ4srUdCzxM112JuewVMij5q4JLcNmHcB68Bxn9dpr3MVUSPlJwmameuiQuISIA8lMB+iRiRbFsaqA==}
engines: {node: '>=18'}
peerDependencies:
zod: ^3.0.0
'@ai-sdk/openai-compatible@0.2.14':
resolution: {integrity: sha512-icjObfMCHKSIbywijaoLdZ1nSnuRnWgMEMLgwoxPJgxsUHMx0aVORnsLUid4SPtdhHI3X2masrt6iaEQLvOSFw==}
engines: {node: '>=18'}
peerDependencies:
zod: ^3.0.0
'@ai-sdk/openai@1.1.13':
resolution: {integrity: sha512-IdChK1pJTW3NQis02PG/hHTG0gZSyQIMOLPt7f7ES56C0xH2yaKOU1Tp2aib7pZzWGwDlzTOW2h5TtAB8+V6CQ==}
engines: {node: '>=18'}
@ -366,10 +417,20 @@ packages:
zod:
optional: true
'@ai-sdk/provider-utils@2.2.8':
resolution: {integrity: sha512-fqhG+4sCVv8x7nFzYnFo19ryhAa3w096Kmc3hWxMQfW/TubPOmt3A6tYZhl4mUfQWWQMsuSkLrtjlWuXBVSGQA==}
engines: {node: '>=18'}
peerDependencies:
zod: ^3.23.8
'@ai-sdk/provider@1.0.8':
resolution: {integrity: sha512-f9jSYwKMdXvm44Dmab1vUBnfCDSFfI5rOtvV1W9oKB7WYHR5dGvCC6x68Mk3NUfrdmNoMVHGoh6JT9HCVMlMow==}
engines: {node: '>=18'}
'@ai-sdk/provider@1.1.3':
resolution: {integrity: sha512-qZMxYJ0qqX/RfnuIaab+zp8UAeJn/ygXXAffR5I4N0n1IrvA6qBsjc8hXLmBiMV2zoXlifkacF7sEFnYnjBcqg==}
engines: {node: '>=18'}
'@ai-sdk/react@1.1.17':
resolution: {integrity: sha512-NAuEflFvjw1uh1AOmpyi7rBF4xasWsiWUb86JQ8ScjDGxoGDYEdBnaHOxUpooLna0dGNbSPkvDMnVRhoLKoxPQ==}
engines: {node: '>=18'}
@ -382,6 +443,12 @@ packages:
zod:
optional: true
'@ai-sdk/replicate@0.2.8':
resolution: {integrity: sha512-l9t4+RzbAn8osstkbWs6l++Nava+4LO4dsaddnE0GQM5E0BEIgMTJ14hoyfE02Ep0rJZ0M2HlXGqv5heW47P8A==}
engines: {node: '>=18'}
peerDependencies:
zod: ^3.0.0
'@ai-sdk/ui-utils@1.1.15':
resolution: {integrity: sha512-NsV/3CMmjc4m53snzRdtZM6teTQUXIKi8u0Kf7GBruSzaMSuZ4DWaAAlUshhR3p2FpZgtsogW+vYG1/rXsGu+Q==}
engines: {node: '>=18'}
@ -1515,6 +1582,12 @@ packages:
'@levischuck/tiny-cbor@0.2.11':
resolution: {integrity: sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow==}
'@marsidev/react-turnstile@1.1.0':
resolution: {integrity: sha512-X7bP9ZYutDd+E+klPYF+/BJHqEyyVkN4KKmZcNRr84zs3DcMoftlMAuoKqNSnqg0HE7NQ1844+TLFSJoztCdSA==}
peerDependencies:
react: ^17.0.2 || ^18.0.0 || ^19.0
react-dom: ^17.0.2 || ^18.0.0 || ^19.0
'@mdx-js/mdx@3.1.0':
resolution: {integrity: sha512-/QxEhPAvGwbQmy1Px8F899L5Uc2KZ6JtXwlCgJmjSTBedwOZkByYcBG4GceIGPXRDsmfxhHazuS+hlOShRLeDw==}
@ -3554,6 +3627,10 @@ packages:
engines: {node: '>=0.4.0'}
hasBin: true
agent-base@7.1.3:
resolution: {integrity: sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==}
engines: {node: '>= 14'}
ai@4.1.45:
resolution: {integrity: sha512-nQkxQ2zCD+O/h8zJ+PxmBv9coyMaG1uP9kGJvhNaGAA25hbZRQWL0NbTsSJ/QMOUraXKLa+6fBm3VF1NkJK9Kg==}
engines: {node: '>=18'}
@ -3616,6 +3693,9 @@ packages:
better-call@0.3.3:
resolution: {integrity: sha512-N4lDVm0NGmFfDJ0XMQ4O83Zm/3dPlvIQdxvwvgSLSkjFX5PM4GUYSVAuxNzXN27QZMHDkrJTWUqxBrm4tPC3eA==}
bignumber.js@9.3.0:
resolution: {integrity: sha512-EM7aMFTXbptt/wZdMlBv2t8IViwQL+h6SLHosp8Yf0dqJMTnY6iL32opnAB6kAdL0SZPuvcAzFr31o0c/R3/RA==}
bindings@1.5.0:
resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==}
@ -3634,6 +3714,9 @@ packages:
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
hasBin: true
buffer-equal-constant-time@1.0.1:
resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==}
buffer-from@1.1.2:
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
@ -3998,6 +4081,9 @@ packages:
eastasianwidth@0.2.0:
resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==}
ecdsa-sig-formatter@1.0.11:
resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==}
electron-to-chromium@1.5.113:
resolution: {integrity: sha512-wjT2O4hX+wdWPJ76gWSkMhcHAV2PTMX+QetUCPYEdCIe+cxmgzzSSiGRCKW8nuh4mwKZlpv0xvoW7OF2X+wmHg==}
@ -4242,6 +4328,14 @@ packages:
function-bind@1.1.2:
resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
gaxios@6.7.1:
resolution: {integrity: sha512-LDODD4TMYx7XXdpwxAVRAIAuB0bzv0s+ywFonY46k126qzQHT9ygyoa9tncmOiQmmDrik65UYsEkv3lbfqQ3yQ==}
engines: {node: '>=14'}
gcp-metadata@6.1.1:
resolution: {integrity: sha512-a4tiq7E0/5fTjxPAaH4jpjkSv/uCaU2p5KC6HVGrvl0cDjA8iBZv4vv1gyzlmK0ZUKqwpOyQMKzZQe3lTit77A==}
engines: {node: '>=14'}
gensync@1.0.0-beta.2:
resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==}
engines: {node: '>=6.9.0'}
@ -4277,6 +4371,14 @@ packages:
resolution: {integrity: sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==}
engines: {node: '>=4'}
google-auth-library@9.15.1:
resolution: {integrity: sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng==}
engines: {node: '>=14'}
google-logging-utils@0.0.2:
resolution: {integrity: sha512-NEgUnEcBiP5HrPzufUkBzJOD/Sxsco3rLNo1F1TNf7ieU8ryUzBhqba8r756CjLX7rn3fHl6iLEwPYuqpoKgQQ==}
engines: {node: '>=14'}
gopd@1.2.0:
resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==}
engines: {node: '>= 0.4'}
@ -4284,6 +4386,10 @@ packages:
graceful-fs@4.2.11:
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
gtoken@7.1.0:
resolution: {integrity: sha512-pCcEwRi+TKpMlxAQObHDQ56KawURgyAf6jtIY046fJ5tIv3zDe/LEIubckAO8fj6JnAxLdmWkUfNyulQ2iKdEw==}
engines: {node: '>=14.0.0'}
has-flag@4.0.0:
resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
engines: {node: '>=8'}
@ -4327,6 +4433,10 @@ packages:
htmlparser2@8.0.2:
resolution: {integrity: sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==}
https-proxy-agent@7.0.6:
resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==}
engines: {node: '>= 14'}
ieee754@1.2.1:
resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==}
@ -4393,6 +4503,10 @@ packages:
resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==}
engines: {node: '>=12'}
is-stream@2.0.1:
resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==}
engines: {node: '>=8'}
is-unicode-supported@0.1.0:
resolution: {integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==}
engines: {node: '>=10'}
@ -4423,6 +4537,9 @@ packages:
engines: {node: '>=6'}
hasBin: true
json-bigint@1.0.0:
resolution: {integrity: sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==}
json-schema@0.4.0:
resolution: {integrity: sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==}
@ -4436,6 +4553,12 @@ packages:
engines: {node: ^18.0.0 || >=20.0.0}
hasBin: true
jwa@2.0.1:
resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==}
jws@4.0.0:
resolution: {integrity: sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg==}
knip@5.61.2:
resolution: {integrity: sha512-ZBv37zDvZj0/Xwk0e93xSjM3+5bjxgqJ0PH2GlB5tnWV0ktXtmatWLm+dLRUCT/vpO3SdGz2nNAfvVhuItUNcQ==}
engines: {node: '>=18.18.0'}
@ -4890,6 +5013,15 @@ packages:
sass:
optional: true
node-fetch@2.7.0:
resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==}
engines: {node: 4.x || >=6.0.0}
peerDependencies:
encoding: ^0.1.0
peerDependenciesMeta:
encoding:
optional: true
node-releases@2.0.19:
resolution: {integrity: sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==}
@ -5535,6 +5667,9 @@ packages:
resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
engines: {node: '>=8.0'}
tr46@0.0.3:
resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==}
trim-lines@3.0.1:
resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==}
@ -5638,6 +5773,10 @@ packages:
util-deprecate@1.0.2:
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
uuid@9.0.1:
resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==}
hasBin: true
vary@1.1.2:
resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==}
engines: {node: '>= 0.8'}
@ -5664,6 +5803,12 @@ packages:
wcwidth@1.0.1:
resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==}
webidl-conversions@3.0.1:
resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==}
whatwg-url@5.0.0:
resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==}
which@2.0.2:
resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
engines: {node: '>= 8'}
@ -5739,6 +5884,49 @@ packages:
snapshots:
'@ai-sdk/anthropic@1.2.12(zod@3.25.64)':
dependencies:
'@ai-sdk/provider': 1.1.3
'@ai-sdk/provider-utils': 2.2.8(zod@3.25.64)
zod: 3.25.64
'@ai-sdk/fal@0.1.12(zod@3.25.64)':
dependencies:
'@ai-sdk/provider': 1.1.3
'@ai-sdk/provider-utils': 2.2.8(zod@3.25.64)
zod: 3.25.64
'@ai-sdk/fireworks@0.2.14(zod@3.25.64)':
dependencies:
'@ai-sdk/openai-compatible': 0.2.14(zod@3.25.64)
'@ai-sdk/provider': 1.1.3
'@ai-sdk/provider-utils': 2.2.8(zod@3.25.64)
zod: 3.25.64
'@ai-sdk/google-vertex@2.2.24(zod@3.25.64)':
dependencies:
'@ai-sdk/anthropic': 1.2.12(zod@3.25.64)
'@ai-sdk/google': 1.2.19(zod@3.25.64)
'@ai-sdk/provider': 1.1.3
'@ai-sdk/provider-utils': 2.2.8(zod@3.25.64)
google-auth-library: 9.15.1
zod: 3.25.64
transitivePeerDependencies:
- encoding
- supports-color
'@ai-sdk/google@1.2.19(zod@3.25.64)':
dependencies:
'@ai-sdk/provider': 1.1.3
'@ai-sdk/provider-utils': 2.2.8(zod@3.25.64)
zod: 3.25.64
'@ai-sdk/openai-compatible@0.2.14(zod@3.25.64)':
dependencies:
'@ai-sdk/provider': 1.1.3
'@ai-sdk/provider-utils': 2.2.8(zod@3.25.64)
zod: 3.25.64
'@ai-sdk/openai@1.1.13(zod@3.25.64)':
dependencies:
'@ai-sdk/provider': 1.0.8
@ -5754,10 +5942,21 @@ snapshots:
optionalDependencies:
zod: 3.25.64
'@ai-sdk/provider-utils@2.2.8(zod@3.25.64)':
dependencies:
'@ai-sdk/provider': 1.1.3
nanoid: 3.3.8
secure-json-parse: 2.7.0
zod: 3.25.64
'@ai-sdk/provider@1.0.8':
dependencies:
json-schema: 0.4.0
'@ai-sdk/provider@1.1.3':
dependencies:
json-schema: 0.4.0
'@ai-sdk/react@1.1.17(react@19.0.0)(zod@3.25.64)':
dependencies:
'@ai-sdk/provider-utils': 2.1.9(zod@3.25.64)
@ -5768,6 +5967,12 @@ snapshots:
react: 19.0.0
zod: 3.25.64
'@ai-sdk/replicate@0.2.8(zod@3.25.64)':
dependencies:
'@ai-sdk/provider': 1.1.3
'@ai-sdk/provider-utils': 2.2.8(zod@3.25.64)
zod: 3.25.64
'@ai-sdk/ui-utils@1.1.15(zod@3.25.64)':
dependencies:
'@ai-sdk/provider': 1.0.8
@ -6542,6 +6747,11 @@ snapshots:
'@levischuck/tiny-cbor@0.2.11': {}
'@marsidev/react-turnstile@1.1.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
dependencies:
react: 19.0.0
react-dom: 19.0.0(react@19.0.0)
'@mdx-js/mdx@3.1.0(acorn@8.14.0)':
dependencies:
'@types/estree': 1.0.6
@ -8609,6 +8819,8 @@ snapshots:
acorn@8.14.0: {}
agent-base@7.1.3: {}
ai@4.1.45(react@19.0.0)(zod@3.25.64):
dependencies:
'@ai-sdk/provider': 1.0.8
@ -8675,6 +8887,8 @@ snapshots:
uncrypto: 0.1.3
zod: 3.25.64
bignumber.js@9.3.0: {}
bindings@1.5.0:
dependencies:
file-uri-to-path: 1.0.0
@ -8701,6 +8915,8 @@ snapshots:
node-releases: 2.0.19
update-browserslist-db: 1.1.3(browserslist@4.24.4)
buffer-equal-constant-time@1.0.1: {}
buffer-from@1.1.2: {}
buffer@5.7.1:
@ -8964,6 +9180,10 @@ snapshots:
eastasianwidth@0.2.0: {}
ecdsa-sig-formatter@1.0.11:
dependencies:
safe-buffer: 5.2.1
electron-to-chromium@1.5.113: {}
embla-carousel-react@8.5.2(react@19.0.0):
@ -9351,6 +9571,26 @@ snapshots:
function-bind@1.1.2: {}
gaxios@6.7.1:
dependencies:
extend: 3.0.2
https-proxy-agent: 7.0.6
is-stream: 2.0.1
node-fetch: 2.7.0
uuid: 9.0.1
transitivePeerDependencies:
- encoding
- supports-color
gcp-metadata@6.1.1:
dependencies:
gaxios: 6.7.1
google-logging-utils: 0.0.2
json-bigint: 1.0.0
transitivePeerDependencies:
- encoding
- supports-color
gensync@1.0.0-beta.2: {}
get-intrinsic@1.2.7:
@ -9393,10 +9633,32 @@ snapshots:
globals@11.12.0: {}
google-auth-library@9.15.1:
dependencies:
base64-js: 1.5.1
ecdsa-sig-formatter: 1.0.11
gaxios: 6.7.1
gcp-metadata: 6.1.1
gtoken: 7.1.0
jws: 4.0.0
transitivePeerDependencies:
- encoding
- supports-color
google-logging-utils@0.0.2: {}
gopd@1.2.0: {}
graceful-fs@4.2.11: {}
gtoken@7.1.0:
dependencies:
gaxios: 6.7.1
jws: 4.0.0
transitivePeerDependencies:
- encoding
- supports-color
has-flag@4.0.0: {}
has-symbols@1.1.0: {}
@ -9526,6 +9788,13 @@ snapshots:
domutils: 3.2.2
entities: 4.5.0
https-proxy-agent@7.0.6:
dependencies:
agent-base: 7.1.3
debug: 4.4.0
transitivePeerDependencies:
- supports-color
ieee754@1.2.1: {}
image-size@2.0.2: {}
@ -9576,6 +9845,8 @@ snapshots:
is-plain-obj@4.1.0: {}
is-stream@2.0.1: {}
is-unicode-supported@0.1.0: {}
isexe@2.0.0: {}
@ -9598,6 +9869,10 @@ snapshots:
jsesc@3.1.0: {}
json-bigint@1.0.0:
dependencies:
bignumber.js: 9.3.0
json-schema@0.4.0: {}
json5@2.2.3: {}
@ -9608,6 +9883,17 @@ snapshots:
chalk: 5.4.1
diff-match-patch: 1.0.5
jwa@2.0.1:
dependencies:
buffer-equal-constant-time: 1.0.1
ecdsa-sig-formatter: 1.0.11
safe-buffer: 5.2.1
jws@4.0.0:
dependencies:
jwa: 2.0.1
safe-buffer: 5.2.1
knip@5.61.2(@types/node@20.19.0)(typescript@5.8.3):
dependencies:
'@nodelib/fs.walk': 1.2.8
@ -10282,6 +10568,10 @@ snapshots:
- '@babel/core'
- babel-plugin-macros
node-fetch@2.7.0:
dependencies:
whatwg-url: 5.0.0
node-releases@2.0.19: {}
normalize-path@3.0.0: {}
@ -11125,6 +11415,8 @@ snapshots:
dependencies:
is-number: 7.0.0
tr46@0.0.3: {}
trim-lines@3.0.1: {}
trough@2.2.0: {}
@ -11231,6 +11523,8 @@ snapshots:
util-deprecate@1.0.2: {}
uuid@9.0.1: {}
vary@1.1.2: {}
vaul@1.1.2(@types/react-dom@19.0.3(@types/react@19.0.9))(@types/react@19.0.9)(react-dom@19.0.0(react@19.0.0))(react@19.0.0):
@ -11275,6 +11569,13 @@ snapshots:
dependencies:
defaults: 1.0.4
webidl-conversions@3.0.1: {}
whatwg-url@5.0.0:
dependencies:
tr46: 0.0.3
webidl-conversions: 3.0.1
which@2.0.2:
dependencies:
isexe: 2.0.0

View File

@ -1,5 +1,6 @@
'use server';
import { websiteConfig } from '@/config/website';
import { findPlanByPlanId } from '@/lib/price-plan';
import { getSession } from '@/lib/server';
import { getUrlWithLocale } from '@/lib/urls/urls';
@ -8,6 +9,7 @@ import type { CreateCheckoutParams } from '@/payment/types';
import { Routes } from '@/routes';
import { getLocale } from 'next-intl/server';
import { createSafeActionClient } from 'next-safe-action';
import { cookies } from 'next/headers';
import { z } from 'zod';
// Create a safe action client
@ -67,12 +69,22 @@ export const createCheckoutAction = actionClient
}
// Add user id to metadata, so we can get it in the webhook event
const customMetadata = {
const customMetadata: Record<string, string> = {
...metadata,
userId: session.user.id,
userName: session.user.name,
};
// https://datafa.st/docs/stripe-checkout-api
// if datafast analytics is enabled, add the revenue attribution to the metadata
if (websiteConfig.features.enableDatafastRevenueTrack) {
const cookieStore = await cookies();
customMetadata.datafast_visitor_id =
cookieStore.get('datafast_visitor_id')?.value ?? '';
customMetadata.datafast_session_id =
cookieStore.get('datafast_session_id')?.value ?? '';
}
// Create the checkout session with localized URLs
const successUrl = getUrlWithLocale(
'/settings/billing?session_id={CHECKOUT_SESSION_ID}',

View File

@ -0,0 +1,36 @@
'use server';
import { validateTurnstileToken } from '@/lib/captcha';
import { createSafeActionClient } from 'next-safe-action';
import { z } from 'zod';
// Create a safe action client
const actionClient = createSafeActionClient();
// Captcha validation schema
const captchaSchema = z.object({
captchaToken: z.string().min(1, { message: 'Captcha token is required' }),
});
// Create a safe action for captcha validation
export const validateCaptchaAction = actionClient
.schema(captchaSchema)
.action(async ({ parsedInput }) => {
const { captchaToken } = parsedInput;
try {
const isValid = await validateTurnstileToken(captchaToken);
return {
success: true,
valid: isValid,
};
} catch (error) {
console.error('Captcha validation error:', error);
return {
success: false,
valid: false,
error: error instanceof Error ? error.message : 'Something went wrong',
};
}
});

View File

@ -0,0 +1,102 @@
'use client';
import {
Carousel,
type CarouselApi,
CarouselContent,
CarouselItem,
CarouselNext,
CarouselPrevious,
} from '@/components/ui/carousel';
import { cn } from '@/lib/utils';
import { useEffect, useState } from 'react';
import type { GeneratedImage, ProviderTiming } from '../lib/image-types';
import type { ProviderKey } from '../lib/provider-config';
import { ImageDisplay } from './ImageDisplay';
interface ImageCarouselProps {
providers: ProviderKey[];
images: GeneratedImage[];
timings: Record<ProviderKey, ProviderTiming>;
failedProviders: ProviderKey[];
enabledProviders: Record<ProviderKey, boolean>;
providerToModel: Record<ProviderKey, string>;
}
export function ImageCarousel({
providers,
images,
timings,
failedProviders,
enabledProviders,
providerToModel,
}: ImageCarouselProps) {
const [currentSlide, setCurrentSlide] = useState(0);
const [api, setApi] = useState<CarouselApi>();
useEffect(() => {
if (!api) return;
api.on('select', () => {
setCurrentSlide(api.selectedScrollSnap());
});
}, [api]);
return (
<div className="relative w-full">
<Carousel setApi={setApi} opts={{ align: 'start', loop: true }}>
<CarouselContent>
{providers.map((provider, i) => {
const imageData = images?.find(
(img) => img.provider === provider
)?.image;
const timing = timings[provider];
return (
<CarouselItem key={provider}>
<ImageDisplay
modelId={
images?.find((img) => img.provider === provider)?.modelId ||
providerToModel[provider]
}
provider={provider}
image={imageData}
timing={timing}
failed={failedProviders.includes(provider)}
enabled={enabledProviders[provider]}
/>
<div className="text-center text-sm text-muted-foreground mt-4">
{i + 1} of {providers.length}
</div>
</CarouselItem>
);
})}
</CarouselContent>
<CarouselPrevious className="left-0 bg-background/80 backdrop-blur-sm" />
<CarouselNext className="right-0 bg-background/80 backdrop-blur-sm" />
</Carousel>
{/* Dot Indicators */}
<div className="absolute -bottom-6 left-0 right-0">
<div className="flex justify-center gap-1">
{providers.map((_, index) => (
<button
type="button"
key={index}
className={cn(
'h-1.5 rounded-full transition-all',
index === currentSlide
? 'w-4 bg-primary'
: 'w-1.5 bg-primary/50'
)}
onClick={() => api?.scrollTo(index)}
>
<span className="sr-only">Go to image {index + 1}</span>
</button>
))}
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,195 @@
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { cn } from '@/lib/utils';
import { AlertCircle, Download, ImageIcon, Share } from 'lucide-react';
import { useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import { imageHelpers } from '../lib/image-helpers';
import type { ProviderTiming } from '../lib/image-types';
import { Stopwatch } from './Stopwatch';
interface ImageDisplayProps {
provider: string;
image: string | null | undefined;
timing?: ProviderTiming;
failed?: boolean;
fallbackIcon?: React.ReactNode;
enabled?: boolean;
modelId: string;
}
export function ImageDisplay({
provider,
image,
timing,
failed,
fallbackIcon,
modelId,
}: ImageDisplayProps) {
const [isZoomed, setIsZoomed] = useState(false);
useEffect(() => {
if (isZoomed) {
window.history.pushState({ zoomed: true }, '');
}
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape' && isZoomed) {
setIsZoomed(false);
}
};
const handlePopState = () => {
if (isZoomed) {
setIsZoomed(false);
}
};
if (isZoomed) {
document.addEventListener('keydown', handleEscape);
window.addEventListener('popstate', handlePopState);
}
return () => {
document.removeEventListener('keydown', handleEscape);
window.removeEventListener('popstate', handlePopState);
};
}, [isZoomed]);
const handleImageClick = (e: React.MouseEvent) => {
if (image) {
e.stopPropagation();
setIsZoomed(true);
}
};
const handleActionClick = (
e: React.MouseEvent,
imageData: string,
provider: string
) => {
e.stopPropagation();
imageHelpers.shareOrDownload(imageData, provider).catch((error) => {
console.error('Failed to share/download image:', error);
});
};
return (
<>
<div
className={cn(
'relative w-full aspect-square group bg-zinc-50 rounded-lg',
image && !failed && 'cursor-pointer',
(!image || failed) && 'border-1 border-zinc-100'
)}
onClick={handleImageClick}
>
{(image || failed) && (
<div className="absolute top-2 left-2 max-w-[75%] bg-white/95 px-2 py-1 flex items-center gap-2 rounded-lg">
<TooltipProvider>
<Tooltip delayDuration={100}>
<TooltipTrigger asChild>
<Label className="text-xs text-gray-900 truncate min-w-0 grow">
{imageHelpers.formatModelId(modelId)}
</Label>
</TooltipTrigger>
<TooltipContent>
<p>{modelId}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
)}
{image && !failed ? (
<>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={`data:image/png;base64,${image}`}
alt={`Generated by ${provider}`}
className="w-full h-full object-cover rounded-lg"
/>
<Button
size="icon"
variant="secondary"
className="absolute bottom-2 left-2 sm:opacity-0 sm:group-hover:opacity-100 transition-opacity"
onClick={(e) => handleActionClick(e, image, provider)}
>
<span className="sm:hidden">
<Share className="h-4 w-4" />
</span>
<span className="hidden sm:block">
<Download className="h-4 w-4" />
</span>
</Button>
{timing?.elapsed && (
<div className="absolute bottom-2 right-2 bg-black/70 backdrop-blur-sm rounded-md px-2 py-1 shadow">
<span className="text-xs text-white/90 font-medium">
{(timing.elapsed / 1000).toFixed(1)}s
</span>
</div>
)}
</>
) : (
<div className="absolute inset-0 flex flex-col items-center justify-center">
{failed ? (
fallbackIcon || <AlertCircle className="h-8 w-8 text-red-500" />
) : image ? (
<>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={`data:image/png;base64,${image}`}
alt={`Generated by ${provider}`}
className="w-full h-full object-cover rounded-lg"
/>
<Button
size="icon"
variant="secondary"
className="absolute bottom-2 left-2 sm:opacity-0 sm:group-hover:opacity-100 transition-opacity"
onClick={(e) => handleActionClick(e, image, provider)}
>
<span className="sm:hidden">
<Share className="h-4 w-4" />
</span>
<span className="hidden sm:block">
<Download className="h-4 w-4" />
</span>
</Button>
</>
) : timing?.startTime ? (
<>
{/* <div className="text-zinc-400 mb-2">{provider}</div> */}
<Stopwatch startTime={timing.startTime} />
</>
) : (
<ImageIcon className="h-12 w-12 text-zinc-300" />
)}
</div>
)}
</div>
{isZoomed &&
image &&
createPortal(
<div
className="fixed inset-0 bg-black/90 z-50 flex items-center justify-center cursor-pointer min-h-[100dvh] w-screen"
onClick={() => setIsZoomed(false)}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={`data:image/png;base64,${image}`}
alt={`Generated by ${provider}`}
className="max-h-[90dvh] max-w-[90vw] object-contain"
onClick={(e) => e.stopPropagation()}
/>
</div>,
document.body
)}
</>
);
}

View File

@ -0,0 +1,120 @@
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Button } from '@/components/ui/button';
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible';
import { AlertCircle, ChevronDown, Settings } from 'lucide-react';
import type {
GeneratedImage,
ImageError,
ProviderTiming,
} from '../lib/image-types';
import {
PROVIDER_ORDER,
type ProviderKey,
initializeProviderRecord,
} from '../lib/provider-config';
import { ImageCarousel } from './ImageCarousel';
import { ImageDisplay } from './ImageDisplay';
interface ImageGeneratorProps {
images: GeneratedImage[];
errors: ImageError[];
failedProviders: ProviderKey[];
timings: Record<ProviderKey, ProviderTiming>;
enabledProviders: Record<ProviderKey, boolean>;
toggleView: () => void;
}
export function ImageGenerator({
images,
errors,
failedProviders,
timings,
enabledProviders,
toggleView,
}: ImageGeneratorProps) {
return (
<div className="space-y-6">
{/* If there are errors, render a collapsible alert */}
{errors.length > 0 && (
<Collapsible>
<CollapsibleTrigger asChild>
<Button
variant="ghost"
className="flex items-center gap-2 text-destructive"
>
<AlertCircle className="h-4 w-4" />
{errors.length} {errors.length === 1 ? 'error' : 'errors'}{' '}
occurred
<ChevronDown className="h-4 w-4 transition-transform duration-200 data-[state=open]:rotate-180" />
</Button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="space-y-2 mt-2">
{errors.map((err, index) => (
<Alert key={index} variant="destructive">
<AlertCircle className="h-4 w-4" />
<div className="ml-3">
<AlertTitle className="capitalize">
{err.provider} Error
</AlertTitle>
<AlertDescription className="mt-1 text-sm">
{err.message}
</AlertDescription>
</div>
</Alert>
))}
</div>
</CollapsibleContent>
</Collapsible>
)}
<div className="flex items-center justify-between">
<h3 className="text-xl font-semibold">Generated Images</h3>
<Button
variant="outline"
className=""
onClick={() => toggleView()}
size="icon"
>
<Settings className="h-4 w-4" />
</Button>
</div>
{/* Mobile layout: Carousel */}
<div className="sm:hidden">
<ImageCarousel
providers={PROVIDER_ORDER}
images={images}
timings={timings}
failedProviders={failedProviders}
enabledProviders={enabledProviders}
providerToModel={initializeProviderRecord<string>()}
/>
</div>
{/* Desktop layout: Grid */}
<div className="hidden sm:grid sm:grid-cols-2 2xl:grid-cols-4 gap-6">
{PROVIDER_ORDER.map((provider) => {
const imageItem = images.find((img) => img.provider === provider);
const imageData = imageItem?.image;
const timing = timings[provider];
return (
<ImageDisplay
key={provider}
provider={provider}
image={imageData}
timing={timing}
failed={failedProviders.includes(provider)}
enabled={enabledProviders[provider]}
modelId={imageItem?.modelId ?? ''}
/>
);
})}
</div>
</div>
);
}

View File

@ -0,0 +1,25 @@
import { Button } from '@/components/ui/button';
import { ArrowUpRightIcon } from 'lucide-react';
import Link from 'next/link';
import { QualityModeToggle } from './QualityModeToggle';
export const ImageGeneratorHeader = () => {
return (
<header className="mb-4">
<div className="mx-auto flex justify-between items-center">
<div>
<h1 className="text-xl flex sm:text-xl sm:font-bold antialiased font-semibold">
<span className="mr-2">🏞</span> AI Image Generator
</h1>
</div>
{/* <Link href={`${process.env.NEXT_PUBLIC_APP_URL}`} target="_blank">
<Button size="icon" className="block sm:hidden">
<ArrowUpRightIcon className="w-4 h-4" />
</Button>
</Link> */}
{/* <QualityModeToggle onValueChange={() => {}} value="performance" /> */}
</div>
</header>
);
};

View File

@ -0,0 +1,145 @@
'use client';
import { useState } from 'react';
import { useImageGeneration } from '../hooks/use-image-generation';
import {
MODEL_CONFIGS,
type ModelMode,
PROVIDERS,
PROVIDER_ORDER,
type ProviderKey,
initializeProviderRecord,
} from '../lib/provider-config';
import type { Suggestion } from '../lib/suggestions';
import { ImageGeneratorHeader } from './ImageGeneratorHeader';
import { ModelCardCarousel } from './ModelCardCarousel';
import { ModelSelect } from './ModelSelect';
import { PromptInput } from './PromptInput';
export function ImagePlayground({
suggestions,
}: {
suggestions: Suggestion[];
}) {
const {
images,
timings,
failedProviders,
isLoading,
startGeneration,
activePrompt,
} = useImageGeneration();
const [showProviders, setShowProviders] = useState(true);
const [selectedModels, setSelectedModels] = useState<
Record<ProviderKey, string>
>(MODEL_CONFIGS.performance);
const [enabledProviders, setEnabledProviders] = useState(
initializeProviderRecord(true)
);
const [mode, setMode] = useState<ModelMode>('performance');
const toggleView = () => {
setShowProviders((prev) => !prev);
};
const handleModeChange = (newMode: ModelMode) => {
setMode(newMode);
setSelectedModels(MODEL_CONFIGS[newMode]);
setShowProviders(true);
};
const handleModelChange = (providerKey: ProviderKey, model: string) => {
setSelectedModels((prev) => ({ ...prev, [providerKey]: model }));
};
const handleProviderToggle = (provider: string, enabled: boolean) => {
setEnabledProviders((prev) => ({
...prev,
[provider]: enabled,
}));
};
const providerToModel = {
replicate: selectedModels.replicate,
// vertex: selectedModels.vertex,
openai: selectedModels.openai,
fireworks: selectedModels.fireworks,
fal: selectedModels.fal,
};
const handlePromptSubmit = (newPrompt: string) => {
const activeProviders = PROVIDER_ORDER.filter((p) => enabledProviders[p]);
if (activeProviders.length > 0) {
startGeneration(newPrompt, activeProviders, providerToModel);
}
setShowProviders(false);
};
return (
<div className="rounded-lg bg-background py-8 px-4 sm:px-6 lg:px-8">
<div className="max-w-7xl mx-auto">
{/* header */}
<ImageGeneratorHeader />
{/* input prompt */}
<PromptInput
onSubmit={handlePromptSubmit}
isLoading={isLoading}
showProviders={showProviders}
onToggleProviders={toggleView}
mode={mode}
onModeChange={handleModeChange}
suggestions={suggestions}
/>
{/* models carousel */}
{(() => {
const getModelProps = () =>
(Object.keys(PROVIDERS) as ProviderKey[]).map((key) => {
const provider = PROVIDERS[key];
const imageItem = images.find((img) => img.provider === key);
const imageData = imageItem?.image;
const modelId = imageItem?.modelId ?? 'N/A';
const timing = timings[key];
return {
label: provider.displayName,
models: provider.models,
value: selectedModels[key],
providerKey: key,
onChange: (model: string, providerKey: ProviderKey) =>
handleModelChange(providerKey, model),
iconPath: provider.iconPath,
color: provider.color,
enabled: enabledProviders[key],
onToggle: (enabled: boolean) =>
handleProviderToggle(key, enabled),
image: imageData,
modelId,
timing,
failed: failedProviders.includes(key),
};
});
return (
<>
<div className="md:hidden">
<ModelCardCarousel models={getModelProps()} />
</div>
<div className="hidden md:grid md:grid-cols-2 2xl:grid-cols-4 gap-8">
{getModelProps().map((props) => (
<ModelSelect key={props.label} {...props} />
))}
</div>
{activePrompt && activePrompt.length > 0 && (
<div className="text-center mt-8 text-muted-foreground">
{activePrompt}
</div>
)}
</>
);
})()}
</div>
</div>
);
}

View File

@ -0,0 +1,119 @@
'use client';
import {
Carousel,
type CarouselApi,
CarouselContent,
CarouselItem,
CarouselNext,
CarouselPrevious,
} from '@/components/ui/carousel';
import { cn } from '@/lib/utils';
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
import type { ProviderTiming } from '../lib/image-types';
import type { ProviderKey } from '../lib/provider-config';
import { ModelSelect } from './ModelSelect';
interface ModelCardCarouselProps {
models: Array<{
label: string;
models: string[];
iconPath: string;
color: string;
value: string;
providerKey: ProviderKey;
enabled?: boolean;
onToggle?: (enabled: boolean) => void;
onChange: (value: string, providerKey: ProviderKey) => void;
image: string | null | undefined;
timing?: ProviderTiming;
failed?: boolean;
modelId: string;
}>;
}
export function ModelCardCarousel({ models }: ModelCardCarouselProps) {
const [currentSlide, setCurrentSlide] = useState(0);
const [api, setApi] = useState<CarouselApi>();
const initialized = useRef(false);
useLayoutEffect(() => {
if (!api || initialized.current) return;
// Force scroll in multiple ways
api.scrollTo(0, false);
api.scrollPrev(); // Reset any potential offset
api.scrollTo(0, false);
initialized.current = true;
setCurrentSlide(0);
}, [api]);
useEffect(() => {
if (!api) return;
const onSelect = () => {
setCurrentSlide(api.selectedScrollSnap());
};
api.on('select', onSelect);
return () => {
api.off('select', onSelect);
return;
};
}, [api]);
return (
<div className="relative w-full mb-8">
<Carousel
setApi={setApi}
opts={{
align: 'start',
dragFree: false,
containScroll: 'trimSnaps',
loop: true,
}}
>
<CarouselContent>
{models.map((model, i) => (
<CarouselItem key={model.label}>
<ModelSelect
{...model}
onChange={(value, providerKey) =>
model.onChange(value, providerKey)
}
/>
<div className="text-center text-sm text-muted-foreground mt-4">
{i + 1} of {models.length}
</div>
</CarouselItem>
))}
</CarouselContent>
<CarouselPrevious className="left-0 bg-background/80 backdrop-blur-sm" />
<CarouselNext className="right-0 bg-background/80 backdrop-blur-sm" />
</Carousel>
{/* Dot Indicators */}
<div className="absolute -bottom-6 left-0 right-0">
<div className="flex justify-center gap-1">
{models.map((_, index) => (
<button
type="button"
key={index}
className={cn(
'h-1.5 rounded-full transition-all',
index === currentSlide
? 'w-4 bg-primary'
: 'w-1.5 bg-primary/50'
)}
onClick={() => api?.scrollTo(index)}
>
<span className="sr-only">Go to model {index + 1}</span>
</button>
))}
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,155 @@
import { Card, CardContent } from '@/components/ui/card';
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { cn } from '@/lib/utils';
import Link from 'next/link';
import { imageHelpers } from '../lib/image-helpers';
import type { ProviderTiming } from '../lib/image-types';
import {
FireworksIcon,
OpenAIIcon,
ReplicateIcon,
// VertexIcon,
falAILogo,
} from '../lib/logos';
import type { ProviderKey } from '../lib/provider-config';
import { ImageDisplay } from './ImageDisplay';
interface ModelSelectProps {
label: string;
models: string[];
value: string;
providerKey: ProviderKey;
onChange: (value: string, providerKey: ProviderKey) => void;
iconPath: string;
color: string;
enabled?: boolean;
onToggle?: (enabled: boolean) => void;
image: string | null | undefined;
timing?: ProviderTiming;
failed?: boolean;
modelId: string;
}
const PROVIDER_ICONS = {
openai: OpenAIIcon,
replicate: ReplicateIcon,
// vertex: VertexIcon,
fireworks: FireworksIcon,
fal: falAILogo,
} as const;
const PROVIDER_LINKS = {
openai: 'openai',
replicate: 'replicate',
// vertex: 'google-vertex',
fireworks: 'fireworks',
fal: 'fal',
} as const;
export function ModelSelect({
label,
models,
value,
providerKey,
onChange,
enabled = true,
image,
timing,
failed,
modelId,
}: ModelSelectProps) {
const Icon = PROVIDER_ICONS[providerKey];
return (
<Card
className={cn('w-full transition-opacity', enabled ? '' : 'opacity-50')}
>
<CardContent className="h-full">
<div className="flex items-center justify-between gap-2 mb-4">
<div className="flex flex-col items-center gap-4 w-full transition-opacity duration-200">
{/* model provider icon */}
<div className="flex items-center gap-4">
<Link
className="bg-primary hover:opacity-80 p-2 rounded-full"
href={
'https://sdk.vercel.ai/providers/ai-sdk-providers/' +
PROVIDER_LINKS[providerKey]
}
target="_blank"
>
<div className="text-primary-foreground">
<Icon size={24} />
</div>
</Link>
<Link
className="hover:opacity-80"
href={
'https://sdk.vercel.ai/providers/ai-sdk-providers/' +
PROVIDER_LINKS[providerKey]
}
target="_blank"
>
<h3 className="font-semibold text-lg">{label}</h3>
</Link>
</div>
{/* models in provider */}
<div className="flex justify-center items-center w-full">
<Select
defaultValue={value}
value={value}
onValueChange={(selectedValue) =>
onChange(selectedValue, providerKey)
}
>
<SelectTrigger className="cursor-pointer w-full">
<SelectValue placeholder={value || 'Select a model'} />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{models.map((model) => (
<SelectItem key={model} value={model} className="">
<span className="hidden xl:inline">
{imageHelpers.formatModelId(model).length > 30
? imageHelpers.formatModelId(model).slice(0, 30) +
'...'
: imageHelpers.formatModelId(model)}
</span>
<span className="hidden lg:inline xl:hidden">
{imageHelpers.formatModelId(model).length > 20
? imageHelpers.formatModelId(model).slice(0, 20) +
'...'
: imageHelpers.formatModelId(model)}
</span>
<span className="lg:hidden">
{imageHelpers.formatModelId(model)}
</span>
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</div>
</div>
</div>
<ImageDisplay
modelId={modelId}
provider={providerKey}
image={image}
timing={timing}
failed={failed}
/>
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,119 @@
import { Textarea } from '@/components/ui/textarea';
import { cn } from '@/lib/utils';
import { ArrowUp, ArrowUpRight, Loader2, RefreshCw } from 'lucide-react';
import { useState } from 'react';
import { type Suggestion, getRandomSuggestions } from '../lib/suggestions';
type QualityMode = 'performance' | 'quality';
// showProviders/onToggleProviders/mode/onModeChange are not used yet
interface PromptInputProps {
onSubmit: (prompt: string) => void;
isLoading?: boolean;
showProviders: boolean;
onToggleProviders: () => void;
mode: QualityMode;
onModeChange: (mode: QualityMode) => void;
suggestions: Suggestion[];
}
export function PromptInput({
suggestions: initSuggestions,
isLoading,
onSubmit,
}: PromptInputProps) {
const [input, setInput] = useState('');
const [suggestions, setSuggestions] = useState<Suggestion[]>(initSuggestions);
const updateSuggestions = () => {
setSuggestions(getRandomSuggestions());
};
const handleSuggestionSelect = (prompt: string) => {
setInput(prompt);
// onSubmit(prompt);
};
const handleSubmit = () => {
if (!isLoading && input.trim()) {
onSubmit(input);
}
};
// const handleRefreshSuggestions = () => {
// setCurrentSuggestions(getRandomSuggestions());
// };
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
if (!isLoading && input.trim()) {
onSubmit(input);
}
}
};
return (
<div className="w-full mb-8">
<div className="bg-card rounded-xl p-4">
<div className="flex flex-col gap-3">
<Textarea
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Enter your prompt here"
rows={3}
className="text-base bg-transparent border-muted p-2 resize-none placeholder:text-muted-foreground text-foreground focus-visible:ring-0 focus-visible:ring-offset-0"
/>
<div className="flex items-center justify-between pt-1">
<div className="flex items-center justify-between space-x-2">
{/* refresh suggestions */}
<button
type="button"
onClick={updateSuggestions}
className="flex items-center justify-between cursor-pointer px-2 rounded-lg py-1 bg-background text-sm hover:opacity-70 group transition-opacity duration-200"
>
<RefreshCw className="w-4 h-4 text-muted-foreground group-hover:opacity-70" />
</button>
{/* suggestions */}
{suggestions.map((suggestion, index) => (
<button
type="button"
key={index}
onClick={() => handleSuggestionSelect(suggestion.prompt)}
className={cn(
'flex items-center justify-between cursor-pointer px-2 rounded-lg py-1 bg-background text-sm hover:opacity-70 group transition-opacity duration-200',
index > 2
? 'hidden md:flex'
: index > 1
? 'hidden sm:flex'
: ''
)}
>
<span>
<span className="text-foreground text-xs sm:text-sm">
{suggestion.text.toLowerCase()}
</span>
</span>
<ArrowUpRight className="ml-1 h-2 w-2 sm:h-3 sm:w-3 text-muted-foreground group-hover:opacity-70" />
</button>
))}
</div>
{/* submit prompt */}
<button
type="button"
onClick={handleSubmit}
disabled={isLoading || !input.trim()}
className="h-8 w-8 cursor-pointer rounded-full bg-primary flex items-center justify-center disabled:opacity-50"
>
{isLoading ? (
<Loader2 className="w-3 h-3 text-primary-foreground animate-spin" />
) : (
<ArrowUp className="w-5 h-5 text-primary-foreground" />
)}
</button>
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,43 @@
'use client';
import { Button } from '@/components/ui/button';
import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area';
import { Lightbulb } from 'lucide-react';
import type { Suggestion } from '../lib/suggestions';
interface PromptSuggestionsProps {
suggestions: Suggestion[];
onSelect: (prompt: string) => void;
disabled?: boolean;
}
export function PromptSuggestions({
suggestions,
onSelect,
disabled = false,
}: PromptSuggestionsProps) {
return (
<div className="relative flex-grow overflow-hidden">
<ScrollArea className="w-full whitespace-nowrap rounded-md">
<div className="flex items-center gap-2">
<div className="flex gap-2 py-1">
{suggestions.map((suggestion) => (
<Button
key={suggestion.text}
variant="secondary"
size="sm"
className="h-8 shrink-0 gap-1.5"
disabled={disabled}
onClick={() => onSelect(suggestion.prompt)}
>
<Lightbulb className="h-3.5 w-3.5 text-muted-foreground" />
{suggestion.text}
</Button>
))}
</div>
</div>
<ScrollBar orientation="horizontal" className="h-2.5" />
</ScrollArea>
</div>
);
}

View File

@ -0,0 +1,56 @@
"use client";
import { Zap, Sparkles } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useToast } from "@/hooks/use-toast";
export type QualityMode = "performance" | "quality";
interface QualityModeToggleProps {
value: QualityMode;
onValueChange: (value: QualityMode) => void;
disabled?: boolean;
}
export function QualityModeToggle({
onValueChange,
disabled = false,
}: QualityModeToggleProps) {
const { toast } = useToast();
return (
<div className="flex flex-col items-center gap-2 min-w-[240px]">
<div className="flex gap-2">
<Button
variant="secondary"
disabled={disabled}
onClick={() => {
onValueChange("performance");
toast({
description: "Switching to faster models for quicker generation",
duration: 2000,
});
}}
>
<Zap className="h-4 w-4 mr-2" />
Performance
</Button>
<Button
variant="secondary"
disabled={disabled}
onClick={() => {
onValueChange("quality");
toast({
description:
"Switching to higher quality models for better results",
duration: 2000,
});
}}
>
<Sparkles className="h-4 w-4 mr-2" />
Quality
</Button>
</div>
</div>
);
}

View File

@ -0,0 +1,17 @@
import { useEffect, useState } from "react";
export function Stopwatch({ startTime }: { startTime: number }) {
const [elapsed, setElapsed] = useState(0);
useEffect(() => {
const interval = setInterval(() => {
setElapsed(Date.now() - startTime);
}, 100);
return () => clearInterval(interval);
}, [startTime]);
return (
<div className="text-lg text-zinc-500 font-mono">{(elapsed / 1000).toFixed(1)}s</div>
);
}

View File

@ -0,0 +1,171 @@
import { useState } from 'react';
import type {
ImageError,
ImageResult,
ProviderTiming,
} from '../lib/image-types';
import {
type ProviderKey,
initializeProviderRecord,
} from '../lib/provider-config';
interface UseImageGenerationReturn {
images: ImageResult[];
errors: ImageError[];
timings: Record<ProviderKey, ProviderTiming>;
failedProviders: ProviderKey[];
isLoading: boolean;
startGeneration: (
prompt: string,
providers: ProviderKey[],
providerToModel: Record<ProviderKey, string>
) => Promise<void>;
resetState: () => void;
activePrompt: string;
}
export function useImageGeneration(): UseImageGenerationReturn {
const [images, setImages] = useState<ImageResult[]>([]);
const [errors, setErrors] = useState<ImageError[]>([]);
const [timings, setTimings] = useState<Record<ProviderKey, ProviderTiming>>(
initializeProviderRecord<ProviderTiming>()
);
const [failedProviders, setFailedProviders] = useState<ProviderKey[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [activePrompt, setActivePrompt] = useState('');
const resetState = () => {
setImages([]);
setErrors([]);
setTimings(initializeProviderRecord<ProviderTiming>());
setFailedProviders([]);
setIsLoading(false);
};
const startGeneration = async (
prompt: string,
providers: ProviderKey[],
providerToModel: Record<ProviderKey, string>
) => {
setActivePrompt(prompt);
try {
setIsLoading(true);
// Initialize images array with null values
setImages(
providers.map((provider) => ({
provider,
image: null,
modelId: providerToModel[provider],
}))
);
// Clear previous state
setErrors([]);
setFailedProviders([]);
// Initialize timings with start times
const now = Date.now();
setTimings(
Object.fromEntries(
providers.map((provider) => [provider, { startTime: now }])
) as Record<ProviderKey, ProviderTiming>
);
// Helper to fetch a single provider
const generateImage = async (provider: ProviderKey, modelId: string) => {
const startTime = now;
console.log(
`Generate image request [provider=${provider}, modelId=${modelId}]`
);
try {
const request = {
prompt,
provider,
modelId,
};
const response = await fetch('/api/generate-images', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(request),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || `Server error: ${response.status}`);
}
const completionTime = Date.now();
const elapsed = completionTime - startTime;
setTimings((prev) => ({
...prev,
[provider]: {
startTime,
completionTime,
elapsed,
},
}));
console.log(
`Successful image response [provider=${provider}, modelId=${modelId}, elapsed=${elapsed}ms]`
);
// Update image in state
setImages((prevImages) =>
prevImages.map((item) =>
item.provider === provider
? { ...item, image: data.image ?? null, modelId }
: item
)
);
} catch (err) {
console.error(
`Error [provider=${provider}, modelId=${modelId}]:`,
err
);
setFailedProviders((prev) => [...prev, provider]);
setErrors((prev) => [
...prev,
{
provider,
message:
err instanceof Error
? err.message
: 'An unexpected error occurred',
},
]);
setImages((prevImages) =>
prevImages.map((item) =>
item.provider === provider
? { ...item, image: null, modelId }
: item
)
);
}
};
// Generate images for all active providers
const fetchPromises = providers.map((provider) => {
const modelId = providerToModel[provider];
return generateImage(provider, modelId);
});
await Promise.all(fetchPromises);
} catch (error) {
console.error('Error fetching images:', error);
} finally {
setIsLoading(false);
}
};
return {
images,
errors,
timings,
failedProviders,
isLoading,
startGeneration,
resetState,
activePrompt,
};
}

View File

@ -0,0 +1,12 @@
import type { ProviderKey } from './provider-config';
export interface GenerateImageRequest {
prompt: string;
provider: ProviderKey;
modelId: string;
}
export interface GenerateImageResponse {
image?: string;
error?: string;
}

View File

@ -0,0 +1,53 @@
export const imageHelpers = {
base64ToBlob: (base64Data: string, type = "image/png"): Blob => {
const byteString = atob(base64Data);
const arrayBuffer = new ArrayBuffer(byteString.length);
const uint8Array = new Uint8Array(arrayBuffer);
for (let i = 0; i < byteString.length; i++) {
uint8Array[i] = byteString.charCodeAt(i);
}
return new Blob([uint8Array], { type });
},
generateImageFileName: (provider: string): string => {
const uniqueId = Math.random().toString(36).substring(2, 8);
return `${provider}-${uniqueId}`.replace(/[^a-z0-9-]/gi, "");
},
shareOrDownload: async (
imageData: string,
provider: string
): Promise<void> => {
const fileName = imageHelpers.generateImageFileName(provider);
const blob = imageHelpers.base64ToBlob(imageData);
const file = new File([blob], `${fileName}.png`, { type: "image/png" });
try {
if (navigator.share) {
await navigator.share({
files: [file],
title: `Image generated by ${provider}`,
});
} else {
throw new Error("Share API not available");
}
} catch (error) {
// Fall back to download for any error (including share cancellation)
console.error("Error sharing/downloading:", error);
const blobUrl = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = blobUrl;
link.download = `${fileName}.png`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(blobUrl);
}
},
formatModelId: (modelId: string): string => {
return modelId.split("/").pop() || modelId;
},
};

View File

@ -0,0 +1,24 @@
import type { ProviderKey } from './provider-config';
export interface GeneratedImage {
provider: ProviderKey;
image: string | null;
modelId?: string;
}
export interface ImageResult {
provider: ProviderKey;
image: string | null;
modelId?: string;
}
export interface ImageError {
provider: ProviderKey;
message: string;
}
export interface ProviderTiming {
startTime?: number;
completionTime?: number;
elapsed?: number;
}

131
src/ai/image/lib/logos.tsx Normal file
View File

@ -0,0 +1,131 @@
export const FireworksIcon = ({ size = 16 }) => {
return (
<svg
height={size}
width={size}
viewBox="0 0 638 315"
xmlns="http://www.w3.org/2000/svg"
fill="white"
>
<g transform="scale(0.8) translate(75,-10)">
<path
d="M318.563 221.755C300.863 221.755 284.979 211.247 278.206 194.978L196.549 0H244.342L318.842 178.361L393.273 0H441.066L358.92 195.048C352.112 211.247 336.263 221.755 318.563 221.755Z"
className="fill-logo"
/>
<path
d="M425.111 314.933C407.481 314.933 391.667 304.494 384.824 288.366C377.947 272.097 381.507 253.524 393.936 240.921L542.657 90.2803L561.229 134.094L425.076 271.748L619.147 270.666L637.72 314.479L425.146 315.003L425.076 314.933H425.111Z"
className="fill-logo"
/>
<path
d="M0 314.408L18.5727 270.595L212.643 271.677L76.525 133.988L95.0977 90.1748L243.819 240.816C256.247 253.384 259.843 272.026 252.93 288.26C246.088 304.424 230.203 314.827 212.643 314.827L0.0698221 314.339L0 314.408Z"
className="fill-logo"
/>
</g>
</svg>
);
};
export const OpenAIIcon = ({ size = 16 }) => {
return (
<svg
data-testid="geist-icon"
height={size}
strokeLinejoin="round"
viewBox="0 0 16 16"
width={size}
style={{ color: 'currentcolor' }}
>
<path
transform="scale(0.8) translate(2,2)"
d="M14.9449 6.54871C15.3128 5.45919 15.1861 4.26567 14.5978 3.27464C13.7131 1.75461 11.9345 0.972595 10.1974 1.3406C9.42464 0.481584 8.3144 -0.00692594 7.15045 7.42132e-05C5.37487 -0.00392587 3.79946 1.1241 3.2532 2.79113C2.11256 3.02164 1.12799 3.72615 0.551837 4.72468C-0.339497 6.24071 -0.1363 8.15175 1.05451 9.45178C0.686626 10.5413 0.813308 11.7348 1.40162 12.7258C2.28637 14.2459 4.06498 15.0279 5.80204 14.6599C6.5743 15.5189 7.68504 16.0074 8.849 15.9999C10.6256 16.0044 12.2015 14.8754 12.7478 13.2069C13.8884 12.9764 14.873 12.2718 15.4491 11.2733C16.3394 9.75728 16.1357 7.84774 14.9454 6.54771L14.9449 6.54871ZM8.85001 14.9544C8.13907 14.9554 7.45043 14.7099 6.90468 14.2604C6.92951 14.2474 6.97259 14.2239 7.00046 14.2069L10.2293 12.3668C10.3945 12.2743 10.4959 12.1008 10.4949 11.9133V7.42173L11.8595 8.19925C11.8742 8.20625 11.8838 8.22025 11.8858 8.23625V11.9558C11.8838 13.6099 10.5263 14.9509 8.85001 14.9544ZM2.32133 12.2028C1.9651 11.5958 1.8369 10.8843 1.95902 10.1938C1.98284 10.2078 2.02489 10.2333 2.05479 10.2503L5.28366 12.0903C5.44733 12.1848 5.65003 12.1848 5.81421 12.0903L9.75604 9.84429V11.3993C9.75705 11.4153 9.74945 11.4308 9.73678 11.4408L6.47295 13.3004C5.01915 14.1264 3.1625 13.6354 2.32184 12.2028H2.32133ZM1.47155 5.24819C1.82626 4.64017 2.38619 4.17516 3.05305 3.93366C3.05305 3.96116 3.05152 4.00966 3.05152 4.04366V7.72424C3.05051 7.91124 3.15186 8.08475 3.31654 8.17725L7.25838 10.4228L5.89376 11.2003C5.88008 11.2093 5.86285 11.2108 5.84765 11.2043L2.58331 9.34327C1.13255 8.51426 0.63494 6.68272 1.47104 5.24869L1.47155 5.24819ZM12.6834 7.82274L8.74157 5.57669L10.1062 4.79968C10.1199 4.79068 10.1371 4.78918 10.1523 4.79568L13.4166 6.65522C14.8699 7.48373 15.3681 9.31827 14.5284 10.7523C14.1732 11.3593 13.6138 11.8243 12.9474 12.0663V8.27575C12.9489 8.08875 12.8481 7.91574 12.6839 7.82274H12.6834ZM14.0414 5.8057C14.0176 5.7912 13.9756 5.7662 13.9457 5.7492L10.7168 3.90916C10.5531 3.81466 10.3504 3.81466 10.1863 3.90916L6.24442 6.15521V4.60017C6.2434 4.58417 6.251 4.56867 6.26367 4.55867L9.52751 2.70063C10.9813 1.87311 12.84 2.36563 13.6781 3.80066C14.0323 4.40667 14.1605 5.11618 14.0404 5.8057H14.0414ZM5.50257 8.57726L4.13744 7.79974C4.12275 7.79274 4.11312 7.77874 4.11109 7.76274V4.04316C4.11211 2.38713 5.47368 1.0451 7.15197 1.0461C7.86189 1.0461 8.54902 1.2921 9.09476 1.74011C9.06993 1.75311 9.02737 1.77661 8.99899 1.79361L5.77012 3.63365C5.60493 3.72615 5.50358 3.89916 5.50459 4.08666L5.50257 8.57626V8.57726ZM6.24391 7.00022L7.99972 5.9997L9.75553 6.99972V9.00027L7.99972 10.0003L6.24391 9.00027V7.00022Z"
fill="currentColor"
/>
</svg>
);
};
export const ReplicateIcon = ({ size = 16 }) => {
return (
<svg
height={size}
width={size}
viewBox="0 0 1500 1500"
xmlns="http://www.w3.org/2000/svg"
style={{ color: 'currentcolor' }}
>
<g fill="white" transform="scale(0.8) translate(450,450)">
<polygon points="1000,427.6 1000,540.6 603.4,540.6 603.4,1000 477,1000 477,427.6" />
<polygon points="1000,213.8 1000,327 364.8,327 364.8,1000 238.4,1000 238.4,213.8" />
<polygon points="1000,0 1000,113.2 126.4,113.2 126.4,1000 0,1000 0,0" />
</g>
</svg>
);
};
export const VertexIcon = ({ size = 16 }) => {
return (
<svg
height={size}
width={size}
viewBox="0 0 512 512"
xmlns="http://www.w3.org/2000/svg"
style={{ color: 'currentcolor' }}
>
<g transform="scale(0.8) translate(65,65)">
<path
d="M128,249c-8.8,0-16-7.2-16-16v-105c0-8.8,7.2-16,16-16s16,7.2,16,16v105c0,8.8-7.2,16-16,16Z"
fill="white"
/>
<path
d="M256,464c-3,0-6-.8-8.6-2.5l-176-112c-7.5-4.7-9.7-14.6-4.9-22.1,4.8-7.5,14.6-9.6,22.1-4.9l167.4,106.5,167.4-106.5c7.5-4.7,17.3-2.5,22.1,4.9,4.7,7.5,2.5,17.3-4.9,22.1l-176,112c-2.6,1.7-5.6,2.5-8.6,2.5h0Z"
fill="white"
/>
<path
d="M256,394c-8.8,0-16-7.2-16-16v-73.1c0-8.8,7.2-16,16-16s16,7.2,16,16v73.1c0,8.8-7.2,16-16,16Z"
fill="white"
/>
<circle cx="128" cy="64" r="16" fill="white" />
<circle cx="128" cy="297" r="16" fill="white" />
<path
d="M384.2,314c-8.8,0-16-7.1-16-16l-.2-106c0-8.8,7.1-16,16-16h0c8.8,0,16,7.1,16,16l.2,106c0,8.8-7.1,16-16,16h0Z"
fill="white"
/>
<circle cx="384" cy="64" r="16" fill="white" />
<circle cx="384" cy="128" r="16" fill="white" />
<path
d="M320,225c-8.8,0-16-7.2-16-16v-103c0-8.8,7.2-16,16-16s16,7.2,16,16v103c0,8.8-7.2,16-16,16Z"
fill="white"
/>
<circle cx="256" cy="177" r="16" fill="white" />
<circle cx="256" cy="241" r="16" fill="white" />
<circle cx="320" cy="273" r="16" fill="white" />
<circle cx="320" cy="337" r="16" fill="white" />
<path
d="M192,225c-8.8,0-16-7.2-16-16v-103c0-8.8,7.2-16,16-16s16,7.2,16,16v103c0,8.8-7.2,16-16,16Z"
fill="white"
/>
<circle cx="192" cy="273" r="16" fill="white" />
<circle cx="192" cy="337" r="16" fill="white" />
</g>
</svg>
);
};
export const falAILogo = ({ size = 16 }: { size: number }) => {
return (
<svg
width={size}
height={size}
viewBox="0 0 170 171"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M109.571 0.690002C112.515 0.690002 114.874 3.08348 115.155 6.01352C117.665 32.149 138.466 52.948 164.603 55.458C167.534 55.7394 169.927 58.0985 169.927 61.042V110.255C169.927 113.198 167.534 115.557 164.603 115.839C138.466 118.349 117.665 139.148 115.155 165.283C114.874 168.213 112.515 170.607 109.571 170.607H60.3553C57.4116 170.607 55.0524 168.213 54.7709 165.283C52.2608 139.148 31.4601 118.349 5.32289 115.839C2.39266 115.557 -0.000976562 113.198 -0.000976562 110.255V61.042C-0.000976562 58.0985 2.39267 55.7394 5.3229 55.458C31.4601 52.948 52.2608 32.149 54.7709 6.01351C55.0524 3.08348 57.4116 0.690002 60.3553 0.690002H109.571ZM34.1182 85.5045C34.1182 113.776 57.0124 136.694 85.2539 136.694C113.495 136.694 136.39 113.776 136.39 85.5045C136.39 57.2332 113.495 34.3147 85.2539 34.3147C57.0124 34.3147 34.1182 57.2332 34.1182 85.5045Z"
fill="currentColor"
/>
</svg>
);
};

View File

@ -0,0 +1,120 @@
export type ProviderKey =
| 'replicate'
// | 'vertex'
| 'openai'
| 'fireworks'
| 'fal';
export type ModelMode = 'performance' | 'quality';
export const PROVIDERS: Record<
ProviderKey,
{
displayName: string;
iconPath: string;
color: string;
models: string[];
}
> = {
// https://ai-sdk.dev/providers/ai-sdk-providers/replicate#image-models
replicate: {
displayName: 'Replicate',
iconPath: '/provider-icons/replicate.svg',
color: 'from-purple-500 to-blue-500',
models: [
'black-forest-labs/flux-1.1-pro',
'black-forest-labs/flux-1.1-pro-ultra',
'black-forest-labs/flux-dev',
'black-forest-labs/flux-pro',
'black-forest-labs/flux-schnell',
'ideogram-ai/ideogram-v2',
'ideogram-ai/ideogram-v2-turbo',
'luma/photon',
'luma/photon-flash',
'recraft-ai/recraft-v3',
// 'recraft-ai/recraft-v3-svg', // added by Fox
// 'stability-ai/stable-diffusion-3.5-medium', // added by Fox
'stability-ai/stable-diffusion-3.5-large',
'stability-ai/stable-diffusion-3.5-large-turbo',
],
},
// vertex: {
// displayName: 'Vertex AI',
// iconPath: '/provider-icons/vertex.svg',
// color: 'from-green-500 to-emerald-500',
// models: ['imagen-3.0-generate-001', 'imagen-3.0-fast-generate-001'],
// },
// https://ai-sdk.dev/providers/ai-sdk-providers/openai#image-models
openai: {
displayName: 'OpenAI',
iconPath: '/provider-icons/openai.svg',
color: 'from-blue-500 to-cyan-500',
models: [
// 'gpt-image-1', // added by Fox
'dall-e-2',
'dall-e-3',
],
},
// https://ai-sdk.dev/providers/ai-sdk-providers/fireworks#image-models
fireworks: {
displayName: 'Fireworks',
iconPath: '/provider-icons/fireworks.svg',
color: 'from-orange-500 to-red-500',
models: [
'accounts/fireworks/models/flux-1-dev-fp8',
'accounts/fireworks/models/flux-1-schnell-fp8',
'accounts/fireworks/models/playground-v2-5-1024px-aesthetic',
'accounts/fireworks/models/japanese-stable-diffusion-xl',
'accounts/fireworks/models/playground-v2-1024px-aesthetic',
'accounts/fireworks/models/SSD-1B',
'accounts/fireworks/models/stable-diffusion-xl-1024-v1-0',
],
},
// https://ai-sdk.dev/providers/ai-sdk-providers/fal#image-models
fal: {
displayName: 'Fal',
iconPath: '/provider-icons/fal.svg',
color: 'from-orange-500 to-red-500',
models: [
'fal-ai/flux/dev', // added by Fox
'fal-ai/flux-pro/kontext',
'fal-ai/flux-pro/kontext/max',
'fal-ai/flux-lora',
'fal-ai/fast-sdxl',
'fal-ai/flux-pro/v1.1-ultra',
'fal-ai/ideogram/v2',
'fal-ai/recraft-v3',
'fal-ai/hyper-sdxl',
// 'fal-ai/stable-diffusion-3.5-large',
],
},
};
export const MODEL_CONFIGS: Record<ModelMode, Record<ProviderKey, string>> = {
performance: {
replicate: 'stability-ai/stable-diffusion-3.5-medium',
// vertex: 'imagen-3.0-fast-generate-001',
openai: 'dall-e-3',
fireworks: 'accounts/fireworks/models/flux-1-schnell-fp8',
fal: 'fal-ai/flux/dev',
},
quality: {
replicate: 'stability-ai/stable-diffusion-3.5-large',
// vertex: 'imagen-3.0-generate-001',
openai: 'dall-e-3',
fireworks: 'accounts/fireworks/models/flux-1-dev-fp8',
fal: 'fal-ai/flux-pro/v1.1-ultra',
},
};
export const PROVIDER_ORDER: ProviderKey[] = [
'replicate',
// 'vertex',
'openai',
'fireworks',
'fal',
];
export const initializeProviderRecord = <T>(defaultValue?: T) =>
Object.fromEntries(
PROVIDER_ORDER.map((key) => [key, defaultValue])
) as Record<ProviderKey, T>;

View File

@ -0,0 +1,138 @@
export interface Suggestion {
text: string;
prompt: string;
}
const artStyles = ['anime', 'art nouveau', 'ukiyo-e', 'watercolor'];
const basePrompts: { text: string; prompt: string }[] = [
{
text: 'Salamander Dusk',
prompt: 'A salamander at dusk in a forest pond',
},
{
text: 'Sultry Chicken',
prompt:
'A sultry chicken peering around the corner from shadows, clearly up to no good',
},
{
text: 'Cat Vercel',
prompt: 'A cat launching its website on Vercel',
},
{
text: 'Red Panda',
prompt:
'A red panda sipping tea under cherry blossoms at sunset with Mount Fuji in the background',
},
{
text: 'Beach Otter',
prompt: 'A mischievous otter surfing the waves in Bali at golden hour',
},
{
text: 'Badger Ramen',
prompt: 'A pensive honey badger eating a bowl of ramen in Osaka',
},
{
text: 'Zen Frog',
prompt:
'A frog meditating on a lotus leaf in a tranquil forest pond at dawn, surrounded by fireflies',
},
{
text: 'Macaw Love',
prompt:
'A colorful macaw delivering a love letter, flying over the Grand Canyon at sunrise',
},
{
text: 'Fox Painting',
prompt: 'A fox walking through a field of lavender with a golden sunset',
},
{
text: 'Armadillo Aerospace',
prompt:
'An armadillo in a rocket at countdown preparing to blast off to Mars',
},
{
text: 'Penguin Delight',
prompt: 'A penguin in pajamas eating ice cream while watching television',
},
{
text: 'Echidna Library',
prompt:
'An echidna reading a book in a cozy library built into the branches of a eucalyptus tree',
},
{
text: 'Capybara Onsen',
prompt:
'A capybara relaxing in a hot spring surrounded by snow-covered mountains with a waterfall in the background',
},
{
text: 'Lion Throne',
prompt:
'A regal lion wearing a crown, sitting on a throne in a jungle palace, with waterfalls in the distance',
},
{
text: 'Dolphin Glow',
prompt:
'A dolphin leaping through a glowing ring of bioluminescence under a starry sky',
},
{
text: 'Owl Detective',
prompt:
'An owl wearing a monocle and top hat, solving a mystery in a misty forest at midnight',
},
{
text: 'Jellyfish Cathedral',
prompt:
'A jellyfish floating gracefully in an underwater cathedral made of coral and glass',
},
{
text: 'Platypus River',
prompt: 'A platypus foraging in a river with a sunset in the background',
},
{
text: 'Chameleon Urban',
prompt:
'A chameleon blending into a graffiti-covered wall in an urban jungle',
},
{
text: 'Tortoise Oasis',
prompt:
'A giant tortoise slowly meandering its way to an oasis in the desert',
},
{
text: 'Hummingbird Morning',
prompt:
'A hummingbird sipping nectar from a purple bougainvillea at sunrise, captured mid-flight',
},
{
text: 'Polar Bear',
prompt:
'A polar bear clambering onto an iceberg to greet a friendly harbor seal as dusk falls',
},
{
text: 'Lemur Sunbathing',
prompt:
'A ring-tailed lemur sunbathing on a rock in Madagascar in early morning light',
},
];
function shuffle<T>(array: T[]): T[] {
const shuffled = [...array];
for (let i = shuffled.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
}
return shuffled;
}
export function getRandomSuggestions(count = 5): Suggestion[] {
const shuffledPrompts = shuffle(basePrompts);
const shuffledStyles = shuffle(artStyles);
return shuffledPrompts.slice(0, count).map((item, index) => ({
text: item.text,
prompt: `${item.prompt}, in the style of ${
shuffledStyles[index % shuffledStyles.length]
}`,
}));
}

View File

@ -1,4 +1,5 @@
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { ImagePlayground } from '@/ai/image/components/ImagePlayground';
import { getRandomSuggestions } from '@/ai/image/lib/suggestions';
import { constructMetadata } from '@/lib/metadata';
import { getUrlWithLocale } from '@/lib/urls/urls';
import type { Metadata } from 'next';
@ -25,31 +26,8 @@ export default async function AIImagePage() {
const t = await getTranslations('AIImagePage');
return (
<div className="max-w-4xl mx-auto space-y-8">
{/* about section */}
<div className="relative max-w-(--breakpoint-md) mx-auto mb-24 mt-8 md:mt-16">
<div className="mx-auto flex flex-col justify-between">
<div className="flex flex-row items-center gap-8">
{/* avatar and name */}
<div className="flex items-center gap-8">
<Avatar className="size-32 p-0.5">
<AvatarImage
className="rounded-full border-4 border-gray-200"
src="/logo.png"
alt="Avatar"
/>
<AvatarFallback>
<div className="size-32 text-muted-foreground" />
</AvatarFallback>
</Avatar>
<div>
<h1 className="text-4xl text-foreground">{t('content')}</h1>
</div>
</div>
</div>
</div>
</div>
<div className="mx-auto space-y-8">
<ImagePlayground suggestions={getRandomSuggestions(5)} />
</div>
);
}

View File

@ -0,0 +1,124 @@
import type { GenerateImageRequest } from '@/ai/image/lib/api-types';
import type { ProviderKey } from '@/ai/image/lib/provider-config';
import { createFal } from '@ai-sdk/fal';
import { fireworks } from '@ai-sdk/fireworks';
import { openai } from '@ai-sdk/openai';
import { replicate } from '@ai-sdk/replicate';
import {
type ImageModel,
experimental_generateImage as generateImage,
} from 'ai';
import { type NextRequest, NextResponse } from 'next/server';
/**
* Intended to be slightly less than the maximum execution time allowed by the
* runtime so that we can gracefully terminate our request.
*/
const TIMEOUT_MILLIS = 55 * 1000;
const DEFAULT_IMAGE_SIZE = '1024x1024';
const DEFAULT_ASPECT_RATIO = '1:1';
const fal = createFal({
apiKey: process.env.FAL_API_KEY,
});
interface ProviderConfig {
createImageModel: (modelId: string) => ImageModel;
dimensionFormat: 'size' | 'aspectRatio';
}
const providerConfig: Record<ProviderKey, ProviderConfig> = {
openai: {
createImageModel: openai.image,
dimensionFormat: 'size',
},
fireworks: {
createImageModel: fireworks.image,
dimensionFormat: 'aspectRatio',
},
replicate: {
createImageModel: replicate.image,
dimensionFormat: 'size',
},
fal: {
createImageModel: fal.image,
dimensionFormat: 'size',
},
};
const withTimeout = <T>(
promise: Promise<T>,
timeoutMillis: number
): Promise<T> => {
return Promise.race([
promise,
new Promise<T>((_, reject) =>
setTimeout(() => reject(new Error('Request timed out')), timeoutMillis)
),
]);
};
export async function POST(req: NextRequest) {
const requestId = Math.random().toString(36).substring(7);
const { prompt, provider, modelId } =
(await req.json()) as GenerateImageRequest;
try {
if (!prompt || !provider || !modelId || !providerConfig[provider]) {
const error = 'Invalid request parameters';
console.error(`${error} [requestId=${requestId}]`);
return NextResponse.json({ error }, { status: 400 });
}
const config = providerConfig[provider];
const startstamp = performance.now();
const generatePromise = generateImage({
model: config.createImageModel(modelId),
prompt,
...(config.dimensionFormat === 'size'
? { size: DEFAULT_IMAGE_SIZE }
: { aspectRatio: DEFAULT_ASPECT_RATIO }),
...(provider !== 'openai' && {
seed: Math.floor(Math.random() * 1000000),
}),
// Vertex AI only accepts a specified seed if watermark is disabled.
providerOptions: { vertex: { addWatermark: false } },
}).then(({ image, warnings }) => {
if (warnings?.length > 0) {
console.warn(
`Warnings [requestId=${requestId}, provider=${provider}, model=${modelId}]: `,
warnings
);
}
console.log(
`Completed image request [requestId=${requestId}, provider=${provider}, model=${modelId}, elapsed=${(
(performance.now() - startstamp) / 1000
).toFixed(1)}s].`
);
return {
provider,
image: image.base64,
};
});
const result = await withTimeout(generatePromise, TIMEOUT_MILLIS);
return NextResponse.json(result, {
status: 'image' in result ? 200 : 500,
});
} catch (error) {
// Log full error detail on the server, but return a generic error message
// to avoid leaking any sensitive information to the client.
console.error(
`Error generating image [requestId=${requestId}, provider=${provider}, model=${modelId}]: `,
error
);
return NextResponse.json(
{
error: 'Failed to generate image. Please try again later.',
},
{ status: 500 }
);
}
}

View File

@ -0,0 +1,9 @@
import { NextResponse } from 'next/server';
/**
* It is used to check if the server is running.
* You can use tools like Uptime Kuma to monitor this endpoint.
*/
export async function GET() {
return NextResponse.json({ message: 'pong' });
}

View File

@ -1,5 +1,6 @@
'use client';
import { validateCaptchaAction } from '@/actions/validate-captcha';
import { AuthCard } from '@/components/auth/auth-card';
import { FormError } from '@/components/shared/form-error';
import { FormSuccess } from '@/components/shared/form-success';
@ -15,6 +16,7 @@ import {
import { Input } from '@/components/ui/input';
import { websiteConfig } from '@/config/website';
import { authClient } from '@/lib/auth-client';
import { isTurnstileEnabled } from '@/lib/captcha';
import { getUrlWithLocaleInCallbackUrl } from '@/lib/urls/urls';
import { DEFAULT_LOGIN_REDIRECT, Routes } from '@/routes';
import { zodResolver } from '@hookform/resolvers/zod';
@ -22,8 +24,9 @@ import { EyeIcon, EyeOffIcon, Loader2Icon } from 'lucide-react';
import { useLocale, useTranslations } from 'next-intl';
import { useSearchParams } from 'next/navigation';
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { useForm, useWatch } from 'react-hook-form';
import * as z from 'zod';
import { Captcha } from '../shared/captcha';
import { SocialLoginButton } from './social-login-button';
interface RegisterFormProps {
@ -50,6 +53,12 @@ export const RegisterForm = ({
const [isPending, setIsPending] = useState(false);
const [showPassword, setShowPassword] = useState(false);
// turnstile captcha schema
const turnstileEnabled = isTurnstileEnabled();
const captchaSchema = turnstileEnabled
? z.string().min(1, 'Please complete the captcha')
: z.string().optional();
const RegisterSchema = z.object({
email: z.string().email({
message: t('emailRequired'),
@ -60,6 +69,7 @@ export const RegisterForm = ({
name: z.string().min(1, {
message: t('nameRequired'),
}),
captchaToken: captchaSchema,
});
const form = useForm<z.infer<typeof RegisterSchema>>({
@ -68,10 +78,30 @@ export const RegisterForm = ({
email: '',
password: '',
name: '',
captchaToken: '',
},
});
const captchaToken = useWatch({
control: form.control,
name: 'captchaToken',
});
const onSubmit = async (values: z.infer<typeof RegisterSchema>) => {
// Validate captcha token if turnstile is enabled
if (turnstileEnabled && values.captchaToken) {
const captchaResult = await validateCaptchaAction({
captchaToken: values.captchaToken,
});
if (!captchaResult?.data?.success || !captchaResult?.data?.valid) {
console.error('register, captcha invalid:', values.captchaToken);
const errorMessage = captchaResult?.data?.error || t('captchaInvalid');
setError(errorMessage);
return;
}
}
// 1. if requireEmailVerification is true, callbackURL will be used in the verification email,
// the user will be redirected to the callbackURL after the email is verified.
// 2. if requireEmailVerification is false, the user will not be redirected to the callbackURL,
@ -200,8 +230,12 @@ export const RegisterForm = ({
</div>
<FormError message={error} />
<FormSuccess message={success} />
<Captcha
onSuccess={(token) => form.setValue('captchaToken', token)}
validationError={form.formState.errors.captchaToken?.message}
/>
<Button
disabled={isPending}
disabled={isPending || (turnstileEnabled && !captchaToken)}
size="lg"
type="submit"
className="cursor-pointer w-full flex items-center justify-center gap-2"

View File

@ -0,0 +1,48 @@
'use client';
import { FormMessage } from '@/components/ui/form';
import { isTurnstileEnabled } from '@/lib/captcha';
import { useLocale } from 'next-intl';
import { useTheme } from 'next-themes';
import dynamic from 'next/dynamic';
import type { ComponentProps } from 'react';
const Turnstile = dynamic(
() => import('@marsidev/react-turnstile').then((mod) => mod.Turnstile),
{
ssr: false,
}
);
type Props = Omit<ComponentProps<typeof Turnstile>, 'siteKey'> & {
validationError?: string;
};
/**
* Captcha component for Cloudflare Turnstile
*/
export const Captcha = ({ validationError, ...props }: Props) => {
const turnstileEnabled = isTurnstileEnabled();
const theme = useTheme();
const locale = useLocale();
return turnstileEnabled ? (
<>
<Turnstile
options={{
size: 'flexible',
language: locale,
theme: theme.theme === 'dark' ? 'dark' : 'light',
}}
{...props}
siteKey={process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY || ''}
/>
{validationError && (
<FormMessage className="text-red-500 mt-2">
{validationError}
</FormMessage>
)}
</>
) : null;
};

View File

@ -69,39 +69,39 @@ export function getNavbarLinks(): NestedMenuItem[] {
href: Routes.Docs,
external: false,
},
// {
// title: t('ai.title'),
// items: [
// {
// title: t('ai.items.text.title'),
// description: t('ai.items.text.description'),
// icon: <SquarePenIcon className="size-4 shrink-0" />,
// href: Routes.AIText,
// external: false,
// },
// {
// title: t('ai.items.image.title'),
// description: t('ai.items.image.description'),
// icon: <ImageIcon className="size-4 shrink-0" />,
// href: Routes.AIImage,
// external: false,
// },
// {
// title: t('ai.items.video.title'),
// description: t('ai.items.video.description'),
// icon: <FilmIcon className="size-4 shrink-0" />,
// href: Routes.AIVideo,
// external: false,
// },
// {
// title: t('ai.items.audio.title'),
// description: t('ai.items.audio.description'),
// icon: <AudioLinesIcon className="size-4 shrink-0" />,
// href: Routes.AIAudio,
// external: false,
// },
// ],
// },
{
title: t('ai.title'),
items: [
// {
// title: t('ai.items.text.title'),
// description: t('ai.items.text.description'),
// icon: <SquarePenIcon className="size-4 shrink-0" />,
// href: Routes.AIText,
// external: false,
// },
{
title: t('ai.items.image.title'),
description: t('ai.items.image.description'),
icon: <ImageIcon className="size-4 shrink-0" />,
href: Routes.AIImage,
external: false,
},
// {
// title: t('ai.items.video.title'),
// description: t('ai.items.video.description'),
// icon: <FilmIcon className="size-4 shrink-0" />,
// href: Routes.AIVideo,
// external: false,
// },
// {
// title: t('ai.items.audio.title'),
// description: t('ai.items.audio.description'),
// icon: <AudioLinesIcon className="size-4 shrink-0" />,
// href: Routes.AIAudio,
// external: false,
// },
],
},
{
title: t('pages.title'),
items: [

View File

@ -37,6 +37,7 @@ export const websiteConfig: WebsiteConfig = {
enableUpgradeCard: true,
enableAffonsoAffiliate: false,
enablePromotekitAffiliate: false,
enableDatafastRevenueTrack: false,
},
routes: {
defaultLoginRedirect: '/dashboard',
@ -119,6 +120,7 @@ export const websiteConfig: WebsiteConfig = {
priceId: process.env.NEXT_PUBLIC_STRIPE_PRICE_LIFETIME!,
amount: 19900,
currency: 'USD',
allowPromotionCode: true,
},
],
isFree: false,

39
src/lib/captcha.ts Normal file
View File

@ -0,0 +1,39 @@
export function isTurnstileEnabled() {
return (
process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY !== '' &&
process.env.TURNSTILE_SECRET_KEY !== ''
);
}
interface TurnstileResponse {
success: boolean;
'error-codes'?: string[];
}
/**
* https://developers.cloudflare.com/turnstile/get-started/server-side-validation/
*/
export async function validateTurnstileToken(token: string) {
const turnstileEnabled = isTurnstileEnabled();
if (!turnstileEnabled) {
console.log('validateTurnstileToken, turnstile is disabled');
return true;
}
const response = await fetch(
'https://challenges.cloudflare.com/turnstile/v0/siteverify',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
secret: process.env.TURNSTILE_SECRET_KEY,
response: token,
}),
}
);
const data = (await response.json()) as TurnstileResponse;
return data.success;
}

View File

@ -227,6 +227,7 @@ export class StripeProvider implements PaymentProvider {
success_url: successUrl ?? '',
cancel_url: cancelUrl ?? '',
metadata: customMetadata,
allow_promotion_codes: price.allowPromotionCode ?? false,
};
// Add customer to checkout session

View File

@ -46,16 +46,17 @@ export interface Price {
currency: string; // Currency code (e.g., USD)
interval?: PlanInterval; // Billing interval for recurring payments
trialPeriodDays?: number; // Free trial period in days
allowPromotionCode?: boolean; // Whether to allow promotion code for this price
disabled?: boolean; // Whether to disable this price in UI
}
/**
* Price plan definition
*
*
* 1. When to set the plan disabled?
* When the plan is not available anymore, but you should keep it for existing users
* who have already purchased it, otherwise they can not see the plan in the Billing page.
*
*
* 2. When to set the price disabled?
* When the price is not available anymore, but you should keep it for existing users
* who have already purchased it, otherwise they can not see the price in the Billing page.

View File

@ -1,4 +1,5 @@
import type { ReactNode } from 'react';
import type { PricePlan } from '@/payment/types';
/**
* website config, without translations
@ -69,6 +70,7 @@ export interface FeaturesConfig {
enableUpgradeCard?: boolean; // Whether to enable the upgrade card in the sidebar
enableAffonsoAffiliate?: boolean; // Whether to enable affonso affiliate
enablePromotekitAffiliate?: boolean; // Whether to enable promotekit affiliate
enableDatafastRevenueTrack?: boolean; // Whether to enable datafast revenue tracking
}
/**