diff --git a/README.md b/README.md index 6636a54..a56b1c4 100644 --- a/README.md +++ b/README.md @@ -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 you’re unable to access any of them, please don’t hesitate to reach out to me, and I’ll assist you in resolving the issue. +By default, you should have access to all 5 repositories. If you find that you’re unable to access any of them, please don’t hesitate to reach out to me, and I’ll 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-outfit (ready)](https://github.com/MkSaaSHQ/mksaas-outfit) - [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 diff --git a/env.example b/env.example index 42165be..42b15fb 100644 --- a/env.example +++ b/env.example @@ -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="" diff --git a/messages/en.json b/messages/en.json index b8e58ee..7429307 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", diff --git a/messages/zh.json b/messages/zh.json index b68a5d1..cf11293 100644 --- a/messages/zh.json +++ b/messages/zh.json @@ -203,7 +203,9 @@ "hidePassword": "隐藏密码", "nameRequired": "请输入姓名", "emailRequired": "请输入邮箱", - "passwordRequired": "请输入密码" + "passwordRequired": "请输入密码", + "captchaInvalid": "验证码验证失败", + "captchaError": "验证码验证出错" }, "forgotPassword": { "title": "忘记密码", diff --git a/package.json b/package.json index 9483605..e2bec56 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,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", @@ -33,6 +37,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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 63b1f8b..1ffdacb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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) @@ -360,6 +375,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'} @@ -375,10 +426,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'} @@ -391,6 +452,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'} @@ -2318,6 +2385,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==} @@ -4905,6 +4978,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'} @@ -4977,6 +5054,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==} @@ -5005,6 +5085,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==} @@ -5411,6 +5494,9 @@ packages: eastasianwidth@0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + ecdsa-sig-formatter@1.0.11: + resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} + eciesjs@0.4.14: resolution: {integrity: sha512-eJAgf9pdv214Hn98FlUzclRMYWF7WfoLlkS9nWMTm1qcCwn6Ad4EGD9lr9HXMBfSrZhYQujRE+p0adPRkctC6A==} engines: {bun: '>=1', deno: '>=2', node: '>=16'} @@ -5725,6 +5811,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'} @@ -5779,6 +5873,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'} @@ -5786,6 +5888,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'} + gzip-size@6.0.0: resolution: {integrity: sha512-ax7ZYomf6jqPTQ4+XCpUGyXKHk5WweS+e05MBO4/y3WJ5RkmPXNKvX+bx1behVILVwr6JSQvZAku021CHPXG3Q==} engines: {node: '>=10'} @@ -5837,6 +5943,10 @@ packages: resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==} engines: {node: '>= 0.8'} + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + human-signals@2.1.0: resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} engines: {node: '>=10.17.0'} @@ -5964,6 +6074,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==} @@ -5977,6 +6090,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'} @@ -6491,6 +6610,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==} @@ -7242,6 +7370,9 @@ packages: resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} engines: {node: '>=0.6'} + tr46@0.0.3: + resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} + trim-lines@3.0.1: resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} @@ -7406,6 +7537,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'} @@ -7527,6 +7664,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 @@ -7542,10 +7722,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) @@ -7556,6 +7747,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 @@ -9804,6 +10001,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 @@ -12756,6 +12958,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 @@ -12830,6 +13034,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 @@ -12874,6 +13080,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: @@ -13161,6 +13369,10 @@ snapshots: eastasianwidth@0.2.0: {} + ecdsa-sig-formatter@1.0.11: + dependencies: + safe-buffer: 5.2.1 + eciesjs@0.4.14: dependencies: '@ecies/ciphers': 0.2.3(@noble/ciphers@1.3.0) @@ -13702,6 +13914,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: @@ -13769,10 +14001,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 + gzip-size@6.0.0: dependencies: duplexer: 0.1.2 @@ -13914,6 +14168,13 @@ snapshots: statuses: 2.0.1 toidentifier: 1.0.1 + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.3 + debug: 4.4.0 + transitivePeerDependencies: + - supports-color + human-signals@2.1.0: {} iconv-lite@0.6.3: @@ -14006,6 +14267,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: {} @@ -14016,6 +14281,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 @@ -14743,6 +15019,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: {} @@ -15689,6 +15969,8 @@ snapshots: toidentifier@1.0.1: {} + tr46@0.0.3: {} + trim-lines@3.0.1: {} trough@2.2.0: {} @@ -15871,6 +16153,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 diff --git a/src/actions/create-checkout-session.ts b/src/actions/create-checkout-session.ts index 7a52ae6..f5b6afb 100644 --- a/src/actions/create-checkout-session.ts +++ b/src/actions/create-checkout-session.ts @@ -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 = { ...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}', diff --git a/src/actions/validate-captcha.ts b/src/actions/validate-captcha.ts new file mode 100644 index 0000000..691f3f5 --- /dev/null +++ b/src/actions/validate-captcha.ts @@ -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', + }; + } + }); diff --git a/src/ai/image/components/ImageCarousel.tsx b/src/ai/image/components/ImageCarousel.tsx new file mode 100644 index 0000000..339896a --- /dev/null +++ b/src/ai/image/components/ImageCarousel.tsx @@ -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; + failedProviders: ProviderKey[]; + enabledProviders: Record; + providerToModel: Record; +} + +export function ImageCarousel({ + providers, + images, + timings, + failedProviders, + enabledProviders, + providerToModel, +}: ImageCarouselProps) { + const [currentSlide, setCurrentSlide] = useState(0); + const [api, setApi] = useState(); + + useEffect(() => { + if (!api) return; + + api.on('select', () => { + setCurrentSlide(api.selectedScrollSnap()); + }); + }, [api]); + + return ( +
+ + + {providers.map((provider, i) => { + const imageData = images?.find( + (img) => img.provider === provider + )?.image; + const timing = timings[provider]; + + return ( + + img.provider === provider)?.modelId || + providerToModel[provider] + } + provider={provider} + image={imageData} + timing={timing} + failed={failedProviders.includes(provider)} + enabled={enabledProviders[provider]} + /> +
+ {i + 1} of {providers.length} +
+
+ ); + })} +
+ + + +
+ + {/* Dot Indicators */} +
+
+ {providers.map((_, index) => ( + + ))} +
+
+
+ ); +} diff --git a/src/ai/image/components/ImageDisplay.tsx b/src/ai/image/components/ImageDisplay.tsx new file mode 100644 index 0000000..86a7a17 --- /dev/null +++ b/src/ai/image/components/ImageDisplay.tsx @@ -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 ( + <> +
+ {(image || failed) && ( +
+ + + + + + +

{modelId}

+
+
+
+
+ )} + {image && !failed ? ( + <> + {/* eslint-disable-next-line @next/next/no-img-element */} + {`Generated + + {timing?.elapsed && ( +
+ + {(timing.elapsed / 1000).toFixed(1)}s + +
+ )} + + ) : ( +
+ {failed ? ( + fallbackIcon || + ) : image ? ( + <> + {/* eslint-disable-next-line @next/next/no-img-element */} + {`Generated + + + ) : timing?.startTime ? ( + <> + {/*
{provider}
*/} + + + ) : ( + + )} +
+ )} +
+ + {isZoomed && + image && + createPortal( +
setIsZoomed(false)} + > + {/* eslint-disable-next-line @next/next/no-img-element */} + {`Generated e.stopPropagation()} + /> +
, + document.body + )} + + ); +} diff --git a/src/ai/image/components/ImageGenerator.tsx b/src/ai/image/components/ImageGenerator.tsx new file mode 100644 index 0000000..cd3dfd9 --- /dev/null +++ b/src/ai/image/components/ImageGenerator.tsx @@ -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; + enabledProviders: Record; + toggleView: () => void; +} + +export function ImageGenerator({ + images, + errors, + failedProviders, + timings, + enabledProviders, + toggleView, +}: ImageGeneratorProps) { + return ( +
+ {/* If there are errors, render a collapsible alert */} + {errors.length > 0 && ( + + + + + +
+ {errors.map((err, index) => ( + + +
+ + {err.provider} Error + + + {err.message} + +
+
+ ))} +
+
+
+ )} + +
+

Generated Images

+ +
+ + {/* Mobile layout: Carousel */} +
+ ()} + /> +
+ + {/* Desktop layout: Grid */} +
+ {PROVIDER_ORDER.map((provider) => { + const imageItem = images.find((img) => img.provider === provider); + const imageData = imageItem?.image; + const timing = timings[provider]; + return ( + + ); + })} +
+
+ ); +} diff --git a/src/ai/image/components/ImageGeneratorHeader.tsx b/src/ai/image/components/ImageGeneratorHeader.tsx new file mode 100644 index 0000000..da13f29 --- /dev/null +++ b/src/ai/image/components/ImageGeneratorHeader.tsx @@ -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 ( +
+
+
+

+ 🏞️ AI Image Generator +

+
+ {/* + + */} + + {/* {}} value="performance" /> */} +
+
+ ); +}; diff --git a/src/ai/image/components/ImagePlayground.tsx b/src/ai/image/components/ImagePlayground.tsx new file mode 100644 index 0000000..bf85603 --- /dev/null +++ b/src/ai/image/components/ImagePlayground.tsx @@ -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 + >(MODEL_CONFIGS.performance); + const [enabledProviders, setEnabledProviders] = useState( + initializeProviderRecord(true) + ); + const [mode, setMode] = useState('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 ( +
+
+ {/* header */} + + + {/* input prompt */} + + + {/* 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 ( + <> +
+ +
+
+ {getModelProps().map((props) => ( + + ))} +
+ {activePrompt && activePrompt.length > 0 && ( +
+ {activePrompt} +
+ )} + + ); + })()} +
+
+ ); +} diff --git a/src/ai/image/components/ModelCardCarousel.tsx b/src/ai/image/components/ModelCardCarousel.tsx new file mode 100644 index 0000000..b32acf3 --- /dev/null +++ b/src/ai/image/components/ModelCardCarousel.tsx @@ -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(); + 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 ( +
+ + + {models.map((model, i) => ( + + + model.onChange(value, providerKey) + } + /> +
+ {i + 1} of {models.length} +
+
+ ))} +
+ + + +
+ + {/* Dot Indicators */} +
+
+ {models.map((_, index) => ( + + ))} +
+
+
+ ); +} diff --git a/src/ai/image/components/ModelSelect.tsx b/src/ai/image/components/ModelSelect.tsx new file mode 100644 index 0000000..a1724a0 --- /dev/null +++ b/src/ai/image/components/ModelSelect.tsx @@ -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 ( + + +
+
+ {/* model provider icon */} +
+ +
+ +
+ + + +

{label}

+ +
+ + {/* models in provider */} +
+ +
+
+
+ + +
+
+ ); +} diff --git a/src/ai/image/components/PromptInput.tsx b/src/ai/image/components/PromptInput.tsx new file mode 100644 index 0000000..e63634e --- /dev/null +++ b/src/ai/image/components/PromptInput.tsx @@ -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(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) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + if (!isLoading && input.trim()) { + onSubmit(input); + } + } + }; + + return ( +
+
+
+