v1.0
This commit is contained in:
1
.env.development
Normal file
1
.env.development
Normal file
@@ -0,0 +1 @@
|
||||
NEXT_PUBLIC_BASEURL=https://www.typeframes.cc/api
|
||||
1
.env.production
Normal file
1
.env.production
Normal file
@@ -0,0 +1 @@
|
||||
NEXT_PUBLIC_BASEURL=https://www.typeframes.cc/api
|
||||
7
.eslintrc.json
Normal file
7
.eslintrc.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "next/core-web-vitals",
|
||||
"rules": {
|
||||
"react/no-unescaped-entities": "off",
|
||||
"react/jsx-no-duplicate-props": "off"
|
||||
}
|
||||
}
|
||||
38
.gitignore
vendored
Normal file
38
.gitignore
vendored
Normal file
@@ -0,0 +1,38 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
.yarn/install-state.gz
|
||||
|
||||
/public
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# local env files
|
||||
.env*.local
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
23
Dockerfile
Normal file
23
Dockerfile
Normal file
@@ -0,0 +1,23 @@
|
||||
# 使用指定的 Node.js 版本作为基础镜像
|
||||
FROM node:20.15.1
|
||||
|
||||
# 设置工作目录
|
||||
WORKDIR /app
|
||||
|
||||
# 复制 package.json 和 package-lock.json(如果有)到工作目录
|
||||
COPY package*.json ./
|
||||
|
||||
# 安装依赖
|
||||
RUN npm install
|
||||
|
||||
# 复制项目的所有文件到工作目录
|
||||
COPY . .
|
||||
|
||||
# 构建 Next.js 项目
|
||||
RUN npm run build
|
||||
|
||||
# 暴露应用运行的端口
|
||||
EXPOSE 12680
|
||||
|
||||
# 启动 Next.js 服务
|
||||
CMD ["npm", "start"]
|
||||
36
README.md
Normal file
36
README.md
Normal file
@@ -0,0 +1,36 @@
|
||||
This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app).
|
||||
|
||||
## Getting Started
|
||||
|
||||
First, run the development server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
# or
|
||||
pnpm dev
|
||||
# or
|
||||
bun dev
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
|
||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||
|
||||
This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font.
|
||||
|
||||
## Learn More
|
||||
|
||||
To learn more about Next.js, take a look at the following resources:
|
||||
|
||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
|
||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome!
|
||||
|
||||
## Deploy on Vercel
|
||||
|
||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||
|
||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details.
|
||||
43
next.config.mjs
Normal file
43
next.config.mjs
Normal file
@@ -0,0 +1,43 @@
|
||||
import { fileURLToPath } from 'url';
|
||||
import path from 'path';
|
||||
import createNextIntlPlugin from 'next-intl/plugin';
|
||||
|
||||
const withNextIntl = createNextIntlPlugin();
|
||||
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
images: {
|
||||
formats: ["image/avif", "image/webp"],
|
||||
remotePatterns: [
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "typeframes.ai",
|
||||
port: "",
|
||||
pathname: "/_next/image/**",
|
||||
},
|
||||
],
|
||||
domains: ['www.typeframes.com'], // 允许从该域名加载图片资源
|
||||
|
||||
},
|
||||
sassOptions: {
|
||||
includePaths: [path.join(path.dirname(fileURLToPath(import.meta.url)), 'styles')],
|
||||
},
|
||||
async rewrites() {
|
||||
return [
|
||||
{
|
||||
source: '/api/:path*',
|
||||
destination: 'https://www.typeframes.cc/api/:path*/',
|
||||
},
|
||||
{
|
||||
source: '/oauth2callback/:path*',
|
||||
destination: 'https://www.typeframes.cc/oauth2callback/:path*/',
|
||||
},
|
||||
{
|
||||
source: '/google/:path*',
|
||||
destination: 'https://www.typeframes.cc/google/:path*/',
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
export default withNextIntl(nextConfig);
|
||||
6829
package-lock.json
generated
Normal file
6829
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
41
package.json
Normal file
41
package.json
Normal file
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"name": "typeframes-ai",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start -p 12680",
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@headlessui/react": "^2.1.2",
|
||||
"@radix-ui/react-collapsible": "^1.1.0",
|
||||
"@radix-ui/react-dialog": "^1.1.1",
|
||||
"@radix-ui/react-tabs": "^1.1.0",
|
||||
"@radix-ui/react-toast": "^1.2.1",
|
||||
"@radix-ui/themes": "^3.1.1",
|
||||
"classnames": "^2.5.1",
|
||||
"framer-motion": "^11.3.28",
|
||||
"next": "14.2.5",
|
||||
"next-intl": "^3.19.0",
|
||||
"query-string": "^9.1.0",
|
||||
"react": "^18",
|
||||
"react-dom": "^18",
|
||||
"react-icons": "^5.2.1",
|
||||
"zustand": "^4.5.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/forms": "^0.5.7",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^18",
|
||||
"@types/react-dom": "^18",
|
||||
"daisyui": "^4.12.10",
|
||||
"eslint": "^8",
|
||||
"eslint-config-next": "14.2.5",
|
||||
"postcss": "^8",
|
||||
"sass": "^1.77.8",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
8
postcss.config.mjs
Normal file
8
postcss.config.mjs
Normal file
@@ -0,0 +1,8 @@
|
||||
/** @type {import('postcss-load-config').Config} */
|
||||
const config = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
21
src/apis/auth.ts
Normal file
21
src/apis/auth.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
// import 'server-only';
|
||||
|
||||
import request from '@/utils/request';
|
||||
|
||||
|
||||
export const GetUserInfo = (params?: Record<string, any>) => {
|
||||
return request.get<any>('/profile/', {
|
||||
params,
|
||||
cacheTime: 0,
|
||||
});
|
||||
};
|
||||
|
||||
export const GetSpeakersData = () => {
|
||||
return request.get<any>('/get_speakers/');
|
||||
}
|
||||
export const GetVideoList = () => {
|
||||
return request.get<any>('/video-list/');
|
||||
}
|
||||
export const GetMetadata = () => {
|
||||
return request.get<any>('/website/');
|
||||
}
|
||||
24
src/app/(console)/create/page.tsx
Normal file
24
src/app/(console)/create/page.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { useTranslations } from "next-intl";
|
||||
import CreateTabs from "@/ui/(console)/create/create-tabs";
|
||||
|
||||
export default function Page() {
|
||||
const t = useTranslations("createPage");
|
||||
|
||||
return (
|
||||
<div className="bg-base-100 flex-[80%] overflow-auto flex flex-col gap-8 p-8">
|
||||
<div className="min-height: 100vh; width: 100%; padding-right: 0px;">
|
||||
<div className="w-full">
|
||||
<div className="w-full mb-6">
|
||||
<div className="text-2xl font-bold text-neutral flex gap-4 items-center">
|
||||
<div>{t("createNewVideo")}</div>
|
||||
</div>
|
||||
<p className="text-info text-sm font-light max-w-[620px]">
|
||||
{t("selectStyleOrTemplate")}
|
||||
</p>
|
||||
</div>
|
||||
<CreateTabs />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
3
src/app/(console)/home/page.tsx
Normal file
3
src/app/(console)/home/page.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
export default function Page() {
|
||||
return <>123</>;
|
||||
}
|
||||
23
src/app/(console)/layout.tsx
Normal file
23
src/app/(console)/layout.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import SideBar from "@/ui/(console)/side-bar";
|
||||
import { Metadata } from "next";
|
||||
import GlobalLoading from "@/components/GlobalLoading";
|
||||
import UserInfo from "@/components/logics/UserInfo";
|
||||
|
||||
export default async function ConsoleLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<>
|
||||
<div className="w-full flex overflow-hidden h-screen bg-[#f6f6f6]">
|
||||
{/* <Suspense fallback={<Loading />}> */}
|
||||
<SideBar />
|
||||
{children}
|
||||
<GlobalLoading />
|
||||
{/* </Suspense> */}
|
||||
</div>
|
||||
<UserInfo />
|
||||
</>
|
||||
);
|
||||
}
|
||||
14
src/app/(console)/loading.tsx
Normal file
14
src/app/(console)/loading.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export default function Loading() {
|
||||
const t = useTranslations("loading");
|
||||
|
||||
return (
|
||||
<main className="z-0 relative h-screen w-screen gap-2 flex justify-center items-center">
|
||||
<div className="text-black text-[13px] opacity-30 font-medium flex items-center">
|
||||
<span className="loading loading-spinner loading-sm text-secondary mr-1"></span>
|
||||
{t("preparing")}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
129
src/app/(console)/projects/page.tsx
Normal file
129
src/app/(console)/projects/page.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
"use client";
|
||||
import useFetch from "@/hooks/useFetch";
|
||||
import { useEffect, useState, useRef } from "react";
|
||||
import { FaPlus } from "react-icons/fa6";
|
||||
import VideoItem from "@/ui/(console)/projects/video-item";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Loading from "@/components/Loading";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { list } from "postcss";
|
||||
|
||||
export default function Page() {
|
||||
const t = useTranslations("project");
|
||||
const router = useRouter();
|
||||
const [page, setPage] = useState(1);
|
||||
const [videos, setVideos] = useState<any>([]);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const observerRef = useRef(null); // 用于观察的元素
|
||||
const {
|
||||
data: videoData,
|
||||
loading,
|
||||
fetchData: fetchVideoList,
|
||||
} = useFetch({
|
||||
url: `/api/video-list/?page=${page}&page_size=10`,
|
||||
method: "GET",
|
||||
cacheTime: 0,
|
||||
});
|
||||
|
||||
// 获取视频列表
|
||||
useEffect(() => {
|
||||
if (page === 1) {
|
||||
fetchVideoList().then((res) => {
|
||||
setVideos(res.list || []);
|
||||
setHasMore(res.page < res.total_pages); // 检查是否还有更多页面
|
||||
});
|
||||
} else if (!loadingMore) {
|
||||
setLoadingMore(true);
|
||||
fetchVideoList().then((res) => {
|
||||
setVideos((prevVideos) => [...prevVideos, ...(res.list || [])]);
|
||||
setHasMore(res.page < res.total_pages);
|
||||
setLoadingMore(false);
|
||||
});
|
||||
}
|
||||
}, [page]);
|
||||
|
||||
// 触底加载更多
|
||||
useEffect(() => {
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0].isIntersecting && hasMore && !loadingMore) {
|
||||
setPage((prevPage) => prevPage + 1);
|
||||
}
|
||||
},
|
||||
{ threshold: 1 }
|
||||
);
|
||||
|
||||
if (observerRef.current) {
|
||||
observer.observe(observerRef.current);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (observerRef.current) observer.unobserve(observerRef.current);
|
||||
};
|
||||
}, [hasMore, loadingMore]);
|
||||
|
||||
// 没有滚动条时继续加载
|
||||
useEffect(() => {
|
||||
const handleNoScrollBar = () => {
|
||||
if (
|
||||
document.documentElement.scrollHeight <=
|
||||
document.documentElement.clientHeight &&
|
||||
hasMore
|
||||
) {
|
||||
setPage((prevPage) => prevPage + 1);
|
||||
}
|
||||
};
|
||||
|
||||
handleNoScrollBar(); // 初始化时检查
|
||||
|
||||
window.addEventListener("resize", handleNoScrollBar); // 窗口大小变化时检查
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("resize", handleNoScrollBar);
|
||||
};
|
||||
}, [hasMore]);
|
||||
|
||||
return (
|
||||
<div className="bg-base-100 flex-[80%] overflow-auto flex flex-col gap-8 p-8">
|
||||
<div className="min-h-screen w-full pr-0">
|
||||
<main className="w-full">
|
||||
<div className="flex justify-between items-center w-full">
|
||||
<div className="flex flex-col">
|
||||
<div className="text-2xl font-bold">
|
||||
{t("yourVideos")}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => router.push("/create")}
|
||||
className="inline-flex gap-1.5 items-center justify-center whitespace-nowrap z-0 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 bg-black border border-black relative w-auto text-white px-4 py-2 rounded-md"
|
||||
>
|
||||
<span className="btn-icon">
|
||||
<FaPlus size="1em" />
|
||||
</span>
|
||||
{t("newVideo")}
|
||||
</button>
|
||||
</div>
|
||||
<div className="pt-8 h-full">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-4 gap-8 w-full">
|
||||
{videos.length > 0 ? (
|
||||
videos.map((item, index) => (
|
||||
<VideoItem
|
||||
item={item}
|
||||
key={index}
|
||||
></VideoItem>
|
||||
))
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</div>
|
||||
{loadingMore && <Loading />}{" "}
|
||||
{/* 加载更多时显示加载中 */}
|
||||
<div ref={observerRef} style={{ height: 1 }}></div>{" "}
|
||||
{/* 观察触底的元素 */}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
3
src/app/(console)/queue/page.tsx
Normal file
3
src/app/(console)/queue/page.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
export default function Page() {
|
||||
return <div>queue</div>;
|
||||
}
|
||||
24
src/app/(main)/layout.tsx
Normal file
24
src/app/(main)/layout.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import PageHeader from "@/ui/page/page-header";
|
||||
import PageFooter from "@/ui/page/page-footer";
|
||||
import GlobalLoading from "@/components/GlobalLoading";
|
||||
export default function MainLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<>
|
||||
<main
|
||||
data-theme="typeframes"
|
||||
className="z-0 bg-almostblack relative flex flex-col items-center overflow-x-hidden"
|
||||
>
|
||||
<PageHeader />
|
||||
<section className="max-w-screen-2xl w-full relative py-24 px-4 md:px-16 ">
|
||||
{children}
|
||||
</section>
|
||||
<PageFooter />
|
||||
</main>
|
||||
<GlobalLoading />
|
||||
</>
|
||||
);
|
||||
}
|
||||
20
src/app/(main)/page.tsx
Normal file
20
src/app/(main)/page.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import PageIntro from "@/ui/page/page-intro";
|
||||
import PageStatis from "@/ui/page/page-statis";
|
||||
import PageTeach from "@/ui/page/page-Teach";
|
||||
import PageExpect from "@/ui/page/page-expect";
|
||||
import PageTools from "@/ui/page/page-tools";
|
||||
import PageFaqs from "@/ui/page/page-faqs";
|
||||
import PageRemark from "@/ui/page/page-remark";
|
||||
export default function Home() {
|
||||
return (
|
||||
<>
|
||||
<PageIntro />
|
||||
<PageStatis />
|
||||
{/* <PageTeach /> */}
|
||||
<PageExpect />
|
||||
{/* <PageTools /> */}
|
||||
{/* <PageFaqs /> */}
|
||||
<PageRemark />
|
||||
</>
|
||||
);
|
||||
}
|
||||
247
src/app/(main)/pricing/page.tsx
Normal file
247
src/app/(main)/pricing/page.tsx
Normal file
@@ -0,0 +1,247 @@
|
||||
"use client";
|
||||
import PaymentDialog from "@/components/PaymentDialog";
|
||||
import useFetch from "@/hooks/useFetch";
|
||||
import useLoadingStore from "@/store/loadingStore";
|
||||
import classNames from "classnames";
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
export default function Page() {
|
||||
const t = useTranslations("pricing"); // 使用翻译
|
||||
const { fetchData: GetPricing, data: pricingData } = useFetch({
|
||||
url: "/api/plans/",
|
||||
method: "GET",
|
||||
});
|
||||
useEffect(() => {
|
||||
GetPricing().then((res) => {
|
||||
console.log("pricing:", res);
|
||||
});
|
||||
}, []);
|
||||
const {
|
||||
fetchData: createPayPalOrder,
|
||||
loading: payPalLoading,
|
||||
data: payPalData,
|
||||
} = useFetch({
|
||||
url: "/api/paypal/create/",
|
||||
method: "POST",
|
||||
});
|
||||
const showLoading = useLoadingStore((state) => state.showLoading);
|
||||
const hideLoading = useLoadingStore((state) => state.hideLoading);
|
||||
const {
|
||||
fetchData: createAlipayOrder,
|
||||
loading: alipayLoading,
|
||||
data: alipayData,
|
||||
} = useFetch({
|
||||
url: "/api/alipay/create_order/",
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
const {
|
||||
fetchData: createAlipayH5Order,
|
||||
loading: alipayH5Loading,
|
||||
data: alipayH5Data,
|
||||
} = useFetch({
|
||||
url: "/api/alipay/create_h5_order/",
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
const [isDialogOpen, setDialogOpen] = useState(false);
|
||||
const [selectedPlan, setSelectedPlan] = useState({
|
||||
id: null,
|
||||
title: "",
|
||||
price: null,
|
||||
});
|
||||
|
||||
const openDialog = () => {
|
||||
setDialogOpen(true);
|
||||
};
|
||||
const handlePay = (platform) => {
|
||||
const { id, title, price } = selectedPlan;
|
||||
if (!id || !title || !price) return;
|
||||
if (platform === "paypal") {
|
||||
showLoading();
|
||||
createPayPalOrder({ plan_id: id, payment_method: "paypal" })
|
||||
.then((res) => {
|
||||
console.log("paypal res:", res);
|
||||
window.location.href = res.approval_url;
|
||||
})
|
||||
.finally(() => {
|
||||
hideLoading();
|
||||
});
|
||||
} else {
|
||||
showLoading();
|
||||
createAlipayOrder({ id, title, price })
|
||||
.then((res) => {
|
||||
console.log("Alipay res:", res);
|
||||
window.location.href = res.alipay_url;
|
||||
})
|
||||
.finally(() => {
|
||||
hideLoading();
|
||||
});
|
||||
}
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<article className="rounded-lg px-4 sm:px-6 md:px-8 border border-[#252629] w-full bg-[#15171a]">
|
||||
<div className="w-full relative border-x border-[#353e3b]">
|
||||
<div className="absolute z-0 w-full h-full grid lg:grid-cols-2 gap-8 items-center">
|
||||
<section className="-z-0 absolute w-full h-full col-span-2 grid grid-cols-2 place-content-between">
|
||||
<div className="bg-[#4BDE81] rounded-full w-1 h-1 my-8 outline outline-8 outline-[#15171A] -mx-[2.5px] "></div>
|
||||
<div className="bg-[#4BDE81] rounded-full w-1 h-1 my-8 outline outline-8 outline-[#15171A] -mx-[2px] place-self-end"></div>
|
||||
<div className="bg-[#4BDE81] rounded-full w-1 h-1 my-8 outline outline-8 outline-[#15171A] -mx-[2.5px] "></div>
|
||||
<div className="bg-[#4BDE81] rounded-full w-1 h-1 my-8 outline outline-8 outline-[#15171A] -mx-[2px] place-self-end"></div>
|
||||
</section>
|
||||
</div>
|
||||
<div className="relative z-10 mx-auto py-12 lg:py-24">
|
||||
<div className="space-y-4 mb-14">
|
||||
<div className="mx-auto text-green-200 border border-[#c8eed6]/25 shadow-lg shadow-[#4add80]/10 w-fit font-medium text-sm rounded-full bg-gradient-to-b from-[#737b88]/20 to-[#191b1e]/20 p-[1.5px]">
|
||||
<div className="bg-[#15171a]/50 flex items-center space-x-1 py-1 px-2 rounded-full ">
|
||||
{/* SVG Icon here */}
|
||||
<span> {t("pricing")}</span>
|
||||
</div>
|
||||
</div>
|
||||
<h2 className='text-white font-["Euclid_Circular_A"] text-3xl lg:text-4xl xl:text-5xl text-center w-11/12 max-w-3xl text-pretty mx-auto'>
|
||||
{t("chooseYourPlan")}
|
||||
</h2>
|
||||
{/* <p className="text-center text-neutral-dark">
|
||||
Get 2 months free when you pay yearly
|
||||
</p> */}
|
||||
</div>
|
||||
<article className="grid grid-cols-1 lg:grid-cols-3 gap-4 items-start justify-start w-[95%] lg:w-[85%] xl:w-[75%] mx-auto">
|
||||
{pricingData &&
|
||||
pricingData.map((plan, index) => (
|
||||
<section
|
||||
key={index}
|
||||
className={classNames(
|
||||
"relative rounded-xl p-8 border border-white/5 flex flex-col",
|
||||
{
|
||||
"bg-[#353e3b]/20":
|
||||
plan.is_promotional,
|
||||
}
|
||||
)}
|
||||
>
|
||||
{plan.is_promotional && (
|
||||
<small className="text-[#beffd6] absolute top-2 right-2 bg-white/5 font-normal rounded-full px-2 py-1 text-xs ml-auto">
|
||||
{t("LaunchOffer")}
|
||||
</small>
|
||||
)}
|
||||
<div className="uppercase text-transparent font-medium text-left bg-clip-text bg-gradient-to-b bg-[length:250%_100%] from-white/50 via-white to-white/50">
|
||||
{t(plan.title)}
|
||||
</div>
|
||||
<div className='w-full my-5 mb-2 flex items-end gap-2 text-white font-["Euclid_Circular_A"] text-4xl font-bold'>
|
||||
<div
|
||||
className="flex flex-col overflow-hidden"
|
||||
style={{ height: "1em" }}
|
||||
>
|
||||
<span>{`$${plan.price}`}</span>
|
||||
</div>
|
||||
{/* <small className="text-neutral-dark text-sm -ml-1.5 font-normal">
|
||||
/month
|
||||
</small> */}
|
||||
</div>
|
||||
<div className="mt-4 flex flex-col gap-3">
|
||||
<div className="flex flex-col items-start w-full gap-2.5 py-4 text-neutral-dark border-y border-white/5">
|
||||
<div className="flex gap-2 items-center">
|
||||
{/* SVG Icon here */}
|
||||
{t("get")}{" "}
|
||||
{plan.credits_per_month}{" "}
|
||||
{t("points")}
|
||||
</div>
|
||||
<div className="flex gap-2 items-center">
|
||||
{t("FastVideoGeneration")}
|
||||
</div>
|
||||
<div className="flex gap-2 items-center">
|
||||
{t("RichVideoContent")}
|
||||
</div>
|
||||
</div>
|
||||
<div className="z-[0] relative group">
|
||||
<div
|
||||
className={classNames(
|
||||
"mt-3 mb-2",
|
||||
{
|
||||
"btn-tf-primary-container":
|
||||
plan.is_promotional,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<Link
|
||||
className={classNames(
|
||||
"btn-tf w-full flex items-center justify-center gap-2 transition-all duration-300", // 添加 transition 以使 hover 效果平滑
|
||||
{
|
||||
"btn-tf-secondary hover:border-secondary hover:text-secondary":
|
||||
!plan.is_promotional,
|
||||
"btn-tf-primary hover:shadow-[0_7px_16.1px_-3px_rgba(75,222,129,.49)]":
|
||||
plan.is_promotional,
|
||||
}
|
||||
)}
|
||||
href="#"
|
||||
onClick={() => {
|
||||
openDialog();
|
||||
setSelectedPlan({
|
||||
id: plan.id,
|
||||
title: plan.title,
|
||||
price: plan.price,
|
||||
});
|
||||
}}
|
||||
>
|
||||
{t("SelectPlan")}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 flex flex-col gap-1 text-neutral-dark text-xs font-medium">
|
||||
<div className="text-[10px] leading-tight font-normal text-[#6e7176]">
|
||||
{t("agree")}
|
||||
{/* <Link
|
||||
className="font-semibold"
|
||||
href="/terms"
|
||||
>
|
||||
terms
|
||||
</Link> */}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</article>
|
||||
<div className="mt-10 w-full flex justify-center items-center">
|
||||
<p className="text-info">
|
||||
{t("SubscriptionsInclude")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-center space-y-3 flex flex-col items-center mt-14">
|
||||
<article className="flex items-center flex-col w-full mt-10 space-y-2">
|
||||
<div className="flex flex-col">
|
||||
<h2 className="text-xl text-center font-semibold text-white">
|
||||
{t("GenerateVideos")}
|
||||
</h2>
|
||||
<p className="text-neutral-dark text-sm text-center sm:text-base">
|
||||
{t("SaveMoney")}
|
||||
</p>
|
||||
<div className="mt-8 flex flex-col justify-center items-center">
|
||||
<span className="text-gray-300 font-bold">
|
||||
{t("UsedByCreators")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* <div className="mt-0 flex flex-col justify-center items-center">
|
||||
<div className="flex gap-4 flex-col items-center md:flex-row md:items-end ">
|
||||
<div className="mt-4 flex -space-x-1 children:h-10 children:w-10 children:rounded-full children:object-cover children:ring-2 children:ring-white">
|
||||
</div>
|
||||
<div className="text-info text-sm italic">
|
||||
and 1,000+ others
|
||||
</div>
|
||||
</div>
|
||||
</div> */}
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
<PaymentDialog
|
||||
open={isDialogOpen}
|
||||
setOpen={setDialogOpen}
|
||||
onHandlePay={handlePay}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
30
src/app/(main)/terms/page.tsx
Normal file
30
src/app/(main)/terms/page.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import useFetch from "@/hooks/useFetch";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export default function AgreementPage() {
|
||||
const t = useTranslations("terms"); // 翻译使用
|
||||
|
||||
return (
|
||||
<article className="rounded-lg px-4 sm:px-6 md:px-8 border border-[#252629] w-full bg-[#15171a]">
|
||||
<div className="relative z-10 mx-auto py-12 lg:py-24">
|
||||
<div className="space-y-4 mb-14">
|
||||
<h2 className='text-white font-["Euclid_Circular_A"] text-3xl lg:text-4xl xl:text-5xl text-center'>
|
||||
{t("title")} {/* 用户协议的标题 */}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
|
||||
<article className="w-[95%] lg:w-[85%] xl:w-[75%] mx-auto space-y-6 text-white">
|
||||
{/* 用户协议展示 */}
|
||||
<section>
|
||||
<p className="whitespace-pre-line">{t("text")}</p>
|
||||
</section>
|
||||
</article>
|
||||
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
BIN
src/app/favicon.ico
Normal file
BIN
src/app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
103
src/app/globals.scss
Normal file
103
src/app/globals.scss
Normal file
@@ -0,0 +1,103 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer utilities {
|
||||
.text-balance {
|
||||
text-wrap: balance;
|
||||
}
|
||||
}
|
||||
|
||||
/* 首页部分 */
|
||||
.bg-almostblack {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(16 18 21 / var(--tw-bg-opacity));
|
||||
}
|
||||
.text-neutral-dark {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(190 192 199 / var(--tw-text-opacity));
|
||||
}
|
||||
.btn-tf-secondary {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(255 255 255 / var(--tw-text-opacity));
|
||||
border-width: 0.0625rem;
|
||||
border-color: #ffffff15;
|
||||
background: radial-gradient(89.39% 89.39% at 50% 50%, rgba(16, 18, 21, 0.1) 0, hsla(0, 0%, 100%, 0.15) 100%);
|
||||
transition: all 0.15s ease-in-out;
|
||||
}
|
||||
.btn-tf-primary {
|
||||
--tw-text-opacity: 1;
|
||||
font-weight: 600;
|
||||
border-radius: 9999px;
|
||||
background: linear-gradient(180deg, #4bde8150, #a8ffc8 50%, #4bde81);
|
||||
transition: all 0.25s ease;
|
||||
}
|
||||
.btn-tf {
|
||||
padding: 8px 20px;
|
||||
position: relative;
|
||||
height: -moz-fit-content;
|
||||
height: fit-content;
|
||||
border-radius: 624.9375rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
.btn-tf-primary-container {
|
||||
border-radius: 624.9375rem;
|
||||
border-width: 0.0625rem;
|
||||
--tw-text-opacity: 1;
|
||||
--tw-border-opacity: 1;
|
||||
border-color: rgb(75 222 129 / var(--tw-border-opacity));
|
||||
display: flex;
|
||||
padding: 0.0938rem;
|
||||
background: linear-gradient(180deg, #a8ffc8, #4bde81);
|
||||
box-shadow:
|
||||
0 0 0 0.25rem #ffffff24,
|
||||
0 0 0 0.0625rem #4bde81;
|
||||
}
|
||||
.btn-tf-primary {
|
||||
--tw-text-opacity: 1;
|
||||
font-weight: 600;
|
||||
border-radius: 624.9375rem;
|
||||
background: linear-gradient(180deg, #4bde8150, #a8ffc8 50%, #4bde81);
|
||||
transition: all 0.25s ease;
|
||||
}
|
||||
.btn-tf-primary,
|
||||
.btn-tf-primary-container {
|
||||
color: rgb(16 18 21 / var(--tw-text-opacity));
|
||||
}
|
||||
.gradient-bg {
|
||||
background: linear-gradient(180deg, rgba(75, 222, 129, 0.05), rgba(75, 222, 129, 0)), #15171a;
|
||||
}
|
||||
.btn-tf-sm {
|
||||
padding: 6px 14px;
|
||||
height: auto;
|
||||
border-radius: 624.9375rem;
|
||||
height: 32px;
|
||||
padding-left: 12px;
|
||||
padding-right: 12px;
|
||||
min-height: 32px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.diagonal-pattern {
|
||||
background-color: rgba(229, 229, 247, 0);
|
||||
opacity: 0.8;
|
||||
background: repeating-linear-gradient(
|
||||
-45deg,
|
||||
#262c2c,
|
||||
#262c2c 0.0938rem,
|
||||
rgba(229, 229, 247, 0) 0,
|
||||
rgba(229, 229, 247, 0) 0.375rem
|
||||
);
|
||||
}
|
||||
|
||||
.box-shadow-tf {
|
||||
box-shadow:
|
||||
0 0.0625rem 0.375rem -0.125rem rgba(0, 0, 0, 0.15),
|
||||
0 0.5rem 4.375rem -0.375rem rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
.kbd {
|
||||
@apply rounded-[.25rem] border-[.0625rem] border-b-[.125rem]; /* 设置圆角和边框 */
|
||||
@apply px-[4px]; /* 设置左右内边距 */
|
||||
@apply text-[14px] leading-[20px]; /* 设置字体大小和行高 */
|
||||
@apply min-h-[1.6em] min-w-[1.6em]; /* 设置最小高度和宽度 */
|
||||
@apply text-info;
|
||||
}
|
||||
57
src/app/layout.tsx
Normal file
57
src/app/layout.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Suspense } from "react";
|
||||
import { Inter } from "next/font/google";
|
||||
import { NextIntlClientProvider } from "next-intl";
|
||||
import { getLocale, getMessages } from "next-intl/server";
|
||||
import { GetMetadata } from "@/apis/auth";
|
||||
import "./globals.scss";
|
||||
import Loading from "./loading";
|
||||
import CombinedProviders from "@/contexts/CombinedProviders";
|
||||
|
||||
const inter = Inter({ subsets: ["latin"] });
|
||||
|
||||
// export const metadata: Metadata = {
|
||||
// title: "typeframes",
|
||||
// description: "Generated by typeframes",
|
||||
// };
|
||||
// 将metadata改为异步函数,获取动态SEO数据
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const res = await GetMetadata(); // 调用API获取SEO数据
|
||||
if (res.code === 200) {
|
||||
const seoData = res.data;
|
||||
const locale = await getLocale();
|
||||
return {
|
||||
title: seoData["title_" + locale] || "typeframes",
|
||||
description:
|
||||
seoData["description_" + locale] || "Generated by typeframes",
|
||||
keywords: seoData["keywords_" + locale] || "",
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
title: "typeframes",
|
||||
description: "Generated by typeframes",
|
||||
};
|
||||
}
|
||||
}
|
||||
async function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
const locale = await getLocale();
|
||||
|
||||
const messages = await getMessages();
|
||||
|
||||
return (
|
||||
<html lang={locale}>
|
||||
<body className={inter.className}>
|
||||
<NextIntlClientProvider messages={messages}>
|
||||
<Suspense fallback={<Loading />}>
|
||||
<CombinedProviders>{children}</CombinedProviders>
|
||||
</Suspense>
|
||||
</NextIntlClientProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
export default RootLayout;
|
||||
14
src/app/loading.tsx
Normal file
14
src/app/loading.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export default function Loading() {
|
||||
const t = useTranslations("loading");
|
||||
|
||||
return (
|
||||
<main className="z-0 relative h-screen w-screen gap-2 flex justify-center items-center">
|
||||
<div className="text-black text-[13px] opacity-30 font-medium flex items-center">
|
||||
<span className="loading loading-spinner loading-sm text-secondary mr-1"></span>
|
||||
{t("preparing")}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
12
src/app/login/login.module.css
Normal file
12
src/app/login/login.module.css
Normal file
@@ -0,0 +1,12 @@
|
||||
.login_form {
|
||||
background-color: rgba(255, 255, 255, 0.1); /* 半透明背景 */
|
||||
padding: 30px;
|
||||
border-radius: 15px;
|
||||
backdrop-filter: blur(10px); /* 背景模糊效果 */
|
||||
box-shadow: 0 4px 30px rgba(0, 0, 0, 0.1); /* 轻微阴影 */
|
||||
|
||||
width: 100%;
|
||||
border: 1px solid rgba(74,222,128, 0.9); /* 添加微弱的白色边框 */
|
||||
|
||||
}
|
||||
|
||||
69
src/app/login/page.tsx
Normal file
69
src/app/login/page.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
"use client";
|
||||
import Link from "next/link";
|
||||
|
||||
import LoginForm from "@/ui/login/login-form";
|
||||
import { useState } from "react";
|
||||
import RegisterForm from "@/ui/login/register-form";
|
||||
import styles from "./login.module.css";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
type FormStatus = "login" | "register";
|
||||
export default function Page() {
|
||||
const [formStatus, setFormStatus] = useState<FormStatus>("login");
|
||||
const t = useTranslations("pageFooter"); // 用于获取底部信息的翻译
|
||||
|
||||
return (
|
||||
<main className="z-0 bg-ivory relative flex flex-col items-center pt-12 pb-5 px-4 md:px-24 w-full overflow-x-hidden">
|
||||
<video
|
||||
autoPlay
|
||||
muted
|
||||
loop
|
||||
playsInline
|
||||
className="absolute top-0 left-0 w-full h-full object-cover z-[-1]"
|
||||
>
|
||||
<source
|
||||
src="/video/video_2024-09-16 11_18_33.webm"
|
||||
type="video/mp4"
|
||||
/>
|
||||
</video>
|
||||
<article className="relative flex flex-col lg:flex-row gap-[80px] lg:gap-0 justify-evenly pt-[100px] lg:pt-0 mb-[120px] lg:mb-[30px] min-h-[calc(100vh_-_96px)] items-center">
|
||||
<section className="flex flex-col gap-[15px] items-center">
|
||||
<Link className="mb-2" href="/">
|
||||
{/* 在此处添加您的 Logo,如果有的话 */}
|
||||
</Link>
|
||||
<div className="text-info text-xl font-light my-4">
|
||||
{t("welcome")} 👋
|
||||
</div>
|
||||
<section className={styles["login_form"]}>
|
||||
{formStatus === "login" ? (
|
||||
<LoginForm
|
||||
toRegister={() => setFormStatus("register")}
|
||||
/>
|
||||
) : (
|
||||
<RegisterForm back={() => setFormStatus("login")} />
|
||||
)}
|
||||
</section>
|
||||
</section>
|
||||
</article>
|
||||
|
||||
{/* 底部版权信息 */}
|
||||
<footer className="w-full text-center text-gray-300 text-sm mt-4">
|
||||
<div>
|
||||
© {new Date().getFullYear()}{" "}
|
||||
<span>{t("companyName")}</span> |{" "}
|
||||
<span>{t("companyNameEn")}</span>
|
||||
</div>
|
||||
<div>
|
||||
<a href={t("domain1")} target="_blank" rel="noopener noreferrer">
|
||||
{t("domain1")}
|
||||
</a>{" "}
|
||||
|{" "}
|
||||
<a href={t("domain2")} target="_blank" rel="noopener noreferrer">
|
||||
{t("domain2")}
|
||||
</a>
|
||||
</div>
|
||||
<div>{t("icpNumber")}</div>
|
||||
</footer>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
21
src/components/GlobalLoading.tsx
Normal file
21
src/components/GlobalLoading.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
"use client";
|
||||
// components/GlobalLoading.tsx
|
||||
import React from "react";
|
||||
import useLoadingStore from "@/store/loadingStore";
|
||||
|
||||
const GlobalLoading: React.FC = () => {
|
||||
const isLoading = useLoadingStore((state) => state.isLoading);
|
||||
const text = useLoadingStore((state) => state.text);
|
||||
if (!isLoading) {
|
||||
return null; // 如果不在加载状态,则不渲染任何内容
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 flex flex-col items-center justify-center bg-gray-800 bg-opacity-50 z-50 text-secondary">
|
||||
<div className="loading loading-bars loading-lg"></div>
|
||||
<div>{text}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default GlobalLoading;
|
||||
23
src/components/InputBox.tsx
Normal file
23
src/components/InputBox.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
export default function InputBox({
|
||||
children,
|
||||
serial,
|
||||
title,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
serial: number;
|
||||
title: string;
|
||||
}>) {
|
||||
return (
|
||||
<div className="bg-white p-4 rounded-lg w-full flex-[100%]">
|
||||
<div className="flex justify-between">
|
||||
<div className="flex gap-3 text-gray-700 font-medium flex-grow">
|
||||
<kbd className="kbd">{serial}</kbd>
|
||||
<label>{title}</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 font-medium flex-grow mt-2">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
67
src/components/ListBox.tsx
Normal file
67
src/components/ListBox.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import {
|
||||
Listbox,
|
||||
ListboxButton,
|
||||
ListboxOption,
|
||||
ListboxOptions,
|
||||
} from "@headlessui/react";
|
||||
import { Dispatch, SetStateAction, useState } from "react";
|
||||
import { HiChevronUpDown } from "react-icons/hi2";
|
||||
import { BsCheck2 } from "react-icons/bs";
|
||||
|
||||
type TListBoxItem = {
|
||||
id: number;
|
||||
name: string;
|
||||
value: string;
|
||||
};
|
||||
type TListBoxProps = {
|
||||
list: Array<TListBoxItem>;
|
||||
value: TListBoxItem;
|
||||
onChange: Dispatch<SetStateAction<TListBoxItem>>;
|
||||
};
|
||||
export default function ListBox({
|
||||
list,
|
||||
value: selected,
|
||||
onChange: setSelected,
|
||||
}: Readonly<TListBoxProps>) {
|
||||
return (
|
||||
<>
|
||||
<Listbox value={selected} onChange={setSelected}>
|
||||
<ListboxButton className="bg-white relative w-full rounded-xl py-1.5 pr-5 text-left text-neutral shadow-sm border border-base-100 ring-2 ring-inset ring-transparent focus:outline-none focus:ring-2 focus:ring-[#ddd] sm:text-sm sm:leading-6 ">
|
||||
<span className="flex items-center">
|
||||
<span className="ml-3 max-w-[85%] block truncate">
|
||||
{selected.name}
|
||||
</span>
|
||||
</span>
|
||||
<span className="pointer-events-none absolute inset-y-0 right-0 ml-3 flex items-center pr-2">
|
||||
<HiChevronUpDown className="h-5 w-5 text-gray-400" />
|
||||
</span>
|
||||
</ListboxButton>
|
||||
<ListboxOptions
|
||||
transition
|
||||
portal={false}
|
||||
className="data-[closed]:scale-95 data-[closed]:opacity-0 px-1 box-shadow-tf bg-white opacity-100 pointer-events-auto transition-all absolute z-40 mt-1 max-h-56 w-full overflow-auto rounded-xl py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm"
|
||||
as="ul"
|
||||
>
|
||||
{list.map((item) => (
|
||||
<ListboxOption key={item.id} value={item} as="li">
|
||||
<div className="text-neutral rounded-[10px] bg-base bg-opacity-0 hover:bg-opacity-50 transition-all relative select-none py-2 pr-4 cursor-pointer">
|
||||
<div className="flex items-center">
|
||||
<span className="font-normal ml-3 max-w-[85%] block truncate overflow-hidden">
|
||||
{item.name}
|
||||
</span>
|
||||
{selected.id === item.id ? (
|
||||
<span className="text-neutral absolute inset-y-0 right-0 flex items-center pr-1">
|
||||
<BsCheck2 className="h-4 w-4 text-gray-400" />
|
||||
</span>
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ListboxOption>
|
||||
))}
|
||||
</ListboxOptions>
|
||||
</Listbox>
|
||||
</>
|
||||
);
|
||||
}
|
||||
15
src/components/Loading.tsx
Normal file
15
src/components/Loading.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
"use client";
|
||||
import React from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
const Loading: React.FC = () => {
|
||||
const t = useTranslations("loading");
|
||||
return (
|
||||
<div className=" fixed inset-0 flex flex-col items-center justify-center bg-gray-800 bg-opacity-50 z-50 text-secondary">
|
||||
<div className="loading loading-bars loading-lg"></div>
|
||||
<div> {t("preparing")}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Loading;
|
||||
57
src/components/PaymentDialog.tsx
Normal file
57
src/components/PaymentDialog.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
// components/PaymentDialog.tsx
|
||||
import * as Dialog from "@radix-ui/react-dialog";
|
||||
import { IoClose } from "react-icons/io5";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { FaAlipay, FaPaypal } from "react-icons/fa";
|
||||
|
||||
interface PaymentDialogProps {
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
onHandlePay: (platform: "paypal" | "alipay") => void;
|
||||
}
|
||||
|
||||
export default function PaymentDialog({
|
||||
open,
|
||||
setOpen,
|
||||
onHandlePay,
|
||||
}: PaymentDialogProps) {
|
||||
const t = useTranslations("pricing"); // 使用翻译
|
||||
return (
|
||||
<Dialog.Root open={open} onOpenChange={setOpen}>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay className="bg-black bg-opacity-50 fixed inset-0" />
|
||||
<Dialog.Content className="fixed top-1/2 left-1/2 w-full max-w-md p-6 bg-white rounded-xl transform -translate-x-1/2 -translate-y-1/2 shadow-lg">
|
||||
<Dialog.Title className="text-lg font-bold">
|
||||
{/* 选择支付方式 */}
|
||||
{t("ChoosePaymentMethod")}
|
||||
</Dialog.Title>
|
||||
<Dialog.Description className="mt-2 text-sm text-gray-600">
|
||||
{/* 请选择您要使用的支付方式 */}
|
||||
{t("SelectPaymentMethod")}
|
||||
</Dialog.Description>
|
||||
<div className="mt-4 space-y-2">
|
||||
<button
|
||||
onClick={() => onHandlePay("paypal")}
|
||||
className="btn w-full btn-secondary "
|
||||
>
|
||||
<FaPaypal size={24} className="text-white" />
|
||||
PayPal
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onHandlePay("alipay")}
|
||||
className="btn w-full btn-outline border-2 border-blue-500 text-blue-500 hover:bg-blue-500/20 hover:border-blue-500 hover:text-blue-500"
|
||||
>
|
||||
{/* 支付宝支付 */}
|
||||
<FaAlipay size={24} /> Alipay
|
||||
</button>
|
||||
</div>
|
||||
<Dialog.Close asChild>
|
||||
<button className="absolute top-2 right-2 text-gray-500 hover:text-gray-700">
|
||||
<IoClose size={24} />
|
||||
</button>
|
||||
</Dialog.Close>
|
||||
</Dialog.Content>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
);
|
||||
}
|
||||
204
src/components/UploadAudio.tsx
Normal file
204
src/components/UploadAudio.tsx
Normal file
@@ -0,0 +1,204 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { BiUpload } from "react-icons/bi";
|
||||
import { FaPlay, FaPause } from "react-icons/fa";
|
||||
import useFetch from "@/hooks/useFetch";
|
||||
import { useToast } from "@/contexts/ToastContext"; // 假设 ToastContext 在 contexts 文件夹中
|
||||
import { useTranslations } from "next-intl"; // 导入国际化 hook
|
||||
|
||||
interface AudioUploadProps {
|
||||
onChange?: (audioUrl: string) => void;
|
||||
}
|
||||
|
||||
const UploadAudio = ({ onChange }: AudioUploadProps) => {
|
||||
const t = useTranslations("uploadAudio"); // 使用翻译
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [audioUrl, setAudioUrl] = useState<string | null>(null);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [duration, setDuration] = useState(0);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
const { fetchData } = useFetch({
|
||||
url: "/api/upload-audio/",
|
||||
method: "POST",
|
||||
});
|
||||
const { addToast } = useToast();
|
||||
|
||||
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const selectedFile = e.target.files?.[0];
|
||||
if (selectedFile) {
|
||||
setFile(selectedFile);
|
||||
setUploading(true);
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("audio", selectedFile);
|
||||
|
||||
fetchData(formData)
|
||||
.then((res) => {
|
||||
console.log("upload audio:", res);
|
||||
const audioUrl = res.audio_url; // 假设返回数据中有 audio_url
|
||||
setAudioUrl(audioUrl);
|
||||
addToast(t("uploadSuccess"), "success"); // 使用翻译
|
||||
if (onChange) {
|
||||
onChange(audioUrl);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
setUploading(false);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const togglePlayPause = () => {
|
||||
if (audioRef.current) {
|
||||
if (isPlaying) {
|
||||
audioRef.current.pause();
|
||||
} else {
|
||||
audioRef.current.play();
|
||||
}
|
||||
setIsPlaying(!isPlaying);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTimeUpdate = () => {
|
||||
if (audioRef.current) {
|
||||
setCurrentTime(audioRef.current.currentTime);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLoadedMetadata = () => {
|
||||
if (audioRef.current) {
|
||||
setDuration(audioRef.current.duration);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEnded = () => {
|
||||
setIsPlaying(false);
|
||||
setCurrentTime(0); // 重置播放时间
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (audioRef.current) {
|
||||
audioRef.current.addEventListener("timeupdate", handleTimeUpdate);
|
||||
audioRef.current.addEventListener(
|
||||
"loadedmetadata",
|
||||
handleLoadedMetadata
|
||||
);
|
||||
audioRef.current.addEventListener("ended", handleEnded);
|
||||
}
|
||||
return () => {
|
||||
if (audioRef.current) {
|
||||
audioRef.current.removeEventListener(
|
||||
"timeupdate",
|
||||
handleTimeUpdate
|
||||
);
|
||||
audioRef.current.removeEventListener(
|
||||
"loadedmetadata",
|
||||
handleLoadedMetadata
|
||||
);
|
||||
audioRef.current.removeEventListener("ended", handleEnded);
|
||||
}
|
||||
};
|
||||
}, [audioUrl]);
|
||||
|
||||
const formatTime = (time: number) => {
|
||||
const minutes = Math.floor(time / 60);
|
||||
const seconds = Math.floor(time % 60);
|
||||
return `${minutes}:${seconds < 10 ? "0" : ""}${seconds}`;
|
||||
};
|
||||
|
||||
const calculateGradient = () => {
|
||||
const percentage = (currentTime / duration) * 100 || 0;
|
||||
return `linear-gradient(to right, rgba(74, 222, 128, 0.2) ${percentage}%, rgba(74, 222, 128, 0.05) ${percentage}%)`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
role="presentation"
|
||||
tabIndex={0}
|
||||
className="relative px-4 py-6 bg-ivory w-full min-w-[100px] border border-dashed border-info border-opacity-30 rounded-lg flex flex-col items-center justify-center gap-1 cursor-pointer"
|
||||
onClick={() =>
|
||||
document.getElementById("audio-upload-input")?.click()
|
||||
}
|
||||
>
|
||||
<input
|
||||
id="audio-upload-input"
|
||||
accept="audio/*"
|
||||
type="file"
|
||||
tabIndex={-1}
|
||||
style={{ display: "none" }}
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
{uploading ? (
|
||||
<span className="loading loading-dots loading-lg text-secondary">
|
||||
{t("uploading")}
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
<BiUpload className="w-[35px] h-[35px] text-secondary" />
|
||||
<div className="font-normal text-xs text-info text-center">
|
||||
{t("dropOrClick")}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{audioUrl && (
|
||||
<div className="relative z-0 mt-4">
|
||||
<div
|
||||
className="p-2 cursor-pointer hover:outline hover:outline-base-100 outline-offset-2 border border-base-200 rounded-lg flex flex-col gap-3"
|
||||
style={{
|
||||
background: isPlaying
|
||||
? calculateGradient()
|
||||
: "rgb(255, 255, 255)",
|
||||
transition: "background 0.1s linear",
|
||||
}}
|
||||
>
|
||||
<audio
|
||||
ref={audioRef}
|
||||
src={audioUrl}
|
||||
className="audio-preview hidden"
|
||||
preload="metadata"
|
||||
></audio>
|
||||
<div className="flex gap-2 justify-between items-center">
|
||||
<div className="flex flex-[70%] overflow-hidden gap-2 items-center">
|
||||
<div
|
||||
className="ml-2 mr-1"
|
||||
onClick={togglePlayPause}
|
||||
>
|
||||
{isPlaying ? (
|
||||
<FaPause
|
||||
className="cursor-pointer flex-shrink-0 text-info"
|
||||
size={18}
|
||||
/>
|
||||
) : (
|
||||
<FaPlay
|
||||
className="cursor-pointer flex-shrink-0 text-info"
|
||||
size={18}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="overflow-hidden">
|
||||
<div className="flex whitespace-nowrap text-ellipsis truncate overflow-hidden text-xs font-bold">
|
||||
{file?.name}
|
||||
</div>
|
||||
<div className="overflow-hidden text-info text-[10px] truncate">
|
||||
{t("audioFile")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-1 flex-[30%]">
|
||||
<small>{formatTime(currentTime)}</small>
|
||||
<small> / </small>
|
||||
<small>{formatTime(duration)}</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UploadAudio;
|
||||
76
src/components/UploadImage.tsx
Normal file
76
src/components/UploadImage.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import { useToast } from "@/contexts/ToastContext";
|
||||
import useFetch from "@/hooks/useFetch";
|
||||
import { useState } from "react";
|
||||
import { TfiUser } from "react-icons/tfi";
|
||||
import { useTranslations } from "next-intl";
|
||||
import Image from "next/image";
|
||||
|
||||
interface ImageUploadProps {
|
||||
onChange?: (imageUrl: string) => void;
|
||||
}
|
||||
|
||||
const ImageUpload = ({ onChange }: ImageUploadProps) => {
|
||||
const t = useTranslations("imageUpload"); // 使用翻译
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const { addToast } = useToast();
|
||||
|
||||
const { fetchData } = useFetch({
|
||||
url: "/api/upload-avatar/",
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
const handleImageChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
setUploading(true);
|
||||
const formData = new FormData();
|
||||
formData.append("avatar", file);
|
||||
fetchData(formData)
|
||||
.then((res) => {
|
||||
const imageUrl = URL.createObjectURL(file);
|
||||
setPreviewUrl(imageUrl);
|
||||
addToast(t("uploadSuccess"), "success"); // 使用翻译
|
||||
if (onChange) {
|
||||
onChange(res.avatar_url);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
setUploading(false);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex justify-center items-center min-h-64 w-64 m-4 border-2 border-dashed border-gray-400 rounded-lg cursor-pointer hover:border-secondary relative">
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleImageChange}
|
||||
className="absolute opacity-0 w-full h-full cursor-pointer"
|
||||
/>
|
||||
<div className="flex flex-col items-center justify-center text-gray-500">
|
||||
{uploading ? (
|
||||
<span className="loading loading-dots loading-lg">
|
||||
{t("loading")}
|
||||
</span>
|
||||
) : previewUrl ? (
|
||||
<Image
|
||||
src={previewUrl}
|
||||
alt="Preview"
|
||||
width={100}
|
||||
height={100}
|
||||
className="object-contain w-full h-full rounded-lg"
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<TfiUser size={48} />
|
||||
<p>{t("uploadImage")}</p> {/* 使用翻译 */}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ImageUpload;
|
||||
24
src/components/logics/UserInfo.tsx
Normal file
24
src/components/logics/UserInfo.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import useFetch from "@/hooks/useFetch";
|
||||
import useUserStore from "@/store/userStore";
|
||||
import { useEffect } from "react";
|
||||
|
||||
export default function UserInfo() {
|
||||
const setUser = useUserStore((state) => state.setUser);
|
||||
const {
|
||||
fetchData: GetUserinfo,
|
||||
loading: userinfoLoading,
|
||||
data: userinfoData,
|
||||
} = useFetch({
|
||||
url: "/api/profile/",
|
||||
method: "POST",
|
||||
});
|
||||
useEffect(() => {
|
||||
GetUserinfo().then((res) => {
|
||||
console.log("userinfo", res);
|
||||
setUser(res);
|
||||
});
|
||||
}, []);
|
||||
return null;
|
||||
}
|
||||
9
src/contexts/CombinedProviders.tsx
Normal file
9
src/contexts/CombinedProviders.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import { ToastProvider } from "@/contexts/ToastContext";
|
||||
import { UserProvider } from "@/contexts/UserContext";
|
||||
export default function CombinedProviders({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return <ToastProvider>{children}</ToastProvider>;
|
||||
}
|
||||
103
src/contexts/ToastContext/index.tsx
Normal file
103
src/contexts/ToastContext/index.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
// components/ToastProvider.tsx
|
||||
"use client";
|
||||
import React, { createContext, useContext, useState, ReactNode } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { MdError } from "react-icons/md";
|
||||
import { MdCheckCircle } from "react-icons/md";
|
||||
|
||||
interface ToastContextType {
|
||||
addToast: (message: string, type?: ToastType) => void;
|
||||
addToastError: (message: string) => void;
|
||||
addToastSuccess: (message: string) => void;
|
||||
}
|
||||
type ToastType = "default" | "error" | "success";
|
||||
interface Toast {
|
||||
id: number;
|
||||
message: string;
|
||||
type: ToastType;
|
||||
}
|
||||
|
||||
const ToastContext = createContext<ToastContextType | undefined>(undefined);
|
||||
|
||||
export const ToastProvider: React.FC<{ children: ReactNode }> = ({
|
||||
children,
|
||||
}) => {
|
||||
const [toasts, setToasts] = useState<Toast[]>([]);
|
||||
const [nextId, setNextId] = useState(0);
|
||||
|
||||
const addToast = (message: string, type: ToastType = "default") => {
|
||||
setToasts((prevToasts) => [
|
||||
...prevToasts,
|
||||
{ id: nextId, message, type },
|
||||
]);
|
||||
setNextId(nextId + 1);
|
||||
};
|
||||
const addToastError = (message: string) => {
|
||||
addToast(message, "error");
|
||||
};
|
||||
|
||||
const addToastSuccess = (message: string) => {
|
||||
addToast(message, "success");
|
||||
};
|
||||
const removeToast = (id: number) => {
|
||||
setToasts((prevToasts) =>
|
||||
prevToasts.filter((toast) => toast.id !== id)
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<ToastContext.Provider
|
||||
value={{ addToast, addToastError, addToastSuccess }}
|
||||
>
|
||||
{children}
|
||||
<div
|
||||
className=" z-[9999] left-0 right-0 top-0 flex justify-center pointer-events-none"
|
||||
style={{ position: "fixed", inset: "16px" }}
|
||||
>
|
||||
<AnimatePresence>
|
||||
{toasts.map((toast, index) => (
|
||||
<motion.div
|
||||
key={toast.id}
|
||||
initial={{ opacity: 0, translateY: 20, scale: 0.5 }}
|
||||
animate={{
|
||||
opacity: 1,
|
||||
translateY: 60 * index + 20,
|
||||
scale: 1,
|
||||
}}
|
||||
exit={{ opacity: 0, translateY: 20, scale: 0.5 }}
|
||||
transition={{ duration: 0.2, ease: "easeOut" }}
|
||||
className="absolute rounded-lg shadow-lg bg-white py-2 px-6"
|
||||
onAnimationComplete={() =>
|
||||
setTimeout(() => removeToast(toast.id), 3000)
|
||||
}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
{toast.type === "error" && (
|
||||
<MdError
|
||||
size={20}
|
||||
className="text-red-500 mr-2"
|
||||
/>
|
||||
)}
|
||||
{toast.type === "success" && (
|
||||
<MdCheckCircle
|
||||
size={20}
|
||||
className="text-green-500 mr-2"
|
||||
/>
|
||||
)}
|
||||
{toast.message}
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</ToastContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useToast = () => {
|
||||
const context = useContext(ToastContext);
|
||||
if (context === undefined) {
|
||||
throw new Error("useToast must be used within a ToastProvider");
|
||||
}
|
||||
return context;
|
||||
};
|
||||
55
src/contexts/UserContext.tsx
Normal file
55
src/contexts/UserContext.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
"use client";
|
||||
import useFetch from "@/hooks/useFetch";
|
||||
// context/UserContext.tsx
|
||||
import React, { createContext, useContext, useState, ReactNode } from "react";
|
||||
|
||||
interface User {
|
||||
username: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
isMember: boolean;
|
||||
points: number;
|
||||
referralCode: string;
|
||||
commissionRate: number;
|
||||
}
|
||||
|
||||
// 定义上下文的类型
|
||||
interface UserContextType {
|
||||
user: User | null;
|
||||
setUser: (user: User | null) => void;
|
||||
}
|
||||
|
||||
// 创建上下文,初始值为 null
|
||||
const UserContext = createContext<UserContextType | null>(null);
|
||||
|
||||
// 创建 Provider 组件
|
||||
export const UserProvider: React.FC<{ children: ReactNode }> = ({
|
||||
children,
|
||||
}) => {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const {
|
||||
fetchData: GetUserinfo,
|
||||
loading: userinfoLoading,
|
||||
data: userinfoData,
|
||||
} = useFetch({
|
||||
url: "/api/profile/",
|
||||
method: "GET",
|
||||
});
|
||||
// GetUserinfo().then((res) => {
|
||||
// console.log("userinfo", res);
|
||||
// });
|
||||
return (
|
||||
<UserContext.Provider value={{ user, setUser }}>
|
||||
{children}
|
||||
</UserContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
// 自定义钩子,方便在组件中使用上下文
|
||||
export const useUser = (): UserContextType => {
|
||||
const context = useContext(UserContext);
|
||||
if (!context) {
|
||||
throw new Error("useUser must be used within a UserProvider");
|
||||
}
|
||||
return context;
|
||||
};
|
||||
173
src/hooks/useFetch.tsx
Normal file
173
src/hooks/useFetch.tsx
Normal file
@@ -0,0 +1,173 @@
|
||||
"use client";
|
||||
import { useState } from "react";
|
||||
import queryString from "query-string";
|
||||
import { useToast } from "@/contexts/ToastContext";
|
||||
|
||||
type Method = "GET" | "POST" | "PUT" | "DELETE" | "PATCH";
|
||||
|
||||
type Params = Record<string, any>;
|
||||
|
||||
interface UseFetch {
|
||||
url: string;
|
||||
method: Method;
|
||||
cacheTime?: number;
|
||||
}
|
||||
|
||||
interface BaseResponse<T> {
|
||||
data: T;
|
||||
code: number;
|
||||
message: string;
|
||||
}
|
||||
type Config =
|
||||
| { next: { revalidate: number } }
|
||||
| { cache: "no-store" }
|
||||
| { cache: "force-cache" };
|
||||
|
||||
const useFetch = <T,>({ url, method, cacheTime = 0 }: UseFetch) => {
|
||||
const [data, setData] = useState<any>(null);
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const { addToast } = useToast();
|
||||
const fetchData = async (params?: Params): Promise<any> => {
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
|
||||
return new Promise(async (resolve, reject) => {
|
||||
try {
|
||||
const { url: requestUrl, options } = interceptorsRequest({
|
||||
url,
|
||||
method,
|
||||
params,
|
||||
cacheTime,
|
||||
});
|
||||
const response = await fetch(requestUrl, options);
|
||||
|
||||
const result = await interceptorsResponse<BaseResponse<T>>(
|
||||
response
|
||||
);
|
||||
console.log("response", response);
|
||||
console.log("result", result);
|
||||
setData(result.data);
|
||||
resolve(result.data); // 成功时 resolve 结果
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
addToast(err.message, "error");
|
||||
reject(err.message); // 失败时 reject 错误信息
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return { fetchData, data, loading, error };
|
||||
};
|
||||
|
||||
// 请求拦截器
|
||||
const interceptorsRequest = ({
|
||||
url,
|
||||
method,
|
||||
params,
|
||||
cacheTime,
|
||||
}: UseFetch & { params?: Params }) => {
|
||||
let queryParams = ""; // url 参数
|
||||
let requestPayload: any = ""; // 请求体数据
|
||||
|
||||
const headers = {
|
||||
// authorization: `Bearer ...`,
|
||||
};
|
||||
|
||||
const config: Config =
|
||||
cacheTime || cacheTime === 0
|
||||
? cacheTime > 0
|
||||
? { next: { revalidate: cacheTime } }
|
||||
: { cache: "no-store" }
|
||||
: { cache: "force-cache" };
|
||||
|
||||
if (method === "GET" || method === "DELETE") {
|
||||
// GET/DELETE 请求不能将参数放在 body 中,只能拼接到 URL 上
|
||||
if (params) {
|
||||
queryParams = queryString.stringify(params);
|
||||
url = `${url}?${queryParams}`;
|
||||
}
|
||||
} else {
|
||||
// 非 form-data 传输 JSON 数据格式
|
||||
if (Object.prototype.toString.call(params) === "[object FormData]") {
|
||||
requestPayload = params; // 如果是 FormData 类型,不做处理
|
||||
} else if (
|
||||
!["[object FormData]", "[object URLSearchParams]"].includes(
|
||||
Object.prototype.toString.call(params)
|
||||
)
|
||||
) {
|
||||
Object.assign(headers, { "Content-Type": "application/json" });
|
||||
requestPayload = JSON.stringify(params);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
url,
|
||||
options: {
|
||||
method,
|
||||
headers,
|
||||
body:
|
||||
method !== "GET" && method !== "DELETE"
|
||||
? requestPayload
|
||||
: undefined,
|
||||
...config,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
// 响应拦截器
|
||||
const interceptorsResponse = <T,>(res: Response): Promise<T> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const requestUrl = res.url;
|
||||
if (res.ok) {
|
||||
res.clone()
|
||||
.text()
|
||||
.then((text) => {
|
||||
try {
|
||||
const data = JSON.parse(text);
|
||||
console.log("data", data);
|
||||
if (!data.code) {
|
||||
return resolve(res.json() as Promise<T>);
|
||||
}
|
||||
if (data.code === 200) {
|
||||
return resolve(res.json() as Promise<T>);
|
||||
} else if (data.code === 401) {
|
||||
console.log("data.code", data.code);
|
||||
window.location.href = "/login";
|
||||
return reject({
|
||||
message: data.message,
|
||||
url: requestUrl,
|
||||
});
|
||||
} else {
|
||||
return reject({
|
||||
message: data.message,
|
||||
url: requestUrl,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
console.log("catch");
|
||||
return reject({ message: text, url: requestUrl });
|
||||
}
|
||||
});
|
||||
} else {
|
||||
res.clone()
|
||||
.text()
|
||||
.then((text) => {
|
||||
try {
|
||||
const errorData = JSON.parse(text);
|
||||
return reject({
|
||||
message: errorData || "接口错误",
|
||||
url: requestUrl,
|
||||
});
|
||||
} catch {
|
||||
return reject({ message: text, url: requestUrl });
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export default useFetch;
|
||||
27
src/i18n/request.ts
Normal file
27
src/i18n/request.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { getRequestConfig } from 'next-intl/server';
|
||||
import { headers } from "next/headers";
|
||||
export default getRequestConfig(async () => {
|
||||
const headersList = headers();
|
||||
const acceptLanguage = headersList.get("accept-language");
|
||||
|
||||
// 提取首选语言
|
||||
let locale = 'en'; // 默认语言
|
||||
if (acceptLanguage) {
|
||||
// 根据首选语言选择合适的 locale
|
||||
const preferredLanguages = acceptLanguage.split(',').map(lang => lang.split(';')[0].trim());
|
||||
const supportedLocales = ['en', 'zh']; // 支持的语言列表
|
||||
|
||||
// 检查首选语言是否在支持的语言列表中
|
||||
for (const lang of preferredLanguages) {
|
||||
if (supportedLocales.includes(lang)) {
|
||||
locale = lang;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
locale,
|
||||
messages: (await import(`../../public/locales/${locale}.json`)).default
|
||||
};
|
||||
});
|
||||
54
src/middleware.ts
Normal file
54
src/middleware.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import type { NextRequest } from 'next/server';
|
||||
import routes from '@/models/routes';
|
||||
import { match } from '@formatjs/intl-localematcher';
|
||||
import Negotiator from 'negotiator';
|
||||
|
||||
// 支持的语言
|
||||
const locales = ['en-US', 'zh'];
|
||||
const defaultLocale = 'en-US';
|
||||
|
||||
export function middleware(request: NextRequest) {
|
||||
const { pathname } = request.nextUrl; // 获取请求的路径
|
||||
|
||||
// 获取请求中的 cookie
|
||||
const sessionid = request.cookies.get('sessionid'); // 假设你的 session id 是 'sessionid'
|
||||
const url = new URL(request.url);
|
||||
|
||||
// // 获取用户的语言设置
|
||||
// const negotiatorHeaders = { 'accept-language': request.headers.get('accept-language') || '' };
|
||||
// const languages = new Negotiator({ headers: negotiatorHeaders }).languages();
|
||||
// const selectedLocale = match(languages, locales, defaultLocale);
|
||||
// console.log('languages', languages, selectedLocale)
|
||||
// 如果用户已登录,且访问的是登录页面,则重定向到创建页面
|
||||
if (sessionid && pathname === '/login') {
|
||||
return NextResponse.redirect(new URL('/create', request.url));
|
||||
}
|
||||
|
||||
// 查找当前路径是否需要认证
|
||||
const route = routes.find(route => pathname === route.path);
|
||||
|
||||
// 如果路由需要认证,且没有 sessionid,则重定向到登录页面
|
||||
if (route && route.requiresAuth && !sessionid) {
|
||||
return NextResponse.redirect(new URL('/login', request.url));
|
||||
}
|
||||
const openid = url.searchParams.get('openid');
|
||||
|
||||
// 如果 URL 中存在 openid 参数
|
||||
if (openid) {
|
||||
// 创建一个新的响应对象,并设置 cookie
|
||||
const response = NextResponse.next();
|
||||
response.cookies.set('openid', openid, {
|
||||
path: '/',
|
||||
httpOnly: true,
|
||||
maxAge: 60 * 60 * 24 // 设置cookie有效期为1天
|
||||
});
|
||||
return response;
|
||||
}
|
||||
// 在返回响应时,将用户的语言环境设置到响应头中
|
||||
const response = NextResponse.next();
|
||||
// response.headers.set('Content-Language', selectedLocale);
|
||||
|
||||
// 继续处理请求
|
||||
return response;
|
||||
}
|
||||
17
src/models/routes.ts
Normal file
17
src/models/routes.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
interface Route {
|
||||
name: string;
|
||||
path: string;
|
||||
requiresAuth: boolean;
|
||||
}
|
||||
|
||||
const routes: Route[] = [
|
||||
{ name: "Index", path: "/", requiresAuth: false },
|
||||
{ name: "Create", path: "/create", requiresAuth: true },
|
||||
{ name: "Home", path: "/home", requiresAuth: true },
|
||||
{ name: "Projects", path: "/projects", requiresAuth: false },
|
||||
{ name: "Queue", path: "/queue", requiresAuth: false },
|
||||
{ name: "Login", path: "/login", requiresAuth: false },
|
||||
{ name: "terms", path: "/terms", requiresAuth: false },
|
||||
];
|
||||
|
||||
export default routes;
|
||||
18
src/store/loadingStore.ts
Normal file
18
src/store/loadingStore.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
// store/loadingStore.ts
|
||||
import { create } from 'zustand';
|
||||
|
||||
interface LoadingState {
|
||||
isLoading: boolean;
|
||||
text: string;
|
||||
showLoading: (text?: string) => void;
|
||||
hideLoading: () => void;
|
||||
}
|
||||
|
||||
const useLoadingStore = create<LoadingState>((set) => ({
|
||||
isLoading: false,
|
||||
text: "",
|
||||
showLoading: (text: string = "loading...") => set({ isLoading: true, text: text }),
|
||||
hideLoading: () => set({ isLoading: false }),
|
||||
}));
|
||||
|
||||
export default useLoadingStore;
|
||||
24
src/store/userStore.ts
Normal file
24
src/store/userStore.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
// store/userStore.ts
|
||||
import { create } from 'zustand';
|
||||
interface User {
|
||||
username: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
isMember: boolean;
|
||||
points: number;
|
||||
referralCode: string;
|
||||
commissionRate: number;
|
||||
}
|
||||
interface UserState {
|
||||
user: User | null;
|
||||
setUser: (userInfo: User) => void;
|
||||
clearUser: () => void;
|
||||
}
|
||||
|
||||
const useUserStore = create<UserState>((set) => ({
|
||||
user: null,
|
||||
setUser: (userInfo) => set({ user: userInfo }),
|
||||
clearUser: () => set({ user: null }),
|
||||
}));
|
||||
|
||||
export default useUserStore;
|
||||
3
src/ui/(console)/create/article.tsx
Normal file
3
src/ui/(console)/create/article.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
export default function Article() {
|
||||
return <>文章转视频</>;
|
||||
}
|
||||
38
src/ui/(console)/create/avatar/index.module.css
Normal file
38
src/ui/(console)/create/avatar/index.module.css
Normal file
@@ -0,0 +1,38 @@
|
||||
.stroke-text {
|
||||
font-weight: bold;
|
||||
text-transform: uppercase;
|
||||
color: white;
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.stroke-text:after, .stroke-text:before {
|
||||
content: attr(data-text);
|
||||
position: absolute;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
-webkit-text-stroke: .2em #000;
|
||||
z-index: -1; /* 确保它们位于主文本前 */
|
||||
}
|
||||
.stroke-text:after{
|
||||
left: .05em;
|
||||
top: .05em;
|
||||
}
|
||||
.stroke-text:before{
|
||||
left: 0;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.glow-text-effect{
|
||||
color: var(--text-color, #4ade80);
|
||||
text-shadow: 0 0 .3em rgba(0, 0, 0, .7), 0 0 .05em var(--text-color, #4ade80), 0 0 .1em var(--text-color, #4ade80), 0 0 .15em var(--text-color, #4ade80), 0 0 .2em var(--text-color, #4ade80), 0 0 .25em var(--text-color, #4ade80);
|
||||
}
|
||||
.bevel-text-effect {
|
||||
display: inline-flex;
|
||||
position: relative;
|
||||
color: var(--text-color, #4ade80);
|
||||
-webkit-text-stroke: .025em var(--text-color, #4ade80);
|
||||
-webkit-text-fill-color: transparent;
|
||||
text-shadow: 0 .025em .0375em var(--text-color, #4ade80), 0 0 0 #fff;
|
||||
}
|
||||
653
src/ui/(console)/create/avatar/index.tsx
Normal file
653
src/ui/(console)/create/avatar/index.tsx
Normal file
@@ -0,0 +1,653 @@
|
||||
import InputBox from "@/components/InputBox";
|
||||
import ListBox from "@/components/ListBox";
|
||||
import { useToast } from "@/contexts/ToastContext";
|
||||
import useFetch from "@/hooks/useFetch";
|
||||
import classNames from "classnames";
|
||||
import { use, useEffect, useState } from "react";
|
||||
import { MdCheckCircle } from "react-icons/md";
|
||||
import VideoSelect, { VideoData } from "../components/video";
|
||||
import useUserStore from "@/store/userStore";
|
||||
import useLoadingStore from "@/store/loadingStore";
|
||||
import { useRouter } from "next/navigation";
|
||||
import UploadImage from "@/components/UploadImage";
|
||||
import styles from "./index.module.css";
|
||||
import { Switch } from "@headlessui/react";
|
||||
import {
|
||||
PiAlignTopSimple,
|
||||
PiAlignCenterVerticalSimple,
|
||||
PiAlignBottomSimple,
|
||||
} from "react-icons/pi";
|
||||
import { useTranslations } from "next-intl";
|
||||
export default function Avatar() {
|
||||
const router = useRouter();
|
||||
const t = useTranslations("tiktok");
|
||||
const { addToast, addToastSuccess, addToastError } = useToast();
|
||||
const [rate, setRate] = useState(0.1);
|
||||
const [enabledCaptions, setEnabledCaptions] = useState(false);
|
||||
const ratioList = [
|
||||
// {
|
||||
// name: "16 / 9",
|
||||
// id: 1,
|
||||
// value: "16 / 9",
|
||||
// },
|
||||
// {
|
||||
// name: "1 / 1",
|
||||
// id: 2,
|
||||
// value: "1 / 1",
|
||||
// },
|
||||
{
|
||||
name: "9 / 16",
|
||||
id: 1,
|
||||
value: "9 / 16",
|
||||
},
|
||||
];
|
||||
const showLoading = useLoadingStore((state) => state.showLoading);
|
||||
const hideLoading = useLoadingStore((state) => state.hideLoading);
|
||||
|
||||
const {
|
||||
fetchData: createFetch,
|
||||
loading: createLoading,
|
||||
data: createResult,
|
||||
} = useFetch({
|
||||
url: "/api/create-avatar-video/",
|
||||
method: "POST",
|
||||
});
|
||||
const create = () => {
|
||||
const requestData = {
|
||||
text: text,
|
||||
voice: videoData.videoSelect,
|
||||
style: videoData.style,
|
||||
ratio: ratio.value,
|
||||
rate: rate,
|
||||
mediaType: mediaType.value,
|
||||
selectedAvatar: imageUrl,
|
||||
slug: "create-avatar-video",
|
||||
};
|
||||
console.log("requestData", requestData);
|
||||
if (text === "") {
|
||||
addToastError(t("enterVideoText"));
|
||||
return;
|
||||
}
|
||||
if (imageUrl === "") {
|
||||
addToastError(t("uploadAvatarImage"));
|
||||
return;
|
||||
}
|
||||
if (enabledCaptions) {
|
||||
requestData["captionPreset"] = captionPreset;
|
||||
requestData["captionPosition"] = captionPosition;
|
||||
}
|
||||
showLoading("creating...");
|
||||
createFetch(requestData)
|
||||
.then((res) => {
|
||||
console.log("create result:", res);
|
||||
if (res.success === 1) {
|
||||
addToastSuccess(t("createSuccess"));
|
||||
router.push("/projects");
|
||||
} else {
|
||||
addToastError(t("createFailed"));
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
hideLoading();
|
||||
});
|
||||
};
|
||||
|
||||
const [ratio, setRatio] = useState(ratioList[0]);
|
||||
|
||||
const [captionPreset, setCaptionPreset] = useState("Wrap 1");
|
||||
|
||||
const [captionPosition, setCaptionPosition] = useState("bottom");
|
||||
const mediaTypeList = [
|
||||
{ id: 1, name: t("stockVideo"), value: "stockVideo" },
|
||||
{ id: 2, name: t("movingImage"), value: "movingImage" },
|
||||
];
|
||||
|
||||
const [mediaType, setMediaType] = useState(mediaTypeList[1]);
|
||||
|
||||
const [videoData, setVideoData] = useState<VideoData>({
|
||||
language: "", // 国家语种
|
||||
videoSelect: "", // 讲话人
|
||||
style: "", // 讲话风格
|
||||
});
|
||||
const handleVideoDataChange = (newData: VideoData) => {
|
||||
setVideoData(newData);
|
||||
};
|
||||
|
||||
const [imageUrl, setImageUrl] = useState("");
|
||||
const handleImageDataChange = (imageUrl: string) => {
|
||||
setImageUrl(imageUrl);
|
||||
};
|
||||
const [text, setText] = useState("");
|
||||
|
||||
return (
|
||||
<>
|
||||
<p className="text-info text-sm">{t("useTool")}</p>
|
||||
<InputBox serial={1} title={t("yourVideoText")}>
|
||||
<textarea
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
className="min-h-36 textarea text-neutral placeholder:text-gray-300 font-normal outline-base-200 focus:outline-offset-[1px] outline outline-[1.5px] focus:outline-[1.5px] focus:base-200 rounded-lg bg-transparent overflow-hidden text-base resize-none p-4"
|
||||
placeholder={t("create-avatar-video")}
|
||||
></textarea>
|
||||
<small className="whitespace-pre-line text-info">
|
||||
{t("videoTextTip")}
|
||||
</small>
|
||||
</InputBox>
|
||||
<InputBox serial={2} title={t("avatarImage")}>
|
||||
<UploadImage onChange={handleImageDataChange} />
|
||||
{/* <small className="whitespace-pre-line text-info">
|
||||
💡 Tip: use short, punctuated sentences.
|
||||
</small> */}
|
||||
</InputBox>
|
||||
<InputBox serial={3} title={t("selectVoice")}>
|
||||
{/* <small className="text-info text-xs">
|
||||
{t("voicePunctuationTip")}
|
||||
</small> */}
|
||||
<VideoSelect onChange={handleVideoDataChange} />
|
||||
</InputBox>
|
||||
<InputBox serial={4} title={t("pickParameters")}>
|
||||
<div className="flex gap-2 flex-col md:flex-row md:items-center">
|
||||
<div className="min-w-[200px]">
|
||||
<div className="relative group">
|
||||
<ListBox
|
||||
list={mediaTypeList}
|
||||
value={mediaType}
|
||||
onChange={setMediaType}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col ml-2">
|
||||
<div className="text-gray-700 text-md">
|
||||
{t("selectFootageType")}
|
||||
</div>
|
||||
<div className="text-info text-xs">
|
||||
{t("dynamicOrVideo")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 flex-col md:flex-row md:items-center">
|
||||
<div className="min-w-[200px]">
|
||||
<div className="relative group">
|
||||
<ListBox
|
||||
list={ratioList}
|
||||
value={ratio}
|
||||
onChange={setRatio}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col ml-2">
|
||||
<div className="text-gray-700 text-md">
|
||||
{t("chooseScreenRatio")}
|
||||
</div>
|
||||
<div className="text-info text-xs">
|
||||
{t("adaptScreenRatio")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 flex-col md:flex-row md:items-center">
|
||||
<div className="w-full max-w-[400px]">
|
||||
<div className="w-full">
|
||||
<input
|
||||
type="range"
|
||||
min={0.1}
|
||||
max={1}
|
||||
value={rate}
|
||||
className="range range-secondary range-sm"
|
||||
step={0.1}
|
||||
onChange={(e) =>
|
||||
setRate(Number(e.target.value))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex w-full justify-between px-2 text-xs">
|
||||
<span>|</span>
|
||||
<span>|</span>
|
||||
<span>|</span>
|
||||
<span>|</span>
|
||||
<span>|</span>
|
||||
<span>|</span>
|
||||
<span>|</span>
|
||||
<span>|</span>
|
||||
<span>|</span>
|
||||
<span>|</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col ml-2">
|
||||
<div className="text-gray-700 text-md">
|
||||
{t("chooseScreenRatio")}
|
||||
</div>
|
||||
<div className="text-info text-xs">
|
||||
{t("adaptScreenRatio")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</InputBox>
|
||||
<InputBox serial={5} title={t("chooseGenerationPreset")}>
|
||||
<div className="flex flex-col w-full h-full relative">
|
||||
<div className="overflow-auto px-12 pb-6 pt-6 h-full no-scrollbar">
|
||||
<div className="flex flex-col w-full">
|
||||
<div className="flex items-center mb-4">
|
||||
<Switch
|
||||
checked={enabledCaptions}
|
||||
onChange={setEnabledCaptions}
|
||||
className="mr-2 group inline-flex h-6 w-11 items-center rounded-full bg-base-200 transition data-[checked]:bg-secondary"
|
||||
>
|
||||
<span className="size-4 translate-x-1 rounded-full bg-white transition group-data-[checked]:translate-x-6" />
|
||||
</Switch>
|
||||
<div className="text-gray-500">
|
||||
是否开启字幕
|
||||
</div>
|
||||
</div>
|
||||
{enabledCaptions && (
|
||||
<>
|
||||
<div className="w-full mb-3 flex items-center justify-between">
|
||||
<div className="text-sm font-base text-gray-500 inline-flex gap-2 items-center">
|
||||
{t("selectPreset")}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-2 w-full">
|
||||
<div
|
||||
className={classNames(
|
||||
"rounded-md px-2 py-4 flex items-center justify-center w-full relative gap-1 cursor-pointer outline-offset-2 outline-2 border transition-all bg-white hover:bg-base-100/50 border-base-100",
|
||||
{
|
||||
"border-secondary":
|
||||
captionPreset ===
|
||||
"Basic",
|
||||
"hover:border-base-200":
|
||||
captionPreset !==
|
||||
"Basic",
|
||||
}
|
||||
)}
|
||||
onClick={() =>
|
||||
setCaptionPreset("Basic")
|
||||
}
|
||||
>
|
||||
<span
|
||||
className="heavy-shadow-text-effect"
|
||||
style={
|
||||
{
|
||||
position: "relative",
|
||||
"--text-content":
|
||||
"Basic",
|
||||
"--text-color":
|
||||
"#FFFFFF",
|
||||
color: "rgb(255, 255, 255)",
|
||||
fontFamily:
|
||||
"Montserrat, sans-serif",
|
||||
fontWeight: "bold",
|
||||
textDecoration: "none",
|
||||
fontStyle: "normal",
|
||||
textTransform:
|
||||
"uppercase",
|
||||
textShadow: `
|
||||
.1em .1em .1em #000,
|
||||
.1em -.1em .1em #000,
|
||||
-.1em .1em .1em #000,
|
||||
-.1em -.1em .1em #000,
|
||||
.1em .1em .2em #000,
|
||||
.1em -.1em .2em #000,
|
||||
-.1em .1em .2em #000,
|
||||
-.1em -.1em .2em #000,
|
||||
0 0 .1em #000,
|
||||
0 0 .2em #000,
|
||||
0 0 .3em #000,
|
||||
0 0 .4em #000,
|
||||
0 0 .5em #000,
|
||||
0 0 .6em #000
|
||||
`,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
Basic
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={classNames(
|
||||
"rounded-md px-2 py-4 flex items-center justify-center w-full relative gap-1 cursor-pointer outline-offset-2 outline-2 border transition-all bg-white hover:bg-base-100/50 border-base-100",
|
||||
{
|
||||
"border-secondary":
|
||||
captionPreset ===
|
||||
"REVID",
|
||||
"hover:border-base-200":
|
||||
captionPreset !==
|
||||
"REVID",
|
||||
}
|
||||
)}
|
||||
onClick={() =>
|
||||
setCaptionPreset("REVID")
|
||||
}
|
||||
>
|
||||
<span
|
||||
className={
|
||||
styles["stroke-text"]
|
||||
}
|
||||
style={{
|
||||
fontFamily:
|
||||
"Poppins, sans-serif",
|
||||
}}
|
||||
data-text="REVID"
|
||||
>
|
||||
REVID
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={classNames(
|
||||
"rounded-md px-2 py-4 flex items-center justify-center w-full relative gap-1 cursor-pointer outline-offset-2 outline-2 border transition-all bg-white hover:bg-base-100/50 border-base-100",
|
||||
{
|
||||
"border-secondary":
|
||||
captionPreset ===
|
||||
"Hormozi",
|
||||
"hover:border-base-200":
|
||||
captionPreset !==
|
||||
"Hormozi",
|
||||
}
|
||||
)}
|
||||
onClick={() =>
|
||||
setCaptionPreset("Hormozi")
|
||||
}
|
||||
>
|
||||
<span
|
||||
className={
|
||||
styles["glow-text-effect"]
|
||||
}
|
||||
style={
|
||||
{
|
||||
position: "relative",
|
||||
"--text-content":
|
||||
"Hormozi",
|
||||
"--text-effect-color":
|
||||
"#FFED38",
|
||||
"--text-color":
|
||||
"#FFED38",
|
||||
color: "rgb(255, 237, 56)",
|
||||
fontFamily:
|
||||
"Anton, sans-serif",
|
||||
fontWeight: "bold",
|
||||
textDecoration: "none",
|
||||
fontStyle: "normal",
|
||||
textTransform:
|
||||
"uppercase",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
Hormozi
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={classNames(
|
||||
"rounded-md px-2 py-4 flex items-center justify-center w-full relative gap-1 cursor-pointer outline-offset-2 outline-2 border transition-all bg-white hover:bg-base-100/50 border-base-100",
|
||||
{
|
||||
"border-secondary":
|
||||
captionPreset === "Ali",
|
||||
"hover:border-base-200":
|
||||
captionPreset !== "Ali",
|
||||
}
|
||||
)}
|
||||
onClick={() =>
|
||||
setCaptionPreset("Ali")
|
||||
}
|
||||
>
|
||||
<span
|
||||
className="wrap-text-effect"
|
||||
style={
|
||||
{
|
||||
position: "relative",
|
||||
"--text-content": "Ali",
|
||||
"--text-effect-color":
|
||||
"#FFFFFF",
|
||||
"--text-color":
|
||||
"#1C1E1D",
|
||||
color: "rgb(28, 30, 29)",
|
||||
fontFamily:
|
||||
"Poppins, sans-serif",
|
||||
fontWeight: "bold",
|
||||
textDecoration: "none",
|
||||
fontStyle: "normal",
|
||||
textTransform: "none",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
Ali
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={classNames(
|
||||
"rounded-md px-2 py-4 flex items-center justify-center w-full relative gap-1 cursor-pointer outline-offset-2 outline-2 border transition-all bg-white hover:bg-base-100/50 border-base-100",
|
||||
{
|
||||
"border-secondary":
|
||||
captionPreset ===
|
||||
"Wrap 1",
|
||||
"hover:border-base-200":
|
||||
captionPreset !==
|
||||
"Wrap 1",
|
||||
}
|
||||
)}
|
||||
onClick={() =>
|
||||
setCaptionPreset("Wrap 1")
|
||||
}
|
||||
>
|
||||
<span
|
||||
className={
|
||||
styles["stroke-text"]
|
||||
}
|
||||
style={
|
||||
{
|
||||
position: "relative",
|
||||
"--text-content":
|
||||
"Wrap 1",
|
||||
"--text-color":
|
||||
"#f6f6db",
|
||||
color: "rgb(246, 246, 219)",
|
||||
fontFamily:
|
||||
"Lexend, sans-serif",
|
||||
fontWeight: "bold",
|
||||
textDecoration: "none",
|
||||
fontStyle: "normal",
|
||||
textTransform: "none",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
data-text="Wrap 1"
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
background:
|
||||
"rgb(248, 66, 60)",
|
||||
position: "absolute",
|
||||
inset: "-0.2em -0.4em",
|
||||
borderRadius: "0.4em",
|
||||
zIndex: -2,
|
||||
}}
|
||||
></span>
|
||||
Wrap 1
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={classNames(
|
||||
"rounded-md px-2 py-4 flex items-center justify-center w-full relative gap-1 cursor-pointer outline-offset-2 outline-2 border transition-all bg-white hover:bg-base-100/50 border-base-100",
|
||||
{
|
||||
"border-secondary":
|
||||
captionPreset ===
|
||||
"Wrap 2",
|
||||
"hover:border-base-200":
|
||||
captionPreset !==
|
||||
"Wrap 2",
|
||||
}
|
||||
)}
|
||||
onClick={() =>
|
||||
setCaptionPreset("Wrap 2")
|
||||
}
|
||||
>
|
||||
<span
|
||||
className={
|
||||
styles["stroke-text"]
|
||||
}
|
||||
style={
|
||||
{
|
||||
position: "relative",
|
||||
"--text-content":
|
||||
"Wrap 2",
|
||||
"--text-color":
|
||||
"#ffffff",
|
||||
color: "rgb(255, 255, 255)",
|
||||
fontFamily:
|
||||
"Poppins, sans-serif",
|
||||
fontWeight: "bold",
|
||||
textDecoration: "none",
|
||||
fontStyle: "normal",
|
||||
textTransform:
|
||||
"uppercase",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
data-text="Wrap 2"
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
background:
|
||||
"rgb(121, 212, 246)",
|
||||
position: "absolute",
|
||||
inset: "-0.05em -0.5em",
|
||||
borderRadius: "0.2em",
|
||||
zIndex: -2,
|
||||
}}
|
||||
></span>
|
||||
Wrap 2
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={classNames(
|
||||
"rounded-md px-2 py-4 flex items-center justify-center w-full relative gap-1 cursor-pointer outline-offset-2 outline-2 border transition-all bg-white hover:bg-base-100/50 border-base-100",
|
||||
{
|
||||
"border-secondary":
|
||||
captionPreset ===
|
||||
"Faceless",
|
||||
"hover:border-base-200":
|
||||
captionPreset !==
|
||||
"Faceless",
|
||||
}
|
||||
)}
|
||||
onClick={() =>
|
||||
setCaptionPreset("Faceless")
|
||||
}
|
||||
>
|
||||
<span
|
||||
className={
|
||||
styles["bevel-text-effect"]
|
||||
}
|
||||
style={
|
||||
{
|
||||
position: "relative",
|
||||
"--text-content":
|
||||
"Faceless",
|
||||
"--text-color":
|
||||
"#D8D7D7",
|
||||
color: "rgb(216, 215, 215)",
|
||||
fontFamily:
|
||||
"Lexend, sans-serif",
|
||||
fontWeight: "bold",
|
||||
textDecoration: "none",
|
||||
fontStyle: "normal",
|
||||
textTransform:
|
||||
"uppercase",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
Faceless
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full mt-8 mb-3 flex items-center justify-between">
|
||||
<div className="text-sm font-base text-gray-500 inline-flex gap-2 items-center">
|
||||
{t("alignment")}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-2 w-full">
|
||||
<div
|
||||
onClick={() =>
|
||||
setCaptionPosition("top")
|
||||
}
|
||||
className={classNames(
|
||||
"flex items-center justify-center p-2 border border-base-200 text-xs transition-all bg-white hover:bg-white rounded-lg cursor-pointer gap-1 normal-case text-black/50 hover:text-black",
|
||||
{
|
||||
"border-secondary hover:border-secondary":
|
||||
captionPosition ===
|
||||
"top",
|
||||
}
|
||||
)}
|
||||
>
|
||||
<PiAlignTopSimple size={20} />
|
||||
{t("top")}
|
||||
</div>
|
||||
<div
|
||||
onClick={() =>
|
||||
setCaptionPosition("middle")
|
||||
}
|
||||
className={classNames(
|
||||
"flex items-center justify-center p-2 border border-base-200 text-xs transition-all bg-white hover:bg-white rounded-lg cursor-pointer gap-1 normal-case text-black/50 hover:text-black",
|
||||
{
|
||||
"border-secondary hover:border-secondary":
|
||||
captionPosition ===
|
||||
"middle",
|
||||
}
|
||||
)}
|
||||
>
|
||||
<PiAlignCenterVerticalSimple
|
||||
size={20}
|
||||
/>
|
||||
{t("middle")}
|
||||
</div>
|
||||
|
||||
<div
|
||||
onClick={() =>
|
||||
setCaptionPosition("bottom")
|
||||
}
|
||||
className={classNames(
|
||||
"flex items-center justify-center p-2 border border-base-200 text-xs transition-all bg-white hover:bg-white rounded-lg cursor-pointer gap-1 normal-case text-black/50 hover:text-black",
|
||||
{
|
||||
"border-secondary hover:border-secondary":
|
||||
captionPosition ===
|
||||
"bottom",
|
||||
}
|
||||
)}
|
||||
>
|
||||
<PiAlignBottomSimple size={20} />
|
||||
{t("bottom")}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</InputBox>
|
||||
<div className="relative flex items-end">
|
||||
<button
|
||||
onClick={create}
|
||||
className="inline-flex gap-1.5 items-center justify-center whitespace-nowrap z-0 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 bg-black border border-black relative w-auto text-white px-4 py-2 rounded-md"
|
||||
>
|
||||
{t("generateVideo")}
|
||||
</button>
|
||||
<div className="flex flex-col">
|
||||
<span className="opacity-60 ml-4 gap-1 text-sm items-center">
|
||||
{t("originalPrice")}{" "}
|
||||
<b className="line-through">30{t("credits")}</b>
|
||||
</span>
|
||||
<span className="ml-4 gap-1 text-sm items-center text-info">
|
||||
{t("currentPrice")}{" "}
|
||||
<b>
|
||||
20 {t("additionalCredits")} 10{" "}
|
||||
{t("additionalCreditsEnd")}
|
||||
</b>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
317
src/ui/(console)/create/components/video.tsx
Normal file
317
src/ui/(console)/create/components/video.tsx
Normal file
@@ -0,0 +1,317 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import useFetch from "@/hooks/useFetch";
|
||||
import classNames from "classnames";
|
||||
import * as Collapsible from "@radix-ui/react-collapsible";
|
||||
import * as Tabs from "@radix-ui/react-tabs";
|
||||
import { IoChevronDown, IoPlay, IoPause } from "react-icons/io5";
|
||||
import { useTranslations, useLocale } from "next-intl";
|
||||
import { GetSpeakersData } from "@/apis/auth";
|
||||
// 定义接口
|
||||
export interface VideoData {
|
||||
language: string;
|
||||
videoSelect: string;
|
||||
style: string;
|
||||
}
|
||||
|
||||
export default function VideoSelect({
|
||||
onChange,
|
||||
}: {
|
||||
onChange: (data: VideoData) => void;
|
||||
}) {
|
||||
const t = useTranslations("video");
|
||||
|
||||
const locale = useLocale();
|
||||
// const [speakersData, setSpeakersData] = useState<any>([]);
|
||||
const {
|
||||
data: speakersData,
|
||||
loading: speakersLoading,
|
||||
error: speakersError,
|
||||
fetchData: fetchSpeakers,
|
||||
} = useFetch({
|
||||
url: "/api/get_speakers/",
|
||||
method: "GET",
|
||||
cacheTime: 0,
|
||||
});
|
||||
|
||||
const [videoFormState, setVideoFormState] = useState({
|
||||
language: "zh-CN", // 国家语种
|
||||
videoSelect: "", // 讲话人
|
||||
style: "", // 讲话风格
|
||||
});
|
||||
|
||||
const [currentVideo, setCurrentVideo] = useState<any>({}); // 当前讲话人对象
|
||||
const [isPlaying, setIsPlaying] = useState<string | null>(null); // 当前播放的音频标识
|
||||
const [audio, setAudio] = useState<HTMLAudioElement | null>(null); // 音频对象
|
||||
|
||||
// GetSpeakersData().then((res) => {
|
||||
// console.log("res", res);
|
||||
// setSpeakersData(res.data);
|
||||
// videoChange(res.data[0].讲话人[0]);
|
||||
// });
|
||||
useEffect(() => {
|
||||
fetchSpeakers()
|
||||
.then((res) => {
|
||||
console.log("Fetched speakers data:", res);
|
||||
videoChange(res[0].讲话人[0]);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("Failed to fetch speakers data:", err);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const languageChange = (val: string) => {
|
||||
const selectedLanguage = getLanguageSpeaker(val);
|
||||
const video = selectedLanguage.讲话人[0];
|
||||
const newState = {
|
||||
language: val,
|
||||
videoSelect: video.讲话人英文参数,
|
||||
style: video.风格列表.length > 0 ? video.风格列表[0].英文参数 : "",
|
||||
};
|
||||
setVideoFormState(newState);
|
||||
setCurrentVideo(video); // 设置当前讲话人
|
||||
};
|
||||
|
||||
const getLanguageSpeaker = (language: string) => {
|
||||
return speakersData.find((item) => item.语言参数 === language);
|
||||
};
|
||||
|
||||
const videoChange = (item: any) => {
|
||||
const newState = {
|
||||
...videoFormState,
|
||||
videoSelect: item.讲话人英文参数,
|
||||
style: item.风格列表.length > 0 ? item.风格列表[0].英文参数 : "",
|
||||
};
|
||||
setVideoFormState(newState);
|
||||
setCurrentVideo(item); // 设置当前讲话人
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
console.log("videoFormState", videoFormState);
|
||||
onChange(videoFormState);
|
||||
}, [videoFormState, onChange]);
|
||||
|
||||
const handleToggle = (index) => {
|
||||
setOpenStates((prevState) => ({
|
||||
...prevState,
|
||||
[index]: !prevState[index], // 切换对应 index 的 open 状态
|
||||
}));
|
||||
};
|
||||
|
||||
const [openStates, setOpenStates] = useState({}); // 为每个 Collapsible 创建独立的 open 状态
|
||||
|
||||
const handlePlay = (audioUrl: string) => {
|
||||
// 先停止当前播放的音频
|
||||
if (audio) {
|
||||
audio.pause();
|
||||
audio.currentTime = 0; // 将音频重置到开头
|
||||
}
|
||||
|
||||
// 播放新音频
|
||||
const newAudio = new Audio(audioUrl);
|
||||
setAudio(newAudio);
|
||||
setIsPlaying(audioUrl);
|
||||
newAudio.play();
|
||||
|
||||
newAudio.onended = () => {
|
||||
setIsPlaying(null);
|
||||
};
|
||||
};
|
||||
|
||||
return (
|
||||
<Tabs.Root
|
||||
className=""
|
||||
defaultValue="zh-CN"
|
||||
value={videoFormState.language}
|
||||
onValueChange={languageChange}
|
||||
>
|
||||
<div className="mb-2">{t("language")}</div>
|
||||
<Tabs.List className="w-full flex flex-wrap gap-3 mb-4">
|
||||
{speakersData &&
|
||||
speakersData.map((item, index) => (
|
||||
<Tabs.Trigger
|
||||
key={index}
|
||||
value={item.语言参数}
|
||||
className={classNames(
|
||||
"relative rounded-md border border-base-200 bg-white px-3 flex gap-2 items-center justify-center cursor-pointer",
|
||||
{
|
||||
"outline-2 outline-offset-1 outline outline-secondary/80":
|
||||
videoFormState.language ===
|
||||
item.语言参数,
|
||||
}
|
||||
)}
|
||||
>
|
||||
{t(item.语言参数)}
|
||||
</Tabs.Trigger>
|
||||
))}
|
||||
</Tabs.List>
|
||||
{speakersData &&
|
||||
speakersData.map((item, index) => (
|
||||
<Tabs.Content key={index} value={item.语言参数}>
|
||||
<div className="">
|
||||
<div className="mb-2">{t("speaker")}</div>
|
||||
<div className="grid grid-cols-2 gap-2 flex-wrap w-full">
|
||||
{item.讲话人
|
||||
.slice(0, 6)
|
||||
.map((speakItem, speakIndex) => (
|
||||
<div
|
||||
key={speakIndex}
|
||||
className="w-full relative group"
|
||||
onClick={() =>
|
||||
videoChange(speakItem)
|
||||
}
|
||||
>
|
||||
<div className="relative z-0">
|
||||
<div
|
||||
className={classNames(
|
||||
"p-2 cursor-pointer border border-base-200 rounded-lg flex flex-col gap-3",
|
||||
{
|
||||
"outline outline-2 outline-secondary outline-offset-2 bg-secondary bg-opacity-[0.02]":
|
||||
videoFormState.videoSelect ===
|
||||
speakItem.讲话人英文参数,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<div className="flex gap-2 justify-between items-center">
|
||||
{speakItem.讲话人中文名}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Collapsible.Root
|
||||
className="CollapsibleRoot"
|
||||
open={openStates[index] || false}
|
||||
onOpenChange={() => handleToggle(index)}
|
||||
>
|
||||
<div className="border-b border-none">
|
||||
{item.讲话人.length > 6 && (
|
||||
<Collapsible.Trigger
|
||||
asChild
|
||||
className="my-2"
|
||||
>
|
||||
<button className="cursor-pointer flex flex-1 text-xs items-center font-medium transition-all hover:bg-base-100/50 rounded-lg p-2 gap-1.5 [&[data-state=open]>svg]:rotate-180 justify-start">
|
||||
<label className="cursor-pointer">
|
||||
{t("findMoreVoices")}
|
||||
</label>
|
||||
<IoChevronDown />
|
||||
</button>
|
||||
</Collapsible.Trigger>
|
||||
)}
|
||||
{item.讲话人.length > 6 && (
|
||||
<Collapsible.Content className="transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-visible">
|
||||
<div className="grid grid-cols-2 gap-2 flex-wrap w-full">
|
||||
{item.讲话人
|
||||
.slice(6)
|
||||
.map(
|
||||
(
|
||||
speakItem,
|
||||
speakIndex
|
||||
) => (
|
||||
<div
|
||||
key={
|
||||
speakIndex +
|
||||
6
|
||||
}
|
||||
className="w-full relative group"
|
||||
onClick={() =>
|
||||
videoChange(
|
||||
speakItem
|
||||
)
|
||||
}
|
||||
>
|
||||
<div className="relative z-0">
|
||||
<div
|
||||
className={classNames(
|
||||
"p-2 cursor-pointer border border-base-200 rounded-lg flex flex-col gap-3",
|
||||
{
|
||||
"outline outline-2 outline-secondary outline-offset-2 bg-secondary bg-opacity-[0.02]":
|
||||
videoFormState.videoSelect ===
|
||||
speakItem.讲话人英文参数,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<div className="flex gap-2 justify-between items-center">
|
||||
<div className="">
|
||||
{
|
||||
speakItem.讲话人中文名
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</Collapsible.Content>
|
||||
)}
|
||||
</div>
|
||||
</Collapsible.Root>
|
||||
</div>
|
||||
<div className="pt-2">
|
||||
<div className="mb-2"> {t("speakingStyle")}</div>
|
||||
<div className="h-full flex flex-col">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{JSON.stringify(currentVideo) !== "{}" &&
|
||||
currentVideo.风格列表.map(
|
||||
(styleItem, styleIndex) => {
|
||||
const audioUrl = `/audio/${videoFormState.videoSelect}_${styleItem.英文参数}.mp3`; // 假设音频文件存放在 public/audio 目录中
|
||||
return (
|
||||
<button
|
||||
key={styleIndex}
|
||||
className={classNames(
|
||||
"px-2 py-1 border border-base-200 rounded-md text-xs cursor-pointer hover:bg-secondary/80 hover:text-white flex items-center gap-2",
|
||||
{
|
||||
"bg-secondary text-white":
|
||||
videoFormState.style ===
|
||||
styleItem.英文参数,
|
||||
}
|
||||
)}
|
||||
onClick={() => {
|
||||
setVideoFormState(
|
||||
(
|
||||
prevState
|
||||
) => ({
|
||||
...prevState,
|
||||
style: styleItem.英文参数,
|
||||
})
|
||||
);
|
||||
handlePlay(
|
||||
audioUrl
|
||||
); // 点击播放音频
|
||||
}}
|
||||
>
|
||||
{/* {isPlaying ===
|
||||
audioUrl ? (
|
||||
<IoPause className="animate-spin" />
|
||||
) : (
|
||||
|
||||
)} */}
|
||||
<div className="flex items-center">
|
||||
<IoPlay
|
||||
className={
|
||||
videoFormState.style ===
|
||||
styleItem.英文参数
|
||||
? "text-white mr-1"
|
||||
: " mr-1"
|
||||
}
|
||||
/>
|
||||
<div>
|
||||
{locale === "zh"
|
||||
? styleItem.中文参数
|
||||
: styleItem.英文参数}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
))}
|
||||
</Tabs.Root>
|
||||
);
|
||||
}
|
||||
128
src/ui/(console)/create/create-tabs.tsx
Normal file
128
src/ui/(console)/create/create-tabs.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
"use client";
|
||||
|
||||
import * as Tabs from "@radix-ui/react-tabs";
|
||||
import { Component, useState } from "react";
|
||||
import classNames from "classnames";
|
||||
import Tiktok from "./tiktok";
|
||||
import Avatar from "./avatar";
|
||||
import Music from "./music";
|
||||
import TextVideo from "./textVideo";
|
||||
import ImageVideo from "./imageVideo";
|
||||
import { useTranslations } from "next-intl";
|
||||
export default function CreateTabs() {
|
||||
const t = useTranslations("createTabs");
|
||||
const [tabVal, setTabVal] = useState("text");
|
||||
const tabsChange = (val: string) => {
|
||||
setTabVal(val);
|
||||
};
|
||||
const getVideoTempUrl = (val: string) => {
|
||||
for (let i of tabs) {
|
||||
if (i.value === val) {
|
||||
return i.videoTemp;
|
||||
}
|
||||
}
|
||||
};
|
||||
const tabs = [
|
||||
{
|
||||
name: t("aiTextVideoGenerator"),
|
||||
value: "text",
|
||||
component: <TextVideo />,
|
||||
videoTemp: "/video/img-to-video/cn/demo_video.webm",
|
||||
|
||||
},
|
||||
{
|
||||
name: t("aiImageVideoGenerator"),
|
||||
value: "image",
|
||||
component: <ImageVideo />,
|
||||
videoTemp: "/video/img-to-video/cn/demo_image.webm",
|
||||
},
|
||||
{
|
||||
name: t("aiTiktokVideoGenerator"),
|
||||
value: "tiktok",
|
||||
component: <Tiktok />,
|
||||
videoTemp:
|
||||
"/video/create-tiktok-video/en/demo_video.webm",
|
||||
ImageTemp:
|
||||
"/video/create-tiktok-video/en/demo_demo.webm",
|
||||
},
|
||||
{
|
||||
name: t("aiTalkingAvatarVideoCreator"),
|
||||
value: "avatar",
|
||||
component: <Avatar />,
|
||||
videoTemp:
|
||||
"/video/create-Avatar-video/en/demo_video.webm",
|
||||
ImageTemp:
|
||||
"/video/create-Avatar-video/en/demo_video.webm",
|
||||
},
|
||||
{
|
||||
name: t("aiMusicVideoGenerator"),
|
||||
value: "music",
|
||||
component: <Music />,
|
||||
videoTemp:
|
||||
"/video/music-to-video/cn/demo_video.webm",
|
||||
ImageTemp:
|
||||
"/video/music-to-video/cn/demo_video.webm",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Tabs.Root
|
||||
className=""
|
||||
defaultValue="tiktok"
|
||||
value={tabVal}
|
||||
onValueChange={tabsChange}
|
||||
>
|
||||
<Tabs.List className="w-full flex flex-wrap gap-3">
|
||||
{tabs.map((item, index) => {
|
||||
return (
|
||||
<Tabs.Trigger
|
||||
className={classNames(
|
||||
"relative rounded-lg border border-base-200 bg-white p-3 flex gap-2 items-center justify-center cursor-pointer",
|
||||
{
|
||||
"outline-2 outline-offset-1 outline outline-secondary":
|
||||
tabVal === item.value,
|
||||
}
|
||||
)}
|
||||
value={item.value}
|
||||
key={index}
|
||||
>
|
||||
<span className="text-gray-800 font-medium text-xs">
|
||||
{item.name}
|
||||
</span>
|
||||
</Tabs.Trigger>
|
||||
);
|
||||
})}
|
||||
</Tabs.List>
|
||||
<div className="mt-6 pb-20 flex flex-colw w-full gap-4">
|
||||
<div className="w-full flex-[100%]">
|
||||
{tabs.map((item, index) => {
|
||||
return (
|
||||
<Tabs.Content
|
||||
className="TabsContent"
|
||||
key={index}
|
||||
value={item.value}
|
||||
>
|
||||
<div className="w-full flex flex-col gap-4">
|
||||
{item.component}
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="gap-4 hidden xl:flex">
|
||||
<div className="border-l-[1px] border-gray-200 h-full"></div>
|
||||
<div className="w-full h-fit mt-8 rounded-xl overflow-hidden">
|
||||
<video
|
||||
src={getVideoTempUrl(tabVal)}
|
||||
playsInline
|
||||
loop
|
||||
muted
|
||||
autoPlay
|
||||
preload="auto"
|
||||
></video>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Tabs.Root>
|
||||
);
|
||||
}
|
||||
38
src/ui/(console)/create/imageVideo/index.module.css
Normal file
38
src/ui/(console)/create/imageVideo/index.module.css
Normal file
@@ -0,0 +1,38 @@
|
||||
.stroke-text {
|
||||
font-weight: bold;
|
||||
text-transform: uppercase;
|
||||
color: white;
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.stroke-text:after, .stroke-text:before {
|
||||
content: attr(data-text);
|
||||
position: absolute;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
-webkit-text-stroke: .2em #000;
|
||||
z-index: -1; /* 确保它们位于主文本前 */
|
||||
}
|
||||
.stroke-text:after{
|
||||
left: .05em;
|
||||
top: .05em;
|
||||
}
|
||||
.stroke-text:before{
|
||||
left: 0;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.glow-text-effect{
|
||||
color: var(--text-color, #4ade80);
|
||||
text-shadow: 0 0 .3em rgba(0, 0, 0, .7), 0 0 .05em var(--text-color, #4ade80), 0 0 .1em var(--text-color, #4ade80), 0 0 .15em var(--text-color, #4ade80), 0 0 .2em var(--text-color, #4ade80), 0 0 .25em var(--text-color, #4ade80);
|
||||
}
|
||||
.bevel-text-effect {
|
||||
display: inline-flex;
|
||||
position: relative;
|
||||
color: var(--text-color, #4ade80);
|
||||
-webkit-text-stroke: .025em var(--text-color, #4ade80);
|
||||
-webkit-text-fill-color: transparent;
|
||||
text-shadow: 0 .025em .0375em var(--text-color, #4ade80), 0 0 0 #fff;
|
||||
}
|
||||
289
src/ui/(console)/create/imageVideo/index.tsx
Normal file
289
src/ui/(console)/create/imageVideo/index.tsx
Normal file
@@ -0,0 +1,289 @@
|
||||
import InputBox from "@/components/InputBox";
|
||||
import { useToast } from "@/contexts/ToastContext";
|
||||
import useFetch from "@/hooks/useFetch";
|
||||
import classNames from "classnames";
|
||||
import { use, useEffect, useState } from "react";
|
||||
import useLoadingStore from "@/store/loadingStore";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Field, Label, Radio, RadioGroup } from "@headlessui/react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import Image from "next/image";
|
||||
import UploadImage from "@/components/UploadImage";
|
||||
const models = ["gen2", "gen3"];
|
||||
const ratios = [
|
||||
{
|
||||
label: "16:9",
|
||||
id: 1,
|
||||
value: {
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
},
|
||||
model: ["gen2", "gen3"],
|
||||
},
|
||||
{
|
||||
label: "9:16",
|
||||
id: 2,
|
||||
value: {
|
||||
width: 1080,
|
||||
height: 1920,
|
||||
},
|
||||
model: ["gen2"],
|
||||
|
||||
tooltip: "仅在Gen2模型下支持9:16",
|
||||
},
|
||||
{
|
||||
label: "1:1",
|
||||
id: 3,
|
||||
value: {
|
||||
width: 1080,
|
||||
height: 1080,
|
||||
},
|
||||
model: ["gen2"],
|
||||
tooltip: "仅在Gen2模型下支持1:1",
|
||||
},
|
||||
];
|
||||
const times = [
|
||||
{
|
||||
label: "4s",
|
||||
id: 1,
|
||||
value: 4,
|
||||
model: "gen2",
|
||||
tooltip: "仅在Gen2模型下支持4s",
|
||||
},
|
||||
{
|
||||
label: "5s",
|
||||
id: 2,
|
||||
value: 5,
|
||||
model: "gen3",
|
||||
tooltip: "仅在Gen3模型下支持5s",
|
||||
},
|
||||
{
|
||||
label: "10s",
|
||||
id: 3,
|
||||
value: 10,
|
||||
model: "gen3",
|
||||
tooltip: "仅在Gen3模型下支持10s",
|
||||
},
|
||||
];
|
||||
|
||||
export default function ImageVideo() {
|
||||
const router = useRouter();
|
||||
const t = useTranslations("tiktok");
|
||||
const { addToast, addToastSuccess, addToastError } = useToast();
|
||||
const [rate, setRate] = useState(5);
|
||||
let [model, setModel] = useState(models[0]);
|
||||
|
||||
const showLoading = useLoadingStore((state) => state.showLoading);
|
||||
const hideLoading = useLoadingStore((state) => state.hideLoading);
|
||||
|
||||
const {
|
||||
fetchData: createFetch,
|
||||
loading: createLoading,
|
||||
data: createResult,
|
||||
} = useFetch({
|
||||
url: "/api/text-to-video/",
|
||||
method: "POST",
|
||||
});
|
||||
const create = () => {
|
||||
const requestData = {
|
||||
text_prompt: text,
|
||||
model: model,
|
||||
time: time.value,
|
||||
motion: rate,
|
||||
image_url: imageUrl,
|
||||
};
|
||||
if (text === "") {
|
||||
addToastError(t("enterVideoText"));
|
||||
return;
|
||||
}
|
||||
if (imageUrl === "") {
|
||||
addToastError(t("uploadAvatarImage"));
|
||||
return;
|
||||
}
|
||||
console.log("requestData", requestData);
|
||||
showLoading("creating...");
|
||||
createFetch(requestData)
|
||||
.then((res) => {
|
||||
addToastSuccess(t("createSuccess"));
|
||||
router.push("/projects");
|
||||
})
|
||||
.finally(() => {
|
||||
hideLoading();
|
||||
});
|
||||
};
|
||||
|
||||
const [time, setTime] = useState(times[0]);
|
||||
const [text, setText] = useState("");
|
||||
|
||||
const [imageUrl, setImageUrl] = useState("");
|
||||
const handleImageDataChange = (imageUrl: string) => {
|
||||
setImageUrl(imageUrl);
|
||||
};
|
||||
const modelChange = (e) => {
|
||||
setModel(e);
|
||||
if (!time.model !== e) {
|
||||
for (let i of times) {
|
||||
if (i.model === e) {
|
||||
setTime(i);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<p className="text-info text-sm">{t("useTool")}</p>
|
||||
<InputBox serial={1} title={t("yourVideoText")}>
|
||||
<textarea
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
className="min-h-36 textarea text-neutral placeholder:text-gray-300 font-normal outline-base-200 focus:outline-offset-[1px] outline outline-[1.5px] focus:outline-[1.5px] focus:base-200 rounded-lg bg-transparent overflow-hidden text-base resize-none p-4"
|
||||
placeholder={t("img-to-video")}
|
||||
></textarea>
|
||||
<small className="whitespace-pre-line text-info">
|
||||
{t("videoTextTip")}
|
||||
</small>
|
||||
</InputBox>
|
||||
<InputBox serial={2} title={t("uploadImage")}>
|
||||
<UploadImage onChange={handleImageDataChange} />
|
||||
</InputBox>
|
||||
<InputBox serial={3} title={t("pickParameters")}>
|
||||
<div className="flex flex-col gap-8">
|
||||
<div className="flex gap-2 flex-col md:flex-row md:items-center">
|
||||
<div className="min-w-[160px] flex flex-col ml-2">
|
||||
<div className="text-gray-700 text-md">
|
||||
{t("selectionModel")}
|
||||
</div>
|
||||
{/* <div className="text-info text-xs">
|
||||
{t("dynamicOrVideo")}
|
||||
</div> */}
|
||||
</div>
|
||||
<div className="">
|
||||
<RadioGroup
|
||||
value={model}
|
||||
onChange={modelChange}
|
||||
aria-label="Server size"
|
||||
className="flex items-center gap-4"
|
||||
>
|
||||
{models.map((model) => (
|
||||
<Field key={model}>
|
||||
<Radio
|
||||
value={model}
|
||||
className="group flex items-center gap-2 bg-base-100 rounded-xl py-2 px-4 cursor-pointer border-2 border-base-200/50 data-[checked]:border-secondary data-[checked]:bg-secondary/5"
|
||||
>
|
||||
<div className="flex size-5 items-center justify-center rounded-full border bg-white group-data-[checked]:bg-secondary">
|
||||
<span className="invisible size-2 rounded-full bg-white group-data-[checked]:visible" />
|
||||
</div>
|
||||
<Label className="cursor-pointer">
|
||||
{model}
|
||||
</Label>
|
||||
</Radio>
|
||||
</Field>
|
||||
))}
|
||||
</RadioGroup>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 flex-col md:flex-row md:items-center">
|
||||
<div className="min-w-[160px] flex flex-col ml-2 ">
|
||||
<div className="text-gray-700 text-md">
|
||||
{t("creativeImagination")}
|
||||
</div>
|
||||
<div className="text-info text-xs">
|
||||
{/* {t("adaptScreenRatio")} */}
|
||||
{t("creativityProgressBar")}
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full max-w-[400px]">
|
||||
<div className="w-full">
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={10}
|
||||
value={rate}
|
||||
className="range range-secondary range-sm"
|
||||
step={1}
|
||||
onChange={(e) =>
|
||||
setRate(Number(e.target.value))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex w-full justify-between px-2 text-xs">
|
||||
<span>|</span>
|
||||
<span>|</span>
|
||||
<span>|</span>
|
||||
<span>|</span>
|
||||
<span>|</span>
|
||||
<span>|</span>
|
||||
<span>|</span>
|
||||
<span>|</span>
|
||||
<span>|</span>
|
||||
<span>|</span>
|
||||
<span>|</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 flex-col md:flex-row md:items-center">
|
||||
<div className="min-w-[160px] flex flex-col ml-2">
|
||||
<div className="text-gray-700 text-md">
|
||||
{t("selectVideoDuration")}
|
||||
</div>
|
||||
{/* <div className="text-info text-xs">
|
||||
选择视频时长
|
||||
</div> */}
|
||||
</div>
|
||||
<div className="">
|
||||
<RadioGroup
|
||||
value={time}
|
||||
onChange={setTime}
|
||||
aria-label="Video aspect ratio"
|
||||
>
|
||||
<div className="flex space-x-10">
|
||||
{times.map((time) => (
|
||||
<Field
|
||||
key={time.id}
|
||||
className="relative flex items-center gap-2 group"
|
||||
disabled={time.model != model}
|
||||
>
|
||||
<Radio
|
||||
value={time}
|
||||
className="group flex size-5 items-center justify-center rounded-full border cursor-pointer bg-white data-[checked]:bg-secondary data-[disabled]:bg-base-200"
|
||||
>
|
||||
<span className="invisible size-2 rounded-full bg-white group-data-[checked]:visible" />
|
||||
</Radio>
|
||||
<Label className="cursor-pointer">
|
||||
{time.label}
|
||||
</Label>
|
||||
{time.model !== model && (
|
||||
<div className="w-max absolute left-1/2 transform -translate-x-1/2 bottom-full mb-2 bg-gray-700 text-white text-xs rounded py-1 px-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
{time.tooltip}
|
||||
<div className="absolute left-1/2 transform -translate-x-1/2 -translate-y-1/2 top-full w-2 h-2 bg-gray-700 rotate-45"></div>
|
||||
</div>
|
||||
)}
|
||||
</Field>
|
||||
))}
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</InputBox>
|
||||
<div className="relative flex items-end">
|
||||
<button
|
||||
onClick={create}
|
||||
className="inline-flex gap-1.5 items-center justify-center whitespace-nowrap z-0 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 bg-black border border-black relative w-auto text-white px-4 py-2 rounded-md"
|
||||
>
|
||||
{t("generateVideo")}
|
||||
</button>
|
||||
<div className="flex flex-col">
|
||||
<span className="opacity-60 ml-4 gap-1 text-sm items-center">
|
||||
{t("originalPrice")}{" "}
|
||||
<b className="line-through">20{t("credits")}</b>
|
||||
</span>
|
||||
<span className="ml-4 gap-1 text-sm items-center text-info">
|
||||
{t("currentPrice")} <b>10 {t("credits")}</b>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
38
src/ui/(console)/create/music/index.module.css
Normal file
38
src/ui/(console)/create/music/index.module.css
Normal file
@@ -0,0 +1,38 @@
|
||||
.stroke-text {
|
||||
font-weight: bold;
|
||||
text-transform: uppercase;
|
||||
color: white;
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.stroke-text:after, .stroke-text:before {
|
||||
content: attr(data-text);
|
||||
position: absolute;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
-webkit-text-stroke: .2em #000;
|
||||
z-index: -1; /* 确保它们位于主文本前 */
|
||||
}
|
||||
.stroke-text:after{
|
||||
left: .05em;
|
||||
top: .05em;
|
||||
}
|
||||
.stroke-text:before{
|
||||
left: 0;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.glow-text-effect{
|
||||
color: var(--text-color, #4ade80);
|
||||
text-shadow: 0 0 .3em rgba(0, 0, 0, .7), 0 0 .05em var(--text-color, #4ade80), 0 0 .1em var(--text-color, #4ade80), 0 0 .15em var(--text-color, #4ade80), 0 0 .2em var(--text-color, #4ade80), 0 0 .25em var(--text-color, #4ade80);
|
||||
}
|
||||
.bevel-text-effect {
|
||||
display: inline-flex;
|
||||
position: relative;
|
||||
color: var(--text-color, #4ade80);
|
||||
-webkit-text-stroke: .025em var(--text-color, #4ade80);
|
||||
-webkit-text-fill-color: transparent;
|
||||
text-shadow: 0 .025em .0375em var(--text-color, #4ade80), 0 0 0 #fff;
|
||||
}
|
||||
589
src/ui/(console)/create/music/index.tsx
Normal file
589
src/ui/(console)/create/music/index.tsx
Normal file
@@ -0,0 +1,589 @@
|
||||
import InputBox from "@/components/InputBox";
|
||||
import ListBox from "@/components/ListBox";
|
||||
import { useToast } from "@/contexts/ToastContext";
|
||||
import useFetch from "@/hooks/useFetch";
|
||||
import classNames from "classnames";
|
||||
import { use, useEffect, useState } from "react";
|
||||
import { MdCheckCircle } from "react-icons/md";
|
||||
import VideoSelect, { VideoData } from "../components/video";
|
||||
import useUserStore from "@/store/userStore";
|
||||
import useLoadingStore from "@/store/loadingStore";
|
||||
import { useRouter } from "next/navigation";
|
||||
import UploadImage from "@/components/UploadImage";
|
||||
import styles from "./index.module.css";
|
||||
import { Switch } from "@headlessui/react";
|
||||
import {
|
||||
PiAlignTopSimple,
|
||||
PiAlignCenterVerticalSimple,
|
||||
PiAlignBottomSimple,
|
||||
} from "react-icons/pi";
|
||||
import UploadAudio from "@/components/UploadAudio";
|
||||
import { useTranslations } from "next-intl";
|
||||
export default function Music() {
|
||||
const router = useRouter();
|
||||
const t = useTranslations("tiktok");
|
||||
const { addToast, addToastSuccess, addToastError } = useToast();
|
||||
const [enabledCaptions, setEnabledCaptions] = useState(false);
|
||||
|
||||
const ratioList = [
|
||||
{
|
||||
name: "16 / 9",
|
||||
id: 1,
|
||||
value: "16 / 9",
|
||||
},
|
||||
{
|
||||
name: "1 / 1",
|
||||
id: 2,
|
||||
value: "1 / 1",
|
||||
},
|
||||
{
|
||||
name: "9 / 16",
|
||||
id: 3,
|
||||
value: "9 / 16",
|
||||
},
|
||||
];
|
||||
const showLoading = useLoadingStore((state) => state.showLoading);
|
||||
const hideLoading = useLoadingStore((state) => state.hideLoading);
|
||||
|
||||
const {
|
||||
fetchData: createFetch,
|
||||
loading: createLoading,
|
||||
data: createResult,
|
||||
} = useFetch({
|
||||
url: "/api/create-music-video/",
|
||||
method: "POST",
|
||||
});
|
||||
const create = () => {
|
||||
const requestData = {
|
||||
ratio: ratio.value,
|
||||
mediaType: mediaType.value,
|
||||
audioUrl: audioUrl,
|
||||
slug: "music-to-video",
|
||||
};
|
||||
if (audioUrl === "") {
|
||||
addToastError(t("uploadAudio"));
|
||||
return;
|
||||
}
|
||||
if (enabledCaptions) {
|
||||
requestData["captionPreset"] = captionPreset;
|
||||
requestData["captionPosition"] = captionPosition;
|
||||
}
|
||||
console.log("requestData", requestData);
|
||||
showLoading("creating...");
|
||||
createFetch(requestData)
|
||||
.then((res) => {
|
||||
console.log("create result:", res);
|
||||
if (res.success === 1) {
|
||||
addToastSuccess(t("createSuccess"));
|
||||
router.push("/projects");
|
||||
} else {
|
||||
addToastError(t("createFailed"));
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
hideLoading();
|
||||
});
|
||||
};
|
||||
|
||||
const [ratio, setRatio] = useState(ratioList[0]);
|
||||
|
||||
const [captionPreset, setCaptionPreset] = useState("Wrap 1");
|
||||
|
||||
const [captionPosition, setCaptionPosition] = useState("bottom");
|
||||
const mediaTypeList = [
|
||||
{ id: 1, name: t("stockVideo"), value: "stockVideo" },
|
||||
{ id: 2, name: t("movingImage"), value: "movingImage" },
|
||||
];
|
||||
|
||||
const [mediaType, setMediaType] = useState(mediaTypeList[1]);
|
||||
|
||||
const [videoData, setVideoData] = useState<VideoData>({
|
||||
language: "", // 国家语种
|
||||
videoSelect: "", // 讲话人
|
||||
style: "", // 讲话风格
|
||||
});
|
||||
const handleVideoDataChange = (newData: VideoData) => {
|
||||
setVideoData(newData);
|
||||
};
|
||||
const [audioUrl, setAudioUrl] = useState("");
|
||||
const handleAudioDataChange = (audioUrl: string) => {
|
||||
setAudioUrl(audioUrl);
|
||||
};
|
||||
|
||||
const useinfo = useUserStore((state) => state.user);
|
||||
|
||||
return (
|
||||
<>
|
||||
<InputBox serial={1} title={t("uploadFile")}>
|
||||
<UploadAudio onChange={handleAudioDataChange} />
|
||||
{/* <small className="whitespace-pre-line text-info">
|
||||
💡 Tip: use short, punctuated sentences.
|
||||
</small> */}
|
||||
</InputBox>
|
||||
|
||||
<InputBox serial={2} title={t("pickParameters")}>
|
||||
<div className="flex gap-2 flex-col md:flex-row md:items-center">
|
||||
<div className="min-w-[200px]">
|
||||
<div className="relative group">
|
||||
<ListBox
|
||||
list={mediaTypeList}
|
||||
value={mediaType}
|
||||
onChange={setMediaType}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col ml-2">
|
||||
<div className="text-gray-700 text-md">
|
||||
{t("selectFootageType")}
|
||||
</div>
|
||||
<div className="text-info text-xs">
|
||||
{t("dynamicOrVideo")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 flex-col md:flex-row md:items-center">
|
||||
<div className="min-w-[200px]">
|
||||
<div className="relative group">
|
||||
<ListBox
|
||||
list={ratioList}
|
||||
value={ratio}
|
||||
onChange={setRatio}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col ml-2">
|
||||
<div className="text-gray-700 text-md">
|
||||
{t("chooseScreenRatio")}
|
||||
</div>
|
||||
<div className="text-info text-xs">
|
||||
{t("adaptScreenRatio")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</InputBox>
|
||||
<InputBox serial={3} title={t("chooseGenerationPreset")}>
|
||||
<div className="flex flex-col w-full h-full relative">
|
||||
<div className="overflow-auto px-12 pb-6 pt-6 h-full no-scrollbar">
|
||||
<div className="flex flex-col w-full">
|
||||
<div className="flex items-center mb-4">
|
||||
<Switch
|
||||
checked={enabledCaptions}
|
||||
onChange={setEnabledCaptions}
|
||||
className="mr-2 group inline-flex h-6 w-11 items-center rounded-full bg-base-200 transition data-[checked]:bg-secondary"
|
||||
>
|
||||
<span className="size-4 translate-x-1 rounded-full bg-white transition group-data-[checked]:translate-x-6" />
|
||||
</Switch>
|
||||
<div className="text-gray-500">
|
||||
是否开启字幕
|
||||
</div>
|
||||
</div>
|
||||
{enabledCaptions && (
|
||||
<>
|
||||
<div className="w-full mb-3 flex items-center justify-between">
|
||||
<div className="text-sm font-base text-gray-500 inline-flex gap-2 items-center">
|
||||
{t("selectPreset")}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-2 w-full">
|
||||
<div
|
||||
className={classNames(
|
||||
"rounded-md px-2 py-4 flex items-center justify-center w-full relative gap-1 cursor-pointer outline-offset-2 outline-2 border transition-all bg-white hover:bg-base-100/50 border-base-100",
|
||||
{
|
||||
"border-secondary":
|
||||
captionPreset ===
|
||||
"Basic",
|
||||
"hover:border-base-200":
|
||||
captionPreset !==
|
||||
"Basic",
|
||||
}
|
||||
)}
|
||||
onClick={() =>
|
||||
setCaptionPreset("Basic")
|
||||
}
|
||||
>
|
||||
<span
|
||||
className="heavy-shadow-text-effect"
|
||||
style={
|
||||
{
|
||||
position: "relative",
|
||||
"--text-content":
|
||||
"Basic",
|
||||
"--text-color":
|
||||
"#FFFFFF",
|
||||
color: "rgb(255, 255, 255)",
|
||||
fontFamily:
|
||||
"Montserrat, sans-serif",
|
||||
fontWeight: "bold",
|
||||
textDecoration: "none",
|
||||
fontStyle: "normal",
|
||||
textTransform:
|
||||
"uppercase",
|
||||
textShadow: `
|
||||
.1em .1em .1em #000,
|
||||
.1em -.1em .1em #000,
|
||||
-.1em .1em .1em #000,
|
||||
-.1em -.1em .1em #000,
|
||||
.1em .1em .2em #000,
|
||||
.1em -.1em .2em #000,
|
||||
-.1em .1em .2em #000,
|
||||
-.1em -.1em .2em #000,
|
||||
0 0 .1em #000,
|
||||
0 0 .2em #000,
|
||||
0 0 .3em #000,
|
||||
0 0 .4em #000,
|
||||
0 0 .5em #000,
|
||||
0 0 .6em #000
|
||||
`,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
Basic
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={classNames(
|
||||
"rounded-md px-2 py-4 flex items-center justify-center w-full relative gap-1 cursor-pointer outline-offset-2 outline-2 border transition-all bg-white hover:bg-base-100/50 border-base-100",
|
||||
{
|
||||
"border-secondary":
|
||||
captionPreset ===
|
||||
"REVID",
|
||||
"hover:border-base-200":
|
||||
captionPreset !==
|
||||
"REVID",
|
||||
}
|
||||
)}
|
||||
onClick={() =>
|
||||
setCaptionPreset("REVID")
|
||||
}
|
||||
>
|
||||
<span
|
||||
className={
|
||||
styles["stroke-text"]
|
||||
}
|
||||
style={{
|
||||
fontFamily:
|
||||
"Poppins, sans-serif",
|
||||
}}
|
||||
data-text="REVID"
|
||||
>
|
||||
REVID
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={classNames(
|
||||
"rounded-md px-2 py-4 flex items-center justify-center w-full relative gap-1 cursor-pointer outline-offset-2 outline-2 border transition-all bg-white hover:bg-base-100/50 border-base-100",
|
||||
{
|
||||
"border-secondary":
|
||||
captionPreset ===
|
||||
"Hormozi",
|
||||
"hover:border-base-200":
|
||||
captionPreset !==
|
||||
"Hormozi",
|
||||
}
|
||||
)}
|
||||
onClick={() =>
|
||||
setCaptionPreset("Hormozi")
|
||||
}
|
||||
>
|
||||
<span
|
||||
className={
|
||||
styles["glow-text-effect"]
|
||||
}
|
||||
style={
|
||||
{
|
||||
position: "relative",
|
||||
"--text-content":
|
||||
"Hormozi",
|
||||
"--text-effect-color":
|
||||
"#FFED38",
|
||||
"--text-color":
|
||||
"#FFED38",
|
||||
color: "rgb(255, 237, 56)",
|
||||
fontFamily:
|
||||
"Anton, sans-serif",
|
||||
fontWeight: "bold",
|
||||
textDecoration: "none",
|
||||
fontStyle: "normal",
|
||||
textTransform:
|
||||
"uppercase",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
Hormozi
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={classNames(
|
||||
"rounded-md px-2 py-4 flex items-center justify-center w-full relative gap-1 cursor-pointer outline-offset-2 outline-2 border transition-all bg-white hover:bg-base-100/50 border-base-100",
|
||||
{
|
||||
"border-secondary":
|
||||
captionPreset === "Ali",
|
||||
"hover:border-base-200":
|
||||
captionPreset !== "Ali",
|
||||
}
|
||||
)}
|
||||
onClick={() =>
|
||||
setCaptionPreset("Ali")
|
||||
}
|
||||
>
|
||||
<span
|
||||
className="wrap-text-effect"
|
||||
style={
|
||||
{
|
||||
position: "relative",
|
||||
"--text-content": "Ali",
|
||||
"--text-effect-color":
|
||||
"#FFFFFF",
|
||||
"--text-color":
|
||||
"#1C1E1D",
|
||||
color: "rgb(28, 30, 29)",
|
||||
fontFamily:
|
||||
"Poppins, sans-serif",
|
||||
fontWeight: "bold",
|
||||
textDecoration: "none",
|
||||
fontStyle: "normal",
|
||||
textTransform: "none",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
Ali
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={classNames(
|
||||
"rounded-md px-2 py-4 flex items-center justify-center w-full relative gap-1 cursor-pointer outline-offset-2 outline-2 border transition-all bg-white hover:bg-base-100/50 border-base-100",
|
||||
{
|
||||
"border-secondary":
|
||||
captionPreset ===
|
||||
"Wrap 1",
|
||||
"hover:border-base-200":
|
||||
captionPreset !==
|
||||
"Wrap 1",
|
||||
}
|
||||
)}
|
||||
onClick={() =>
|
||||
setCaptionPreset("Wrap 1")
|
||||
}
|
||||
>
|
||||
<span
|
||||
className={
|
||||
styles["stroke-text"]
|
||||
}
|
||||
style={
|
||||
{
|
||||
position: "relative",
|
||||
"--text-content":
|
||||
"Wrap 1",
|
||||
"--text-color":
|
||||
"#f6f6db",
|
||||
color: "rgb(246, 246, 219)",
|
||||
fontFamily:
|
||||
"Lexend, sans-serif",
|
||||
fontWeight: "bold",
|
||||
textDecoration: "none",
|
||||
fontStyle: "normal",
|
||||
textTransform: "none",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
data-text="Wrap 1"
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
background:
|
||||
"rgb(248, 66, 60)",
|
||||
position: "absolute",
|
||||
inset: "-0.2em -0.4em",
|
||||
borderRadius: "0.4em",
|
||||
zIndex: -2,
|
||||
}}
|
||||
></span>
|
||||
Wrap 1
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={classNames(
|
||||
"rounded-md px-2 py-4 flex items-center justify-center w-full relative gap-1 cursor-pointer outline-offset-2 outline-2 border transition-all bg-white hover:bg-base-100/50 border-base-100",
|
||||
{
|
||||
"border-secondary":
|
||||
captionPreset ===
|
||||
"Wrap 2",
|
||||
"hover:border-base-200":
|
||||
captionPreset !==
|
||||
"Wrap 2",
|
||||
}
|
||||
)}
|
||||
onClick={() =>
|
||||
setCaptionPreset("Wrap 2")
|
||||
}
|
||||
>
|
||||
<span
|
||||
className={
|
||||
styles["stroke-text"]
|
||||
}
|
||||
style={
|
||||
{
|
||||
position: "relative",
|
||||
"--text-content":
|
||||
"Wrap 2",
|
||||
"--text-color":
|
||||
"#ffffff",
|
||||
color: "rgb(255, 255, 255)",
|
||||
fontFamily:
|
||||
"Poppins, sans-serif",
|
||||
fontWeight: "bold",
|
||||
textDecoration: "none",
|
||||
fontStyle: "normal",
|
||||
textTransform:
|
||||
"uppercase",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
data-text="Wrap 2"
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
background:
|
||||
"rgb(121, 212, 246)",
|
||||
position: "absolute",
|
||||
inset: "-0.05em -0.5em",
|
||||
borderRadius: "0.2em",
|
||||
zIndex: -2,
|
||||
}}
|
||||
></span>
|
||||
Wrap 2
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={classNames(
|
||||
"rounded-md px-2 py-4 flex items-center justify-center w-full relative gap-1 cursor-pointer outline-offset-2 outline-2 border transition-all bg-white hover:bg-base-100/50 border-base-100",
|
||||
{
|
||||
"border-secondary":
|
||||
captionPreset ===
|
||||
"Faceless",
|
||||
"hover:border-base-200":
|
||||
captionPreset !==
|
||||
"Faceless",
|
||||
}
|
||||
)}
|
||||
onClick={() =>
|
||||
setCaptionPreset("Faceless")
|
||||
}
|
||||
>
|
||||
<span
|
||||
className={
|
||||
styles["bevel-text-effect"]
|
||||
}
|
||||
style={
|
||||
{
|
||||
position: "relative",
|
||||
"--text-content":
|
||||
"Faceless",
|
||||
"--text-color":
|
||||
"#D8D7D7",
|
||||
color: "rgb(216, 215, 215)",
|
||||
fontFamily:
|
||||
"Lexend, sans-serif",
|
||||
fontWeight: "bold",
|
||||
textDecoration: "none",
|
||||
fontStyle: "normal",
|
||||
textTransform:
|
||||
"uppercase",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
Faceless
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full mt-8 mb-3 flex items-center justify-between">
|
||||
<div className="text-sm font-base text-gray-500 inline-flex gap-2 items-center">
|
||||
{t("alignment")}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-2 w-full">
|
||||
<div
|
||||
onClick={() =>
|
||||
setCaptionPosition("top")
|
||||
}
|
||||
className={classNames(
|
||||
"flex items-center justify-center p-2 border border-base-200 text-xs transition-all bg-white hover:bg-white rounded-lg cursor-pointer gap-1 normal-case text-black/50 hover:text-black",
|
||||
{
|
||||
"border-secondary hover:border-secondary":
|
||||
captionPosition ===
|
||||
"top",
|
||||
}
|
||||
)}
|
||||
>
|
||||
<PiAlignTopSimple size={20} />
|
||||
{t("top")}
|
||||
</div>
|
||||
<div
|
||||
onClick={() =>
|
||||
setCaptionPosition("middle")
|
||||
}
|
||||
className={classNames(
|
||||
"flex items-center justify-center p-2 border border-base-200 text-xs transition-all bg-white hover:bg-white rounded-lg cursor-pointer gap-1 normal-case text-black/50 hover:text-black",
|
||||
{
|
||||
"border-secondary hover:border-secondary":
|
||||
captionPosition ===
|
||||
"middle",
|
||||
}
|
||||
)}
|
||||
>
|
||||
<PiAlignCenterVerticalSimple
|
||||
size={20}
|
||||
/>
|
||||
{t("middle")}
|
||||
</div>
|
||||
|
||||
<div
|
||||
onClick={() =>
|
||||
setCaptionPosition("bottom")
|
||||
}
|
||||
className={classNames(
|
||||
"flex items-center justify-center p-2 border border-base-200 text-xs transition-all bg-white hover:bg-white rounded-lg cursor-pointer gap-1 normal-case text-black/50 hover:text-black",
|
||||
{
|
||||
"border-secondary hover:border-secondary":
|
||||
captionPosition ===
|
||||
"bottom",
|
||||
}
|
||||
)}
|
||||
>
|
||||
<PiAlignBottomSimple size={20} />
|
||||
{t("bottom")}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</InputBox>
|
||||
<div className="relative flex items-end">
|
||||
<button
|
||||
onClick={create}
|
||||
className="inline-flex gap-1.5 items-center justify-center whitespace-nowrap z-0 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 bg-black border border-black relative w-auto text-white px-4 py-2 rounded-md"
|
||||
>
|
||||
{t("generateVideo")}
|
||||
</button>
|
||||
<div className="flex flex-col">
|
||||
<span className="opacity-60 ml-4 gap-1 text-sm items-center">
|
||||
{t("originalPrice")}{" "}
|
||||
<b className="line-through">50{t("credits")}</b>
|
||||
</span>
|
||||
<span className="ml-4 gap-1 text-sm items-center text-info">
|
||||
{t("currentPrice")} <b>30 {t("credits")}</b>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
3
src/ui/(console)/create/review.tsx
Normal file
3
src/ui/(console)/create/review.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
export default function Review() {
|
||||
return <>网站评论视频</>;
|
||||
}
|
||||
38
src/ui/(console)/create/textVideo/index.module.css
Normal file
38
src/ui/(console)/create/textVideo/index.module.css
Normal file
@@ -0,0 +1,38 @@
|
||||
.stroke-text {
|
||||
font-weight: bold;
|
||||
text-transform: uppercase;
|
||||
color: white;
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.stroke-text:after, .stroke-text:before {
|
||||
content: attr(data-text);
|
||||
position: absolute;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
-webkit-text-stroke: .2em #000;
|
||||
z-index: -1; /* 确保它们位于主文本前 */
|
||||
}
|
||||
.stroke-text:after{
|
||||
left: .05em;
|
||||
top: .05em;
|
||||
}
|
||||
.stroke-text:before{
|
||||
left: 0;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.glow-text-effect{
|
||||
color: var(--text-color, #4ade80);
|
||||
text-shadow: 0 0 .3em rgba(0, 0, 0, .7), 0 0 .05em var(--text-color, #4ade80), 0 0 .1em var(--text-color, #4ade80), 0 0 .15em var(--text-color, #4ade80), 0 0 .2em var(--text-color, #4ade80), 0 0 .25em var(--text-color, #4ade80);
|
||||
}
|
||||
.bevel-text-effect {
|
||||
display: inline-flex;
|
||||
position: relative;
|
||||
color: var(--text-color, #4ade80);
|
||||
-webkit-text-stroke: .025em var(--text-color, #4ade80);
|
||||
-webkit-text-fill-color: transparent;
|
||||
text-shadow: 0 .025em .0375em var(--text-color, #4ade80), 0 0 0 #fff;
|
||||
}
|
||||
474
src/ui/(console)/create/textVideo/index.tsx
Normal file
474
src/ui/(console)/create/textVideo/index.tsx
Normal file
@@ -0,0 +1,474 @@
|
||||
import InputBox from "@/components/InputBox";
|
||||
import { useToast } from "@/contexts/ToastContext";
|
||||
import useFetch from "@/hooks/useFetch";
|
||||
import classNames from "classnames";
|
||||
import { use, useEffect, useState } from "react";
|
||||
import useLoadingStore from "@/store/loadingStore";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Field, Label, Radio, RadioGroup } from "@headlessui/react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import Image from "next/image";
|
||||
|
||||
const models = ["gen2", "gen3"];
|
||||
const ratios = [
|
||||
{
|
||||
label: "16:9",
|
||||
id: 1,
|
||||
value: {
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
},
|
||||
model: ["gen2", "gen3"],
|
||||
},
|
||||
{
|
||||
label: "9:16",
|
||||
id: 2,
|
||||
value: {
|
||||
width: 1080,
|
||||
height: 1920,
|
||||
},
|
||||
model: ["gen2"],
|
||||
|
||||
tooltip: "仅在Gen2模型下支持9:16",
|
||||
},
|
||||
{
|
||||
label: "1:1",
|
||||
id: 3,
|
||||
value: {
|
||||
width: 1080,
|
||||
height: 1080,
|
||||
},
|
||||
model: ["gen2"],
|
||||
tooltip: "仅在Gen2模型下支持1:1",
|
||||
},
|
||||
];
|
||||
const times = [
|
||||
{
|
||||
label: "4s",
|
||||
id: 1,
|
||||
value: 4,
|
||||
model: "gen2",
|
||||
tooltip: "仅在Gen2模型下支持4s",
|
||||
},
|
||||
{
|
||||
label: "5s",
|
||||
id: 2,
|
||||
value: 5,
|
||||
model: "gen3",
|
||||
tooltip: "仅在Gen3模型下支持5s",
|
||||
},
|
||||
{
|
||||
label: "10s",
|
||||
id: 3,
|
||||
value: 10,
|
||||
model: "gen3",
|
||||
tooltip: "仅在Gen3模型下支持10s",
|
||||
},
|
||||
];
|
||||
|
||||
const styles = [
|
||||
{
|
||||
name: "",
|
||||
image: "/images/default.jpg",
|
||||
label: "默认",
|
||||
},
|
||||
{
|
||||
name: "abandoned",
|
||||
image: "/images/abandoned.jpg",
|
||||
label: "废弃",
|
||||
},
|
||||
{
|
||||
name: "abstract_sculpture",
|
||||
image: "/images/abstract_sculpture.jpg",
|
||||
label: "抽象",
|
||||
},
|
||||
{
|
||||
name: "advertising",
|
||||
image: "/images/advertising.jpg",
|
||||
label: "广告",
|
||||
},
|
||||
{
|
||||
name: "anime",
|
||||
image: "/images/anime.jpg",
|
||||
label: "动漫",
|
||||
},
|
||||
{
|
||||
name: "cine_lens",
|
||||
image: "/images/cine_lens.jpg",
|
||||
label: "电影镜头",
|
||||
},
|
||||
{
|
||||
name: "cinematic",
|
||||
image: "/images/cinematic.jpg",
|
||||
label: "电影",
|
||||
},
|
||||
{
|
||||
name: "concept_art",
|
||||
image: "/images/concept_art.jpg",
|
||||
label: "艺术",
|
||||
},
|
||||
{
|
||||
name: "forestpunk",
|
||||
image: "/images/forestpunk.jpg",
|
||||
label: "赛博朋克",
|
||||
},
|
||||
{
|
||||
name: "frost",
|
||||
image: "/images/frost.jpg",
|
||||
label: "雪",
|
||||
},
|
||||
{
|
||||
name: "graphite",
|
||||
image: "/images/graphite.jpg",
|
||||
label: "石墨",
|
||||
},
|
||||
{
|
||||
name: "macro_photography",
|
||||
image: "/images/macro_photography.jpg",
|
||||
label: "宏观",
|
||||
},
|
||||
{
|
||||
name: "pixel_art",
|
||||
image: "/images/pixel_art.jpg",
|
||||
label: "像素艺术",
|
||||
},
|
||||
{
|
||||
name: "retro_photography",
|
||||
image: "/images/retro_photography.jpg",
|
||||
label: "复古",
|
||||
},
|
||||
{
|
||||
name: "sci_fi_art",
|
||||
image: "/images/sci_fi_art.jpg",
|
||||
label: "科幻",
|
||||
},
|
||||
{
|
||||
name: "thriller",
|
||||
image: "/images/thriller.jpg",
|
||||
label: "惊悚",
|
||||
},
|
||||
{
|
||||
name: "35mm",
|
||||
image: "/images/35mm.jpg",
|
||||
label: "35mm",
|
||||
},
|
||||
// {
|
||||
// name: "vector",
|
||||
// image: "https://www.typeframes.com/_next/image?url=%2Fpresets%2FLEONARDO.webp&w=256&q=75",
|
||||
// label: "矢量",
|
||||
// },
|
||||
// {
|
||||
// name: "watercolor",
|
||||
// image: "https://www.typeframes.com/_next/image?url=%2Fpresets%2FLEONARDO.webp&w=256&q=75",
|
||||
// label: "水彩",
|
||||
// },
|
||||
];
|
||||
|
||||
export default function TextVideo() {
|
||||
const router = useRouter();
|
||||
const t = useTranslations("tiktok");
|
||||
const s = useTranslations("style");
|
||||
const { addToast, addToastSuccess, addToastError } = useToast();
|
||||
const [rate, setRate] = useState(5);
|
||||
let [model, setModel] = useState(models[0]);
|
||||
const [selectedStyle, setSelectedStyle] = useState(""); // 默认风格为空
|
||||
|
||||
const showLoading = useLoadingStore((state) => state.showLoading);
|
||||
const hideLoading = useLoadingStore((state) => state.hideLoading);
|
||||
|
||||
const {
|
||||
fetchData: createFetch,
|
||||
loading: createLoading,
|
||||
data: createResult,
|
||||
} = useFetch({
|
||||
url: "/api/text-to-video/",
|
||||
method: "POST",
|
||||
});
|
||||
const create = () => {
|
||||
const requestData = {
|
||||
text_prompt: text,
|
||||
model: model,
|
||||
time: time.value,
|
||||
style: selectedStyle,
|
||||
motion: rate,
|
||||
};
|
||||
if (text === "") {
|
||||
addToastError(t("enterVideoText"));
|
||||
return;
|
||||
}
|
||||
if (model === "gen2") {
|
||||
requestData["width"] = ratio.value.width;
|
||||
requestData["height"] = ratio.value.height;
|
||||
}
|
||||
console.log("requestData", requestData);
|
||||
showLoading("creating...");
|
||||
createFetch(requestData)
|
||||
.then((res) => {
|
||||
addToastSuccess(t("createSuccess"));
|
||||
router.push("/projects");
|
||||
})
|
||||
.finally(() => {
|
||||
hideLoading();
|
||||
});
|
||||
};
|
||||
|
||||
const [ratio, setRatio] = useState(ratios[0]);
|
||||
const [time, setTime] = useState(times[0]);
|
||||
const [text, setText] = useState("");
|
||||
|
||||
const modelChange = (e) => {
|
||||
setModel(e);
|
||||
if (!ratio.model.includes(e)) {
|
||||
for (let i of ratios) {
|
||||
if (i.model.includes(e)) {
|
||||
setRatio(i);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!time.model !== e) {
|
||||
for (let i of times) {
|
||||
if (i.model === e) {
|
||||
setTime(i);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<p className="text-info text-sm">{t("useTool")}</p>
|
||||
<InputBox serial={1} title={t("creativeDescription")}>
|
||||
<textarea
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
className="min-h-36 textarea text-neutral placeholder:text-gray-300 font-normal outline-base-200 focus:outline-offset-[1px] outline outline-[1.5px] focus:outline-[1.5px] focus:base-200 rounded-lg bg-transparent overflow-hidden text-base resize-none p-4"
|
||||
placeholder={t("text-to-video")}
|
||||
></textarea>
|
||||
<small className="whitespace-pre-line text-info">
|
||||
{t("videoTextTip")}
|
||||
</small>
|
||||
</InputBox>
|
||||
<InputBox serial={2} title={t("videoStyle")}>
|
||||
<div className="w-full p-2">
|
||||
<div className="flex space-x-4 overflow-x-scroll snap-x snap-mandatory scrollbar-custom">
|
||||
{styles.map((style, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="snap-start flex-shrink-0 cursor-pointer"
|
||||
onClick={() => setSelectedStyle(style.name)} // 选择风格
|
||||
>
|
||||
<div className="text-sm mb-1 ml-2 text-info">
|
||||
{s(style.name)}
|
||||
</div>
|
||||
<Image
|
||||
src={style.image}
|
||||
alt={style.name}
|
||||
width={128}
|
||||
height={160}
|
||||
className={classNames(
|
||||
"w-32 h-32 mb-2 border-4 object-cover rounded-lg hover:shadow-md",
|
||||
{
|
||||
"border-secondary":
|
||||
selectedStyle === style.name,
|
||||
}
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</InputBox>
|
||||
<InputBox serial={3} title={t("pickParameters")}>
|
||||
<div className="flex flex-col gap-8">
|
||||
<div className="flex gap-2 flex-col md:flex-row md:items-center">
|
||||
<div className="min-w-[160px] flex flex-col ml-2">
|
||||
<div className="text-gray-700 text-md">
|
||||
{t("selectionModel")}
|
||||
</div>
|
||||
{/* <div className="text-info text-xs">
|
||||
{t("dynamicOrVideo")}
|
||||
</div> */}
|
||||
</div>
|
||||
<div className="">
|
||||
<RadioGroup
|
||||
value={model}
|
||||
onChange={modelChange}
|
||||
aria-label="Server size"
|
||||
className="flex items-center gap-4"
|
||||
>
|
||||
{models.map((model) => (
|
||||
<Field key={model}>
|
||||
<Radio
|
||||
value={model}
|
||||
className="group flex items-center gap-2 bg-base-100 rounded-xl py-2 px-4 cursor-pointer border-2 border-base-200/50 data-[checked]:border-secondary data-[checked]:bg-secondary/5"
|
||||
>
|
||||
<div className="flex size-5 items-center justify-center rounded-full border bg-white group-data-[checked]:bg-secondary">
|
||||
<span className="invisible size-2 rounded-full bg-white group-data-[checked]:visible" />
|
||||
</div>
|
||||
<Label className="cursor-pointer">
|
||||
{model}
|
||||
</Label>
|
||||
</Radio>
|
||||
</Field>
|
||||
))}
|
||||
</RadioGroup>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 flex-col md:flex-row md:items-center">
|
||||
<div className="min-w-[160px] flex flex-col ml-2">
|
||||
<div className="text-gray-700 text-md">
|
||||
{t("chooseScreenRatio")}
|
||||
</div>
|
||||
<div className="text-info text-xs">
|
||||
{t("adaptScreenRatio")}
|
||||
</div>
|
||||
</div>
|
||||
<div className="">
|
||||
<RadioGroup
|
||||
value={ratio}
|
||||
onChange={setRatio}
|
||||
aria-label="Video aspect ratio"
|
||||
>
|
||||
<div className="flex space-x-10">
|
||||
{ratios.map((ratio) => (
|
||||
<Field
|
||||
key={ratio.id}
|
||||
className="group relative flex flex-col items-center justify-end gap-2"
|
||||
disabled={
|
||||
!ratio.model.includes(model)
|
||||
}
|
||||
>
|
||||
<Radio
|
||||
value={ratio}
|
||||
className="group cursor-pointer flex flex-1 items-center"
|
||||
>
|
||||
<div
|
||||
className={`rounded-md bg-white/0 border-4 border-base-300 ${
|
||||
ratio.label === "16:9"
|
||||
? "w-16 h-9"
|
||||
: ratio.label ===
|
||||
"9:16"
|
||||
? "w-9 h-16"
|
||||
: "w-12 h-12"
|
||||
} group-data-[checked]:border-secondary`}
|
||||
></div>
|
||||
</Radio>
|
||||
<Label className="text-sm cursor-pointer">
|
||||
{ratio.label}
|
||||
</Label>
|
||||
{!ratio.model.includes(model) && (
|
||||
<div className="w-max absolute left-1/2 transform -translate-x-1/2 bottom-full mb-2 bg-gray-700 text-white text-xs rounded py-1 px-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
{ratio.tooltip}
|
||||
<div className="absolute left-1/2 transform -translate-x-1/2 -translate-y-1/2 top-full w-2 h-2 bg-gray-700 rotate-45"></div>
|
||||
</div>
|
||||
)}
|
||||
</Field>
|
||||
))}
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 flex-col md:flex-row md:items-center">
|
||||
<div className="min-w-[160px] flex flex-col ml-2 ">
|
||||
<div className="text-gray-700 text-md">
|
||||
{t("creativeImagination")}
|
||||
</div>
|
||||
<div className="text-info text-xs">
|
||||
{t("creativityProgressBar")}
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full max-w-[400px]">
|
||||
<div className="w-full">
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={10}
|
||||
value={rate}
|
||||
className="range range-secondary range-sm"
|
||||
step={1}
|
||||
onChange={(e) =>
|
||||
setRate(Number(e.target.value))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex w-full justify-between px-2 text-xs">
|
||||
<span>|</span>
|
||||
<span>|</span>
|
||||
<span>|</span>
|
||||
<span>|</span>
|
||||
<span>|</span>
|
||||
<span>|</span>
|
||||
<span>|</span>
|
||||
<span>|</span>
|
||||
<span>|</span>
|
||||
<span>|</span>
|
||||
<span>|</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 flex-col md:flex-row md:items-center">
|
||||
<div className="min-w-[160px] flex flex-col ml-2">
|
||||
<div className="text-gray-700 text-md">
|
||||
{t("selectVideoDuration")}
|
||||
</div>
|
||||
{/* <div className="text-info text-xs">
|
||||
选择视频时长
|
||||
</div> */}
|
||||
</div>
|
||||
<div className="">
|
||||
<RadioGroup
|
||||
value={time}
|
||||
onChange={setTime}
|
||||
aria-label="Video aspect ratio"
|
||||
>
|
||||
<div className="flex space-x-10">
|
||||
{times.map((time) => (
|
||||
<Field
|
||||
key={time.id}
|
||||
className="relative flex items-center gap-2 group"
|
||||
disabled={time.model != model}
|
||||
>
|
||||
<Radio
|
||||
value={time}
|
||||
className="group flex size-5 items-center justify-center rounded-full border cursor-pointer bg-white data-[checked]:bg-secondary data-[disabled]:bg-base-200"
|
||||
>
|
||||
<span className="invisible size-2 rounded-full bg-white group-data-[checked]:visible" />
|
||||
</Radio>
|
||||
<Label className="cursor-pointer">
|
||||
{time.label}
|
||||
</Label>
|
||||
{time.model !== model && (
|
||||
<div className="w-max absolute left-1/2 transform -translate-x-1/2 bottom-full mb-2 bg-gray-700 text-white text-xs rounded py-1 px-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
{time.tooltip}
|
||||
<div className="absolute left-1/2 transform -translate-x-1/2 -translate-y-1/2 top-full w-2 h-2 bg-gray-700 rotate-45"></div>
|
||||
</div>
|
||||
)}
|
||||
</Field>
|
||||
))}
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</InputBox>
|
||||
<div className="relative flex items-end">
|
||||
<button
|
||||
onClick={create}
|
||||
className="inline-flex gap-1.5 items-center justify-center whitespace-nowrap z-0 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 bg-black border border-black relative w-auto text-white px-4 py-2 rounded-md"
|
||||
>
|
||||
{t("generateVideo")}
|
||||
</button>
|
||||
<div className="flex flex-col">
|
||||
<span className="opacity-60 ml-4 gap-1 text-sm items-center">
|
||||
{t("originalPrice")}{" "}
|
||||
<b className="line-through">20{t("credits")}</b>
|
||||
</span>
|
||||
<span className="ml-4 gap-1 text-sm items-center text-info">
|
||||
{t("currentPrice")} <b>10 {t("credits")}</b>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
38
src/ui/(console)/create/tiktok/index.module.css
Normal file
38
src/ui/(console)/create/tiktok/index.module.css
Normal file
@@ -0,0 +1,38 @@
|
||||
.stroke-text {
|
||||
font-weight: bold;
|
||||
text-transform: uppercase;
|
||||
color: white;
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.stroke-text:after, .stroke-text:before {
|
||||
content: attr(data-text);
|
||||
position: absolute;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
-webkit-text-stroke: .2em #000;
|
||||
z-index: -1; /* 确保它们位于主文本前 */
|
||||
}
|
||||
.stroke-text:after{
|
||||
left: .05em;
|
||||
top: .05em;
|
||||
}
|
||||
.stroke-text:before{
|
||||
left: 0;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.glow-text-effect{
|
||||
color: var(--text-color, #4ade80);
|
||||
text-shadow: 0 0 .3em rgba(0, 0, 0, .7), 0 0 .05em var(--text-color, #4ade80), 0 0 .1em var(--text-color, #4ade80), 0 0 .15em var(--text-color, #4ade80), 0 0 .2em var(--text-color, #4ade80), 0 0 .25em var(--text-color, #4ade80);
|
||||
}
|
||||
.bevel-text-effect {
|
||||
display: inline-flex;
|
||||
position: relative;
|
||||
color: var(--text-color, #4ade80);
|
||||
-webkit-text-stroke: .025em var(--text-color, #4ade80);
|
||||
-webkit-text-fill-color: transparent;
|
||||
text-shadow: 0 .025em .0375em var(--text-color, #4ade80), 0 0 0 #fff;
|
||||
}
|
||||
640
src/ui/(console)/create/tiktok/index.tsx
Normal file
640
src/ui/(console)/create/tiktok/index.tsx
Normal file
@@ -0,0 +1,640 @@
|
||||
import InputBox from "@/components/InputBox";
|
||||
import ListBox from "@/components/ListBox";
|
||||
import { useToast } from "@/contexts/ToastContext";
|
||||
import useFetch from "@/hooks/useFetch";
|
||||
import classNames from "classnames";
|
||||
import { useState } from "react";
|
||||
import VideoSelect, { VideoData } from "../components/video";
|
||||
import useLoadingStore from "@/store/loadingStore";
|
||||
import { useRouter } from "next/navigation";
|
||||
import styles from "./index.module.css";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Switch } from "@headlessui/react";
|
||||
import {
|
||||
PiAlignTopSimple,
|
||||
PiAlignCenterVerticalSimple,
|
||||
PiAlignBottomSimple,
|
||||
} from "react-icons/pi";
|
||||
|
||||
export default function Tiktok() {
|
||||
const t = useTranslations("tiktok");
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const { addToast, addToastSuccess, addToastError } = useToast();
|
||||
const [rate, setRate] = useState(0.1);
|
||||
const [enabledCaptions, setEnabledCaptions] = useState(false);
|
||||
const ratioList = [
|
||||
{
|
||||
name: "16 / 9",
|
||||
id: 1,
|
||||
value: "16 / 9",
|
||||
},
|
||||
{
|
||||
name: "1 / 1",
|
||||
id: 2,
|
||||
value: "1 / 1",
|
||||
},
|
||||
{
|
||||
name: "9 / 16",
|
||||
id: 3,
|
||||
value: "9 / 16",
|
||||
},
|
||||
];
|
||||
const showLoading = useLoadingStore((state) => state.showLoading);
|
||||
const hideLoading = useLoadingStore((state) => state.hideLoading);
|
||||
|
||||
const {
|
||||
fetchData: createFetch,
|
||||
loading: createLoading,
|
||||
data: createResult,
|
||||
} = useFetch({
|
||||
url: "/api/create-tiktok-video/",
|
||||
method: "POST",
|
||||
});
|
||||
const create = () => {
|
||||
const requestData = {
|
||||
text: text,
|
||||
voice: videoData.videoSelect,
|
||||
style: videoData.style,
|
||||
ratio: ratio.value,
|
||||
rate: rate,
|
||||
mediaType: mediaType.value,
|
||||
slug: "create-tiktok-video",
|
||||
disableCaptions: !enabledCaptions,
|
||||
};
|
||||
if (enabledCaptions) {
|
||||
requestData["captionPreset"] = captionPreset;
|
||||
requestData["captionPosition"] = captionPosition;
|
||||
}
|
||||
console.log(requestData);
|
||||
if (text === "") {
|
||||
addToastError(t("enterVideoText"));
|
||||
return;
|
||||
}
|
||||
console.log("requestData", requestData);
|
||||
showLoading("creating...");
|
||||
createFetch(requestData)
|
||||
.then((res) => {
|
||||
console.log("create result:", res);
|
||||
if (res.success === 1) {
|
||||
addToastSuccess(t("createSuccess"));
|
||||
router.push("/projects");
|
||||
} else {
|
||||
addToastError(t("createFailed"));
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
hideLoading();
|
||||
});
|
||||
};
|
||||
|
||||
const [ratio, setRatio] = useState(ratioList[0]);
|
||||
|
||||
const [captionPreset, setCaptionPreset] = useState("Wrap 1");
|
||||
|
||||
const [captionPosition, setCaptionPosition] = useState("bottom");
|
||||
const mediaTypeList = [
|
||||
{ id: 1, name: t("stockVideo"), value: "stockVideo" },
|
||||
{ id: 2, name: t("movingImage"), value: "movingImage" },
|
||||
];
|
||||
|
||||
const [mediaType, setMediaType] = useState(mediaTypeList[1]);
|
||||
|
||||
const [videoData, setVideoData] = useState<VideoData>({
|
||||
language: "", // 国家语种
|
||||
videoSelect: "", // 讲话人
|
||||
style: "", // 讲话风格
|
||||
});
|
||||
const handleVideoDataChange = (newData: VideoData) => {
|
||||
setVideoData(newData);
|
||||
};
|
||||
|
||||
const [text, setText] = useState("");
|
||||
|
||||
return (
|
||||
<>
|
||||
<p className="text-info text-sm">{t("useTool")}</p>
|
||||
<InputBox serial={1} title={t("yourVideoText")}>
|
||||
<textarea
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
className="min-h-36 textarea text-neutral placeholder:text-gray-300 font-normal outline-base-200 focus:outline-offset-[1px] outline outline-[1.5px] focus:outline-[1.5px] focus:base-200 rounded-lg bg-transparent overflow-hidden text-base resize-none p-4"
|
||||
placeholder={t("create-tiktok-video")}
|
||||
></textarea>
|
||||
<small className="whitespace-pre-line text-info">
|
||||
{t("videoTextTip")}
|
||||
</small>
|
||||
</InputBox>
|
||||
<InputBox serial={2} title={t("selectVoice")}>
|
||||
{/* <small className="text-info text-xs">
|
||||
{t("voicePunctuationTip")}
|
||||
</small> */}
|
||||
<VideoSelect onChange={handleVideoDataChange} />
|
||||
</InputBox>
|
||||
<InputBox serial={3} title={t("pickParameters")}>
|
||||
<div className="flex gap-2 flex-col md:flex-row md:items-center">
|
||||
<div className="min-w-[200px]">
|
||||
<div className="relative group">
|
||||
<ListBox
|
||||
list={mediaTypeList}
|
||||
value={mediaType}
|
||||
onChange={setMediaType}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col ml-2">
|
||||
<div className="text-gray-700 text-md">
|
||||
{t("selectFootageType")}
|
||||
</div>
|
||||
<div className="text-info text-xs">
|
||||
{t("dynamicOrVideo")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 flex-col md:flex-row md:items-center">
|
||||
<div className="min-w-[200px]">
|
||||
<div className="relative group">
|
||||
<ListBox
|
||||
list={ratioList}
|
||||
value={ratio}
|
||||
onChange={setRatio}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col ml-2">
|
||||
<div className="text-gray-700 text-md">
|
||||
{t("chooseScreenRatio")}
|
||||
</div>
|
||||
<div className="text-info text-xs">
|
||||
{t("adaptScreenRatio")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 flex-col md:flex-row md:items-center">
|
||||
<div className="w-full max-w-[400px]">
|
||||
<div className="w-full">
|
||||
<input
|
||||
type="range"
|
||||
min={0.1}
|
||||
max={1}
|
||||
value={rate}
|
||||
className="range range-secondary range-sm"
|
||||
step={0.1}
|
||||
onChange={(e) =>
|
||||
setRate(Number(e.target.value))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex w-full justify-between px-2 text-xs">
|
||||
<span>|</span>
|
||||
<span>|</span>
|
||||
<span>|</span>
|
||||
<span>|</span>
|
||||
<span>|</span>
|
||||
<span>|</span>
|
||||
<span>|</span>
|
||||
<span>|</span>
|
||||
<span>|</span>
|
||||
<span>|</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col ml-2">
|
||||
<div className="text-gray-700 text-md">
|
||||
{t("chooseSpeakerSpeek")}
|
||||
</div>
|
||||
<div className="text-info text-xs">
|
||||
{t("chooseSpeakerSpeekIntro")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</InputBox>
|
||||
<InputBox serial={4} title={t("chooseGenerationPreset")}>
|
||||
<div className="flex flex-col w-full h-full relative">
|
||||
<div className="overflow-auto px-12 pb-6 pt-6 h-full no-scrollbar">
|
||||
<div className="flex flex-col w-full">
|
||||
<div className="flex items-center mb-4">
|
||||
<Switch
|
||||
checked={enabledCaptions}
|
||||
onChange={setEnabledCaptions}
|
||||
className="mr-2 group inline-flex h-6 w-11 items-center rounded-full bg-base-200 transition data-[checked]:bg-secondary"
|
||||
>
|
||||
<span className="size-4 translate-x-1 rounded-full bg-white transition group-data-[checked]:translate-x-6" />
|
||||
</Switch>
|
||||
<div className="text-gray-500">
|
||||
{t("enabledCaptions")}
|
||||
</div>
|
||||
</div>
|
||||
{enabledCaptions && (
|
||||
<>
|
||||
<div className="w-full mb-3 flex items-center justify-between">
|
||||
<div className="text-sm font-base text-gray-500 inline-flex gap-2 items-center">
|
||||
{t("selectPreset")}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-2 w-full">
|
||||
<div
|
||||
className={classNames(
|
||||
"rounded-md px-2 py-4 flex items-center justify-center w-full relative gap-1 cursor-pointer outline-offset-2 outline-2 border transition-all bg-white hover:bg-base-100/50 border-base-100",
|
||||
{
|
||||
"border-secondary":
|
||||
captionPreset ===
|
||||
"Basic",
|
||||
"hover:border-base-200":
|
||||
captionPreset !==
|
||||
"Basic",
|
||||
}
|
||||
)}
|
||||
onClick={() =>
|
||||
setCaptionPreset("Basic")
|
||||
}
|
||||
>
|
||||
<span
|
||||
className="heavy-shadow-text-effect"
|
||||
style={
|
||||
{
|
||||
position: "relative",
|
||||
"--text-content":
|
||||
"Basic",
|
||||
"--text-color":
|
||||
"#FFFFFF",
|
||||
color: "rgb(255, 255, 255)",
|
||||
fontFamily:
|
||||
"Montserrat, sans-serif",
|
||||
fontWeight: "bold",
|
||||
textDecoration: "none",
|
||||
fontStyle: "normal",
|
||||
textTransform:
|
||||
"uppercase",
|
||||
textShadow: `
|
||||
.1em .1em .1em #000,
|
||||
.1em -.1em .1em #000,
|
||||
-.1em .1em .1em #000,
|
||||
-.1em -.1em .1em #000,
|
||||
.1em .1em .2em #000,
|
||||
.1em -.1em .2em #000,
|
||||
-.1em .1em .2em #000,
|
||||
-.1em -.1em .2em #000,
|
||||
0 0 .1em #000,
|
||||
0 0 .2em #000,
|
||||
0 0 .3em #000,
|
||||
0 0 .4em #000,
|
||||
0 0 .5em #000,
|
||||
0 0 .6em #000
|
||||
`,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
Basic
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={classNames(
|
||||
"rounded-md px-2 py-4 flex items-center justify-center w-full relative gap-1 cursor-pointer outline-offset-2 outline-2 border transition-all bg-white hover:bg-base-100/50 border-base-100",
|
||||
{
|
||||
"border-secondary":
|
||||
captionPreset ===
|
||||
"REVID",
|
||||
"hover:border-base-200":
|
||||
captionPreset !==
|
||||
"REVID",
|
||||
}
|
||||
)}
|
||||
onClick={() =>
|
||||
setCaptionPreset("REVID")
|
||||
}
|
||||
>
|
||||
<span
|
||||
className={
|
||||
styles["stroke-text"]
|
||||
}
|
||||
style={{
|
||||
fontFamily:
|
||||
"Poppins, sans-serif",
|
||||
}}
|
||||
data-text="REVID"
|
||||
>
|
||||
REVID
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={classNames(
|
||||
"rounded-md px-2 py-4 flex items-center justify-center w-full relative gap-1 cursor-pointer outline-offset-2 outline-2 border transition-all bg-white hover:bg-base-100/50 border-base-100",
|
||||
{
|
||||
"border-secondary":
|
||||
captionPreset ===
|
||||
"Hormozi",
|
||||
"hover:border-base-200":
|
||||
captionPreset !==
|
||||
"Hormozi",
|
||||
}
|
||||
)}
|
||||
onClick={() =>
|
||||
setCaptionPreset("Hormozi")
|
||||
}
|
||||
>
|
||||
<span
|
||||
className={
|
||||
styles["glow-text-effect"]
|
||||
}
|
||||
style={
|
||||
{
|
||||
position: "relative",
|
||||
"--text-content":
|
||||
"Hormozi",
|
||||
"--text-effect-color":
|
||||
"#FFED38",
|
||||
"--text-color":
|
||||
"#FFED38",
|
||||
color: "rgb(255, 237, 56)",
|
||||
fontFamily:
|
||||
"Anton, sans-serif",
|
||||
fontWeight: "bold",
|
||||
textDecoration: "none",
|
||||
fontStyle: "normal",
|
||||
textTransform:
|
||||
"uppercase",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
Hormozi
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={classNames(
|
||||
"rounded-md px-2 py-4 flex items-center justify-center w-full relative gap-1 cursor-pointer outline-offset-2 outline-2 border transition-all bg-white hover:bg-base-100/50 border-base-100",
|
||||
{
|
||||
"border-secondary":
|
||||
captionPreset === "Ali",
|
||||
"hover:border-base-200":
|
||||
captionPreset !== "Ali",
|
||||
}
|
||||
)}
|
||||
onClick={() =>
|
||||
setCaptionPreset("Ali")
|
||||
}
|
||||
>
|
||||
<span
|
||||
className="wrap-text-effect"
|
||||
style={
|
||||
{
|
||||
position: "relative",
|
||||
"--text-content": "Ali",
|
||||
"--text-effect-color":
|
||||
"#FFFFFF",
|
||||
"--text-color":
|
||||
"#1C1E1D",
|
||||
color: "rgb(28, 30, 29)",
|
||||
fontFamily:
|
||||
"Poppins, sans-serif",
|
||||
fontWeight: "bold",
|
||||
textDecoration: "none",
|
||||
fontStyle: "normal",
|
||||
textTransform: "none",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
Ali
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={classNames(
|
||||
"rounded-md px-2 py-4 flex items-center justify-center w-full relative gap-1 cursor-pointer outline-offset-2 outline-2 border transition-all bg-white hover:bg-base-100/50 border-base-100",
|
||||
{
|
||||
"border-secondary":
|
||||
captionPreset ===
|
||||
"Wrap 1",
|
||||
"hover:border-base-200":
|
||||
captionPreset !==
|
||||
"Wrap 1",
|
||||
}
|
||||
)}
|
||||
onClick={() =>
|
||||
setCaptionPreset("Wrap 1")
|
||||
}
|
||||
>
|
||||
<span
|
||||
className={
|
||||
styles["stroke-text"]
|
||||
}
|
||||
style={
|
||||
{
|
||||
position: "relative",
|
||||
"--text-content":
|
||||
"Wrap 1",
|
||||
"--text-color":
|
||||
"#f6f6db",
|
||||
color: "rgb(246, 246, 219)",
|
||||
fontFamily:
|
||||
"Lexend, sans-serif",
|
||||
fontWeight: "bold",
|
||||
textDecoration: "none",
|
||||
fontStyle: "normal",
|
||||
textTransform: "none",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
data-text="Wrap 1"
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
background:
|
||||
"rgb(248, 66, 60)",
|
||||
position: "absolute",
|
||||
inset: "-0.2em -0.4em",
|
||||
borderRadius: "0.4em",
|
||||
zIndex: -2,
|
||||
}}
|
||||
></span>
|
||||
Wrap 1
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={classNames(
|
||||
"rounded-md px-2 py-4 flex items-center justify-center w-full relative gap-1 cursor-pointer outline-offset-2 outline-2 border transition-all bg-white hover:bg-base-100/50 border-base-100",
|
||||
{
|
||||
"border-secondary":
|
||||
captionPreset ===
|
||||
"Wrap 2",
|
||||
"hover:border-base-200":
|
||||
captionPreset !==
|
||||
"Wrap 2",
|
||||
}
|
||||
)}
|
||||
onClick={() =>
|
||||
setCaptionPreset("Wrap 2")
|
||||
}
|
||||
>
|
||||
<span
|
||||
className={
|
||||
styles["stroke-text"]
|
||||
}
|
||||
style={
|
||||
{
|
||||
position: "relative",
|
||||
"--text-content":
|
||||
"Wrap 2",
|
||||
"--text-color":
|
||||
"#ffffff",
|
||||
color: "rgb(255, 255, 255)",
|
||||
fontFamily:
|
||||
"Poppins, sans-serif",
|
||||
fontWeight: "bold",
|
||||
textDecoration: "none",
|
||||
fontStyle: "normal",
|
||||
textTransform:
|
||||
"uppercase",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
data-text="Wrap 2"
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
background:
|
||||
"rgb(121, 212, 246)",
|
||||
position: "absolute",
|
||||
inset: "-0.05em -0.5em",
|
||||
borderRadius: "0.2em",
|
||||
zIndex: -2,
|
||||
}}
|
||||
></span>
|
||||
Wrap 2
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={classNames(
|
||||
"rounded-md px-2 py-4 flex items-center justify-center w-full relative gap-1 cursor-pointer outline-offset-2 outline-2 border transition-all bg-white hover:bg-base-100/50 border-base-100",
|
||||
{
|
||||
"border-secondary":
|
||||
captionPreset ===
|
||||
"Faceless",
|
||||
"hover:border-base-200":
|
||||
captionPreset !==
|
||||
"Faceless",
|
||||
}
|
||||
)}
|
||||
onClick={() =>
|
||||
setCaptionPreset("Faceless")
|
||||
}
|
||||
>
|
||||
<span
|
||||
className={
|
||||
styles["bevel-text-effect"]
|
||||
}
|
||||
style={
|
||||
{
|
||||
position: "relative",
|
||||
"--text-content":
|
||||
"Faceless",
|
||||
"--text-color":
|
||||
"#D8D7D7",
|
||||
color: "rgb(216, 215, 215)",
|
||||
fontFamily:
|
||||
"Lexend, sans-serif",
|
||||
fontWeight: "bold",
|
||||
textDecoration: "none",
|
||||
fontStyle: "normal",
|
||||
textTransform:
|
||||
"uppercase",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
Faceless
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full mt-8 mb-3 flex items-center justify-between">
|
||||
<div className="text-sm font-base text-gray-500 inline-flex gap-2 items-center">
|
||||
{t("alignment")}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-2 w-full">
|
||||
<div
|
||||
onClick={() =>
|
||||
setCaptionPosition("top")
|
||||
}
|
||||
className={classNames(
|
||||
"flex items-center justify-center p-2 border border-base-200 text-xs transition-all bg-white hover:bg-white rounded-lg cursor-pointer gap-1 normal-case text-black/50 hover:text-black",
|
||||
{
|
||||
"border-secondary hover:border-secondary":
|
||||
captionPosition ===
|
||||
"top",
|
||||
}
|
||||
)}
|
||||
>
|
||||
<PiAlignTopSimple size={20} />
|
||||
{t("top")}
|
||||
</div>
|
||||
<div
|
||||
onClick={() =>
|
||||
setCaptionPosition("middle")
|
||||
}
|
||||
className={classNames(
|
||||
"flex items-center justify-center p-2 border border-base-200 text-xs transition-all bg-white hover:bg-white rounded-lg cursor-pointer gap-1 normal-case text-black/50 hover:text-black",
|
||||
{
|
||||
"border-secondary hover:border-secondary":
|
||||
captionPosition ===
|
||||
"middle",
|
||||
}
|
||||
)}
|
||||
>
|
||||
<PiAlignCenterVerticalSimple
|
||||
size={20}
|
||||
/>
|
||||
{t("middle")}
|
||||
</div>
|
||||
|
||||
<div
|
||||
onClick={() =>
|
||||
setCaptionPosition("bottom")
|
||||
}
|
||||
className={classNames(
|
||||
"flex items-center justify-center p-2 border border-base-200 text-xs transition-all bg-white hover:bg-white rounded-lg cursor-pointer gap-1 normal-case text-black/50 hover:text-black",
|
||||
{
|
||||
"border-secondary hover:border-secondary":
|
||||
captionPosition ===
|
||||
"bottom",
|
||||
}
|
||||
)}
|
||||
>
|
||||
<PiAlignBottomSimple size={20} />
|
||||
{t("bottom")}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</InputBox>
|
||||
<div className="relative flex items-end">
|
||||
<button
|
||||
onClick={create}
|
||||
className="inline-flex gap-1.5 items-center justify-center whitespace-nowrap z-0 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 bg-black border border-black relative w-auto text-white px-4 py-2 rounded-md"
|
||||
>
|
||||
{t("generateVideo")}
|
||||
</button>
|
||||
<div className="flex flex-col">
|
||||
<span className="opacity-60 ml-4 gap-1 text-sm items-center">
|
||||
{t("originalPrice")}{" "}
|
||||
<b className="line-through">30{t("credits")}</b>
|
||||
</span>
|
||||
<span className="ml-4 gap-1 text-sm items-center text-info">
|
||||
{t("currentPrice")}{" "}
|
||||
<b>
|
||||
20 {t("additionalCredits")} 10{" "}
|
||||
{t("additionalCreditsEnd")}
|
||||
</b>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
116
src/ui/(console)/projects/video-item.tsx
Normal file
116
src/ui/(console)/projects/video-item.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
import Link from "next/link";
|
||||
import { IoEllipsisHorizontal } from "react-icons/io5";
|
||||
import { useTranslations } from "next-intl";
|
||||
import useFetch from "@/hooks/useFetch";
|
||||
import { useEffect } from "react";
|
||||
|
||||
export default function VideoItem({ item }) {
|
||||
const t = useTranslations("videoItem"); // 使用翻译
|
||||
|
||||
const getStatusBadge = (status) => {
|
||||
switch (status) {
|
||||
case "completed":
|
||||
return t("completed");
|
||||
case "failed":
|
||||
return t("failed");
|
||||
case "Pending":
|
||||
case "pending":
|
||||
return t("pending");
|
||||
case "in_progress":
|
||||
return t("inProgress");
|
||||
default:
|
||||
return status;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="group flex flex-col gap-4 items-stretch bg-base-100 rounded-lg cursor-pointer bg-opacity-0 hover:bg-opacity-100 transition-all">
|
||||
<div className="relative w-full overflow-hidden bg-base-200 p-4 aspect-video rounded-xl flex items-center justify-center">
|
||||
<div className="bg-white w-full h-full rounded-xl shadow-lg mx-3 flex flex-col items-center justify-center overflow-hidden">
|
||||
{/* <div className="text-2xl font-bold">
|
||||
{item.media_type}
|
||||
</div>
|
||||
<div className="text-xl font-bold">{item.slug}</div> */}
|
||||
<video
|
||||
src={item.video_url}
|
||||
controls
|
||||
className="w-full h-auto"
|
||||
></video>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex w-full justify-between items-start">
|
||||
<div className="w-full flex flex-col justify-center gap-0.5">
|
||||
<div className="w-full flex justify-between">
|
||||
<div className="flex items-center font-medium overflow-ellipsis whitespace-nowrap truncate">
|
||||
<div
|
||||
className={`badge text-white mr-2 ${
|
||||
item.status === "completed"
|
||||
? "bg-secondary"
|
||||
: item.status === "failed"
|
||||
? "bg-red-500"
|
||||
: item.status === "Pending" ||
|
||||
item.status === "pending" ||
|
||||
item.status === "in_progress"
|
||||
? "bg-yellow-500"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
{getStatusBadge(item.status)}{" "}
|
||||
{/* 状态翻译 */}
|
||||
</div>
|
||||
|
||||
<div className="">{item.text}</div>
|
||||
</div>
|
||||
<div className="max-w-8 w-full ml-4">
|
||||
<div className="text-black dropdown dropdown-end">
|
||||
<label
|
||||
tabIndex={0}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<div className="p-[4px] rounded-[6px] bg-base-200 transition-all opacity-[0.4] bg-opacity-0 group-hover:hover:bg-opacity-[.8] group-hover:hover:opacity-[0.8]">
|
||||
<IoEllipsisHorizontal size={18} />
|
||||
</div>
|
||||
</label>
|
||||
<div
|
||||
tabIndex={0}
|
||||
className="mt-2 !opacity-1 min-w-[150px] box-shadow-tf card compact dropdown-content bg-white rounded-xl z-[1000]"
|
||||
>
|
||||
<ul className="px-2 py-1.5 flex flex-col gap-1 text-sm">
|
||||
{item.audio_url ? (
|
||||
<Link
|
||||
href={item.audio_url}
|
||||
className="py-[5px] h-8 flex whitespace-nowrap items-center gap-2 text-neutral p-2 hover:bg-base-100 rounded-md"
|
||||
>
|
||||
<div className="flex gap-2 items-center cursor-pointer w-full pr-4 transition-opacity opacity-70 hover:opacity-95">
|
||||
{t("downloadingAudio")}{" "}
|
||||
{/* 翻译音频下载 */}
|
||||
</div>
|
||||
</Link>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
{item.video_url ? (
|
||||
<Link
|
||||
href={item.video_url}
|
||||
className="py-[5px] h-8 flex whitespace-nowrap items-center gap-2 text-neutral p-2 hover:bg-base-100 rounded-md"
|
||||
>
|
||||
<div className="flex gap-2 items-center cursor-pointer w-full pr-4 transition-opacity opacity-70 hover:opacity-95">
|
||||
{t("downloadingVideos")}{" "}
|
||||
{/* 翻译视频下载 */}
|
||||
</div>
|
||||
</Link>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
202
src/ui/(console)/side-bar.tsx
Normal file
202
src/ui/(console)/side-bar.tsx
Normal file
@@ -0,0 +1,202 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import React, { useState } from "react";
|
||||
import Image from "next/image";
|
||||
import logoImage from "/public/images/logo2.png";
|
||||
import { FaPlus, FaBars } from "react-icons/fa6";
|
||||
import { MdOutlineHome, MdLogout } from "react-icons/md";
|
||||
import { PiSquaresFourBold } from "react-icons/pi";
|
||||
import { BsLightningChargeFill } from "react-icons/bs";
|
||||
import { useTranslations } from "next-intl";
|
||||
import classNames from "classnames";
|
||||
import useUserStore from "@/store/userStore";
|
||||
import useFetch from "@/hooks/useFetch";
|
||||
import { useToast } from "@/contexts/ToastContext";
|
||||
import useLoadingStore from "@/store/loadingStore";
|
||||
|
||||
type MenuItem = {
|
||||
name: string;
|
||||
href: string;
|
||||
icon?: React.ComponentType;
|
||||
};
|
||||
|
||||
export default function SideBar() {
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const user = useUserStore((state) => state.user);
|
||||
const t = useTranslations("sideBar");
|
||||
const { addToast, addToastSuccess, addToastError } = useToast();
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false); // 控制侧边栏的开关状态
|
||||
const hideLoading = useLoadingStore((state) => state.hideLoading);
|
||||
const toggleSidebar = () => {
|
||||
setSidebarOpen(!sidebarOpen);
|
||||
};
|
||||
|
||||
const menu: { title: string; items: MenuItem[] }[] = [
|
||||
{
|
||||
title: t("creation"),
|
||||
items: [
|
||||
{
|
||||
name: t("videos"),
|
||||
href: "/projects",
|
||||
icon: PiSquaresFourBold,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const {
|
||||
fetchData: logoutFetch,
|
||||
loading: logoutLoading,
|
||||
data: logoutResult,
|
||||
} = useFetch({
|
||||
url: "/api/logout/", // 假设你的退出接口路径为 /api/logout/
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
const logout = () => {
|
||||
|
||||
logoutFetch() // 不需要传递参数
|
||||
.then((res) => {
|
||||
addToastSuccess(t("logoutSuccess"));
|
||||
router.push("/login"); // 退出成功后跳转到登录页面
|
||||
})
|
||||
.finally(() => {
|
||||
hideLoading();
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* 切换侧边栏按钮(仅在小屏幕显示) */}
|
||||
<button
|
||||
className="lg:hidden fixed top-4 left-4 z-30 bg-black text-white p-2 rounded-md"
|
||||
onClick={toggleSidebar}
|
||||
>
|
||||
<FaBars size={20} />
|
||||
</button>
|
||||
|
||||
{/* 侧边栏容器 */}
|
||||
<div
|
||||
className={classNames(
|
||||
"fixed top-0 left-0 h-full bg-white border-r border-base-200 overflow-auto flex flex-col p-6 items-center z-20 transition-transform transform",
|
||||
{
|
||||
"translate-x-0": sidebarOpen, // 打开时显示
|
||||
"-translate-x-full": !sidebarOpen, // 关闭时隐藏
|
||||
"lg:translate-x-0 lg:relative lg:w-60": true, // 在大屏幕上始终显示
|
||||
}
|
||||
)}
|
||||
style={{ width: "240px" }}
|
||||
>
|
||||
{/* 侧边栏内容 */}
|
||||
<div className="w-full flex mt-2 mb-8 min-h-[34px]">
|
||||
<Link className="w-full" href="/">
|
||||
<Image
|
||||
alt="typeframes.ai logo"
|
||||
fetchPriority="high"
|
||||
width={150}
|
||||
height={30}
|
||||
decoding="async"
|
||||
style={{ color: "transparent" }}
|
||||
src={logoImage}
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<Link
|
||||
href="/create"
|
||||
className="inline-flex gap-1.5 items-center justify-center whitespace-nowrap z-0 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 bg-black border border-black relative text-white px-4 py-2 rounded-md w-full"
|
||||
>
|
||||
<span className="btn-icon">
|
||||
<FaPlus size="1em" />
|
||||
</span>
|
||||
{t("createNewVideo")}
|
||||
</Link>
|
||||
|
||||
<div className="mt-4 flex flex-col w-full gap-4 items-start justify-between h-full">
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
{menu.map((menuGroup) => {
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col gap-2"
|
||||
key={menuGroup.title}
|
||||
>
|
||||
<div className="mt-4 mb-1 text-[10px] text-info tracking-wider">
|
||||
{menuGroup.title}
|
||||
</div>
|
||||
{menuGroup.items.map((item) => {
|
||||
return (
|
||||
<div key={item.name}>
|
||||
<Link
|
||||
className={classNames(
|
||||
"gap-2 cursor-pointer text-sm font-medium flex justify-start items-center p-2 hover:bg-base-100 rounded-md",
|
||||
{
|
||||
"bg-base-100/50":
|
||||
pathname ===
|
||||
item.href,
|
||||
"text-info":
|
||||
pathname !==
|
||||
item.href,
|
||||
}
|
||||
)}
|
||||
href={item.href}
|
||||
>
|
||||
<MdOutlineHome size={18} />
|
||||
{item.name}
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col w-full items-start">
|
||||
<div className="relative flex items-center text-sm w-fit mb-4">
|
||||
<div className="z-[0]">
|
||||
<Link
|
||||
className="btn-tf btn-tf-sm btn-tf-lock-price bg-black text-white cursor-pointer flex items-center gap-2.5"
|
||||
href="/pricing"
|
||||
>
|
||||
<BsLightningChargeFill size={16} />
|
||||
{t("upgradeNow")}
|
||||
</Link>
|
||||
<div className="absolute z-[-1] -inset-0.5 bg-gradient-to-r from-sky-400 to-fuchsia-400 rounded-full blur opacity-75 group-hover:opacity-100 transition duration-1000 group-hover:duration-200 animate-tilt"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 flex justify-between items-center w-full text-info bg-gray-50 border border-base-100 rounded-lg px-2.5 py-1.5">
|
||||
<div className="flex flex-col justify-center">
|
||||
<div className="text-sm text-gray-600 font-medium overflow-hidden text-ellipsis max-w-[142px]">
|
||||
{user?.username
|
||||
? user.username
|
||||
: user?.email}
|
||||
</div>
|
||||
<div className="relative flex items-center text-xs cursor-pointer">
|
||||
{t("aiCredits")}: {user?.points}
|
||||
</div>
|
||||
</div>
|
||||
<div className="cursor-pointer" onClick={logout}>
|
||||
<MdLogout size={16} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 点击空白处关闭侧边栏 */}
|
||||
{sidebarOpen && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black bg-opacity-50 z-10 lg:hidden"
|
||||
onClick={toggleSidebar}
|
||||
></div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
33
src/ui/login/google-login.tsx
Normal file
33
src/ui/login/google-login.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
"use client";
|
||||
import useFetch from "@/hooks/useFetch";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useState } from "react";
|
||||
export default function GoogleLogin() {
|
||||
const t = useTranslations("loginForm");
|
||||
|
||||
const { fetchData: fetchGoogleLogin, loading: loading } = useFetch({
|
||||
url: "/oauth2callback/google/login",
|
||||
method: "POST",
|
||||
});
|
||||
const googleLogin = () => {
|
||||
fetchGoogleLogin();
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={() => googleLogin()}
|
||||
className="relative justify-center whitespace-nowrap z-0 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 px-4 py-2 rounded-md bg-gray-800 text-gray-300 hover:text-white border-gray-600 border hover:bg-gray-700 w-full flex items-center gap-3 btn-pop"
|
||||
>
|
||||
{/* <Image
|
||||
src="https://www.typeframes.com/images/google.webp"
|
||||
className="w-4"
|
||||
alt="google login icon"
|
||||
/> */}
|
||||
{loading ? (
|
||||
<span className="loading loading-spinner loading-md"></span>
|
||||
) : (
|
||||
<span>{t("continueWithGoogle")}</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
1
src/ui/login/js/GridArrayBg.module.js
Normal file
1
src/ui/login/js/GridArrayBg.module.js
Normal file
File diff suppressed because one or more lines are too long
286
src/ui/login/login-form.tsx
Normal file
286
src/ui/login/login-form.tsx
Normal file
@@ -0,0 +1,286 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useToast } from "@/contexts/ToastContext";
|
||||
import * as Form from "@radix-ui/react-form";
|
||||
import useFetch from "@/hooks/useFetch";
|
||||
import GoogleLogin from "./google-login";
|
||||
import { RadioGroup, Radio } from "@headlessui/react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import useUserStore from "@/store/userStore";
|
||||
import { useTranslations } from "next-intl";
|
||||
import Link from "next/link";
|
||||
|
||||
interface LoginFormProps {
|
||||
toRegister: () => void;
|
||||
}
|
||||
|
||||
type LoginMethod = "password" | "code";
|
||||
|
||||
export default function LoginForm({ toRegister }: LoginFormProps) {
|
||||
const t = useTranslations("loginForm");
|
||||
const tTerms = useTranslations("terms"); // 用于获取用户协议的标题
|
||||
const tGlobal = useTranslations(); // 用于获取全局翻译,如 "agreeTo" 和 "agreeToTerms"
|
||||
const [loginMethod, setLoginMethod] = useState<LoginMethod>("password");
|
||||
const [username, setUsername] = useState("");
|
||||
const [phone, setPhone] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [captcha, setCaptcha] = useState("");
|
||||
const [captchaTimer, setCaptchaTimer] = useState(0);
|
||||
const [agreed, setAgreed] = useState(true); // 默认勾选
|
||||
|
||||
const router = useRouter();
|
||||
const { addToast } = useToast();
|
||||
|
||||
const setUser = useUserStore((state) => state.setUser);
|
||||
|
||||
const {
|
||||
fetchData: login,
|
||||
loading: loginLoading,
|
||||
error: loginError,
|
||||
data: loginData,
|
||||
} = useFetch({
|
||||
url: "/api/login/",
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
const {
|
||||
fetchData: fetchCaptcha,
|
||||
loading: captchaLoading,
|
||||
error: captchaError,
|
||||
data: captchaData,
|
||||
} = useFetch({
|
||||
url: "/api/send-verification-sms/",
|
||||
method: "POST",
|
||||
});
|
||||
const {
|
||||
fetchData: GetUserinfo,
|
||||
loading: userinfoLoading,
|
||||
data: userinfoData,
|
||||
} = useFetch({
|
||||
url: "/api/profile/",
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
let timer: NodeJS.Timeout;
|
||||
if (captchaTimer > 0) {
|
||||
timer = setInterval(() => {
|
||||
setCaptchaTimer((prev) => prev - 1);
|
||||
}, 1000);
|
||||
}
|
||||
return () => clearInterval(timer);
|
||||
}, [captchaTimer]);
|
||||
|
||||
const handleGetCaptcha = async () => {
|
||||
if (!phone) {
|
||||
addToast(t("enterPhoneNumber"), "error");
|
||||
return;
|
||||
}
|
||||
await fetchCaptcha({ phone_number: phone });
|
||||
setCaptchaTimer(60);
|
||||
};
|
||||
|
||||
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
|
||||
if (!agreed) {
|
||||
addToast(tGlobal("agreeToTerms"), "error");
|
||||
return;
|
||||
}
|
||||
|
||||
// 校验非空字段
|
||||
if (loginMethod === "password" && !username) {
|
||||
addToast(t("enterUsername"), "error");
|
||||
return;
|
||||
}
|
||||
if (loginMethod === "code" && !phone) {
|
||||
addToast(t("enterPhone"), "error");
|
||||
return;
|
||||
}
|
||||
|
||||
if (loginMethod === "password" && !password) {
|
||||
addToast(t("enterPassword"), "error");
|
||||
return;
|
||||
}
|
||||
|
||||
if (loginMethod === "code" && !captcha) {
|
||||
addToast(t("enterCaptcha"), "error");
|
||||
return;
|
||||
}
|
||||
|
||||
const loginData = {
|
||||
login_type: loginMethod,
|
||||
...(loginMethod === "password" && { username, password }),
|
||||
...(loginMethod === "code" && { phone, code: captcha }),
|
||||
};
|
||||
|
||||
// 调用登录函数发送登录请求
|
||||
login(loginData).then(() => {
|
||||
addToast(t("loginSuccess"), "success");
|
||||
|
||||
GetUserinfo().then((res) => {
|
||||
console.log("userinfo", res);
|
||||
setUser(res);
|
||||
});
|
||||
router.replace("/create");
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-[25px] py-8 bg-black/80 rounded-lg shadow-lg px-8 w-[500px]">
|
||||
<div className="mt-auto w-full items-center justify-stretch flex flex-col gap-1.5">
|
||||
<GoogleLogin />
|
||||
<div className="z-0 w-[80%] relative flex justify-center items-center my-3">
|
||||
<small className="font-normal text-xs bg-transparent text-gray-300 px-2.5 py-1 tracking-wide border-0 border-t border-gray-600 leading-none">
|
||||
{t("orUseLoginMethod")}
|
||||
</small>
|
||||
<div className="z-[-1] absolute h-[1px] w-full bg-info opacity-50"></div>
|
||||
</div>
|
||||
<Form.Root className="w-full" onSubmit={handleSubmit}>
|
||||
<div className="w-full flex flex-col gap-3">
|
||||
<Form.Field name="loginMethod">
|
||||
<RadioGroup
|
||||
value={loginMethod}
|
||||
onChange={setLoginMethod}
|
||||
aria-label="Login method"
|
||||
className="flex items-center gap-3"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Radio
|
||||
id="password"
|
||||
value="password"
|
||||
className="cursor-pointer group flex size-5 items-center justify-center rounded-full border bg-gray-700 data-[checked]:bg-green-500"
|
||||
>
|
||||
<span className="invisible size-2 rounded-full bg-white group-data-[checked]:visible" />
|
||||
</Radio>
|
||||
<Form.Label htmlFor="password" className="text-gray-300">
|
||||
{t("passwordLogin")}
|
||||
</Form.Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Radio
|
||||
id="code"
|
||||
value="code"
|
||||
className="cursor-pointer group flex size-5 items-center justify-center rounded-full border bg-gray-700 data-[checked]:bg-green-500"
|
||||
>
|
||||
<span className="invisible size-2 rounded-full bg-white group-data-[checked]:visible" />
|
||||
</Radio>
|
||||
<Form.Label htmlFor="code" className="text-gray-300">
|
||||
{t("codeLogin")}
|
||||
</Form.Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</Form.Field>
|
||||
|
||||
{loginMethod === "password" && (
|
||||
<>
|
||||
<Form.Field name="username">
|
||||
<input
|
||||
type="text"
|
||||
id="username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
className="h-10 input border-gray-600 bg-gray-800 outline-none focus:outline-none w-full rounded-lg text-sm text-gray-300 placeholder:text-gray-500"
|
||||
placeholder={t("emailOrPhone")}
|
||||
/>
|
||||
</Form.Field>
|
||||
|
||||
<Form.Field name="password">
|
||||
<input
|
||||
type="password"
|
||||
id="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="h-10 input border-gray-600 bg-gray-800 outline-none focus:outline-none w-full rounded-lg text-sm text-gray-300 placeholder:text-gray-500"
|
||||
placeholder={t("yourPassword")}
|
||||
/>
|
||||
</Form.Field>
|
||||
</>
|
||||
)}
|
||||
|
||||
{loginMethod === "code" && (
|
||||
<>
|
||||
<Form.Field name="phone">
|
||||
<input
|
||||
type="tel"
|
||||
id="phone"
|
||||
value={phone}
|
||||
onChange={(e) => setPhone(e.target.value)}
|
||||
className="h-10 input border-gray-600 bg-gray-800 outline-none focus:outline-none w-full rounded-lg text-sm text-gray-300 placeholder:text-gray-500"
|
||||
placeholder={t("phone")}
|
||||
/>
|
||||
</Form.Field>
|
||||
<Form.Field name="captcha">
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="text"
|
||||
id="captcha"
|
||||
value={captcha}
|
||||
onChange={(e) => setCaptcha(e.target.value)}
|
||||
className="h-10 input border-gray-600 bg-gray-800 outline-none focus:outline-none w-full rounded-lg text-sm text-gray-300 placeholder:text-gray-500"
|
||||
placeholder={t("captcha")}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleGetCaptcha}
|
||||
className="btn h-10 btn-sm w-32 ml-2 bg-transparent hover:bg-green-500 hover:text-white text-gray-500 rounded-lg"
|
||||
disabled={captchaTimer > 0 || captchaLoading}
|
||||
>
|
||||
{captchaLoading ? (
|
||||
<span className="loading loading-spinner loading-md"></span>
|
||||
) : captchaTimer > 0 ? (
|
||||
`${captchaTimer}s`
|
||||
) : (
|
||||
t("getCaptcha")
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</Form.Field>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 用户协议复选框 */}
|
||||
<Form.Field name="agreement">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="flex items-center text-gray-300 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={agreed}
|
||||
onChange={(e) => setAgreed(e.target.checked)}
|
||||
className="h-4 w-4 text-green-500 bg-gray-700 border-gray-600 rounded focus:ring-green-500"
|
||||
/>
|
||||
<span className="ml-2">
|
||||
{tGlobal("agreeTo")}{" "}
|
||||
<Link href="/terms" className="text-green-500 underline">
|
||||
{tTerms("title")}
|
||||
</Link>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</Form.Field>
|
||||
|
||||
<Form.Submit
|
||||
disabled={loginLoading}
|
||||
className="btn bg-transparent hover:bg-transparent text-gray-500 w-full rounded-lg hover:text-green-500"
|
||||
>
|
||||
{t("login")}
|
||||
{loginLoading ? (
|
||||
<span className="loading loading-spinner loading-md"></span>
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
</Form.Submit>
|
||||
</div>
|
||||
</Form.Root>
|
||||
|
||||
<button
|
||||
onClick={toRegister}
|
||||
className="btn btn-outline btn-neutral whitespace-nowrap z-0 text-sm font-medium w-full mt-3 bg-transparent border-gray-500 text-gray-300 hover:bg-gray-700"
|
||||
>
|
||||
{t("register")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
301
src/ui/login/register-form.tsx
Normal file
301
src/ui/login/register-form.tsx
Normal file
@@ -0,0 +1,301 @@
|
||||
"use client";
|
||||
|
||||
import { IoIosArrowBack } from "react-icons/io";
|
||||
import { RadioGroup, Radio } from "@headlessui/react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { isValidPhoneNumber } from "@/utils/utils";
|
||||
import { useToast } from "@/contexts/ToastContext";
|
||||
import * as Form from "@radix-ui/react-form";
|
||||
import useFetch from "@/hooks/useFetch";
|
||||
import { useTranslations } from "next-intl";
|
||||
import Link from "next/link";
|
||||
|
||||
interface RegisterFormProps {
|
||||
back: () => void;
|
||||
}
|
||||
|
||||
const plans: Plan[] = ["phone", "email"];
|
||||
|
||||
type Plan = "phone" | "email";
|
||||
|
||||
export default function RegisterForm({ back }: RegisterFormProps) {
|
||||
const t = useTranslations("registerForm");
|
||||
const tTerms = useTranslations("terms"); // 用于获取用户协议的标题
|
||||
const tGlobal = useTranslations(); // 用于获取全局翻译,如 "agreeTo" 和 "agreeToTerms"
|
||||
const [selected, setSelected] = useState<Plan>(plans[0]);
|
||||
const [email, setEmail] = useState("");
|
||||
const [phone, setPhone] = useState("");
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [captcha, setCaptcha] = useState("");
|
||||
const [captchaTimer, setCaptchaTimer] = useState(0);
|
||||
const [agreed, setAgreed] = useState(true); // 默认勾选
|
||||
|
||||
const { addToast } = useToast();
|
||||
|
||||
// 获取验证码
|
||||
const {
|
||||
fetchData: fetchVerificationCode,
|
||||
data: verificationData,
|
||||
loading: verificationLoading,
|
||||
error: verificationError,
|
||||
} = useFetch({
|
||||
url: "/api/send-verification-sms/",
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
// 注册
|
||||
const {
|
||||
fetchData: register,
|
||||
data: registerData,
|
||||
loading: registerLoading,
|
||||
error: registerError,
|
||||
} = useFetch({
|
||||
url: "/api/register/",
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
let timer: NodeJS.Timeout;
|
||||
if (captchaTimer > 0) {
|
||||
timer = setInterval(() => {
|
||||
setCaptchaTimer((prev) => prev - 1);
|
||||
}, 1000);
|
||||
}
|
||||
return () => clearInterval(timer);
|
||||
}, [captchaTimer]);
|
||||
|
||||
const handleGetCaptcha = async () => {
|
||||
if (selected === "phone" && !isValidPhoneNumber(phone)) {
|
||||
addToast(t("invalidPhoneNumber"), "error");
|
||||
return;
|
||||
}
|
||||
|
||||
if (selected === "email" && !email) {
|
||||
addToast(t("enterEmail"), "error");
|
||||
return;
|
||||
}
|
||||
|
||||
const data =
|
||||
selected === "phone" ? { phone_number: phone } : { email: email };
|
||||
|
||||
await fetchVerificationCode(data);
|
||||
setCaptchaTimer(60);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (verificationData?.code === 200) {
|
||||
addToast(t("captchaSent"), "success");
|
||||
setCaptchaTimer(60);
|
||||
}
|
||||
}, [verificationData, verificationError]);
|
||||
|
||||
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault(); // 阻止表单默认提交行为
|
||||
|
||||
if (!agreed) {
|
||||
addToast(tGlobal("agreeToTerms"), "error");
|
||||
return;
|
||||
}
|
||||
|
||||
// 校验非空字段
|
||||
if (!username || !password || !confirmPassword || !captcha) {
|
||||
addToast(t("completeInformation"), "error");
|
||||
return;
|
||||
}
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
addToast(t("passwordMismatch"), "error");
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查 email 或 phone 是否有效
|
||||
if (selected === "email" && !email) {
|
||||
addToast(t("enterEmail"), "error");
|
||||
return;
|
||||
}
|
||||
|
||||
if (selected === "phone" && !phone) {
|
||||
addToast(t("enterPhoneNumber"), "error");
|
||||
return;
|
||||
}
|
||||
|
||||
// 构建注册请求参数
|
||||
const registerData = {
|
||||
code: captcha,
|
||||
confirm_password: confirmPassword,
|
||||
password,
|
||||
username,
|
||||
registerMethod: selected,
|
||||
...(selected === "email" && { email }), // 仅当 selected 为 "email" 时添加 email 字段
|
||||
...(selected === "phone" && { phone }), // 仅当 selected 为 "phone" 时添加 phone 字段
|
||||
};
|
||||
|
||||
// 调用 register 函数发送注册请求
|
||||
await register(registerData);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-[25px] py-8 bg-black/80 rounded-lg shadow-lg px-8 w-[400px]">
|
||||
<div className="w-full flex items-center justify-between mb-4 ">
|
||||
<button
|
||||
className="text-gray-300 hover:text-white"
|
||||
onClick={back}
|
||||
>
|
||||
<IoIosArrowBack size={24} />
|
||||
</button>
|
||||
<div className="text-xl font-bold text-center text-gray-300">
|
||||
{t("register")}
|
||||
</div>
|
||||
<div className="w-6"></div> {/* 占位符,使标题居中 */}
|
||||
</div>
|
||||
<Form.Root className="w-full" onSubmit={handleSubmit}>
|
||||
<div className="w-full flex flex-col gap-4">
|
||||
<Form.Field name="radio">
|
||||
<RadioGroup
|
||||
value={selected}
|
||||
onChange={setSelected}
|
||||
aria-label={t("registrationMethod")}
|
||||
className="flex items-center justify-center gap-6"
|
||||
>
|
||||
{plans.map((plan) => (
|
||||
<div key={plan} className="flex items-center gap-2">
|
||||
<Radio
|
||||
id={plan}
|
||||
value={plan}
|
||||
className="cursor-pointer group flex h-5 w-5 items-center justify-center rounded-full border bg-gray-700 data-[checked]:bg-green-500"
|
||||
>
|
||||
<span className="invisible h-2 w-2 rounded-full bg-white group-data-[checked]:visible" />
|
||||
</Radio>
|
||||
<Form.Label htmlFor={plan} className="text-gray-300">
|
||||
{t(plan)}
|
||||
</Form.Label>
|
||||
</div>
|
||||
))}
|
||||
</RadioGroup>
|
||||
</Form.Field>
|
||||
|
||||
{selected === "email" && (
|
||||
<Form.Field name="email">
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="h-10 input border-gray-600 bg-gray-800 focus:outline-none w-full rounded-lg text-sm text-gray-300 placeholder-gray-500"
|
||||
placeholder={t("email")}
|
||||
/>
|
||||
</Form.Field>
|
||||
)}
|
||||
{selected === "phone" && (
|
||||
<Form.Field name="phone">
|
||||
<input
|
||||
type="tel"
|
||||
id="phone"
|
||||
value={phone}
|
||||
onChange={(e) => setPhone(e.target.value)}
|
||||
className="h-10 input border-gray-600 bg-gray-800 focus:outline-none w-full rounded-lg text-sm text-gray-300 placeholder-gray-500"
|
||||
placeholder={t("phoneNumber")}
|
||||
/>
|
||||
</Form.Field>
|
||||
)}
|
||||
|
||||
{/* 用户名 */}
|
||||
<Form.Field name="username">
|
||||
<input
|
||||
type="text"
|
||||
id="username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
className="h-10 input border-gray-600 bg-gray-800 focus:outline-none w-full rounded-lg text-sm text-gray-300 placeholder-gray-500"
|
||||
placeholder={t("username")}
|
||||
/>
|
||||
</Form.Field>
|
||||
|
||||
{/* 密码 */}
|
||||
<Form.Field name="password">
|
||||
<input
|
||||
type="password"
|
||||
id="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="h-10 input border-gray-600 bg-gray-800 focus:outline-none w-full rounded-lg text-sm text-gray-300 placeholder-gray-500"
|
||||
placeholder={t("password")}
|
||||
/>
|
||||
</Form.Field>
|
||||
|
||||
{/* 确认密码 */}
|
||||
<Form.Field name="confirmPassword">
|
||||
<input
|
||||
type="password"
|
||||
id="confirmPassword"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
className="h-10 input border-gray-600 bg-gray-800 focus:outline-none w-full rounded-lg text-sm text-gray-300 placeholder-gray-500"
|
||||
placeholder={t("confirmPassword")}
|
||||
/>
|
||||
</Form.Field>
|
||||
|
||||
{/* 验证码 */}
|
||||
<Form.Field name="captcha">
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="text"
|
||||
id="captcha"
|
||||
value={captcha}
|
||||
onChange={(e) => setCaptcha(e.target.value)}
|
||||
className="h-10 input border-gray-600 bg-gray-800 focus:outline-none w-full rounded-lg text-sm text-gray-300 placeholder-gray-500"
|
||||
placeholder={t("captcha")}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleGetCaptcha}
|
||||
className="btn h-10 btn-sm w-32 ml-2 bg-transparent hover:bg-green-500 hover:text-white text-gray-500 rounded-lg"
|
||||
disabled={captchaTimer > 0 || verificationLoading}
|
||||
>
|
||||
{verificationLoading ? (
|
||||
<span className="loading loading-spinner loading-md"></span>
|
||||
) : captchaTimer > 0 ? (
|
||||
`${captchaTimer}s`
|
||||
) : (
|
||||
t("getCode")
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</Form.Field>
|
||||
|
||||
{/* 用户协议复选框 */}
|
||||
<Form.Field name="agreement">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="flex items-center text-gray-300 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={agreed}
|
||||
onChange={(e) => setAgreed(e.target.checked)}
|
||||
className="h-4 w-4 text-green-500 bg-gray-700 border-gray-600 rounded focus:ring-green-500"
|
||||
/>
|
||||
<span className="ml-2">
|
||||
{tGlobal("agreeTo")}{" "}
|
||||
<Link href="/terms" className="text-green-500 underline">
|
||||
{tTerms("title")}
|
||||
</Link>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</Form.Field>
|
||||
|
||||
<Form.Submit
|
||||
disabled={registerLoading}
|
||||
className="btn bg-transparent hover:bg-green-500 text-gray-300 w-full rounded-lg hover:text-white border border-gray-600"
|
||||
>
|
||||
{t("submit")}
|
||||
{registerLoading ? (
|
||||
<span className="loading loading-spinner loading-md ml-2"></span>
|
||||
) : null}
|
||||
</Form.Submit>
|
||||
</div>
|
||||
</Form.Root>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
303
src/ui/page/page-Teach.tsx
Normal file
303
src/ui/page/page-Teach.tsx
Normal file
@@ -0,0 +1,303 @@
|
||||
import Image from "next/image";
|
||||
import IconHowItWorks from "/public/icons/IconHowItWorks.svg";
|
||||
export default function PageTeach() {
|
||||
return (
|
||||
<section
|
||||
id="how-it-works"
|
||||
className="content-visibility-auto scroll-m-20 w-full mt-24 space-y-8"
|
||||
>
|
||||
<div className="space-y-4 mb-14">
|
||||
<div className="mx-auto text-green-200 border border-[#c8eed6]/25 shadow-lg shadow-[#4add80]/10 w-fit font-medium text-sm rounded-full bg-gradient-to-b from-[#737b88]/20 to-[#191b1e]/20 p-[1.5px]">
|
||||
<div className="bg-[#15171a]/50 flex items-center space-x-1 py-1 px-2 rounded-full ">
|
||||
<Image
|
||||
alt="typeframes.ai logo"
|
||||
fetchPriority="high"
|
||||
width={17}
|
||||
height={16}
|
||||
decoding="async"
|
||||
style={{ color: "transparent" }}
|
||||
src={IconHowItWorks}
|
||||
/>
|
||||
<span>How it works</span>
|
||||
</div>
|
||||
</div>
|
||||
<h2 className='text-white font-["Euclid_Circular_A"] text-3xl lg:text-4xl xl:text-5xl text-center w-11/12 max-w-3xl text-pretty mx-auto'>
|
||||
Creating TikTok content has never been that easy
|
||||
</h2>
|
||||
</div>
|
||||
<div className="gradient-bg rounded-lg px-4 sm:px-6 md:px-8 pb-8 border border-[#252629] text-neutral-dark">
|
||||
<div className="rounded-lg rounded-t-none border border-t-0 border-[#353e3b] divide-y lg:divide-y-0 lg:divide-x divide-[#353e3b] grid lg:grid-cols-2">
|
||||
<div className="p-4 sm:p-6 lg:p-8 pt-0 lg:pb-14 my-auto">
|
||||
<div className="my-6 md:mb-8 overflow-hidden relative flex items-center justify-center size-12 rounded-xl border border-[#353e3b]">
|
||||
<span className='font-bold text-2xl font-["Euclid_Circular_A"]'>
|
||||
1
|
||||
</span>
|
||||
<span className='absolute blur-xl bg-white font-bold text-2xl font-["Euclid_Circular_A"]'>
|
||||
1
|
||||
</span>
|
||||
</div>
|
||||
<h4 className='text-white font-medium text-2xl font-["Euclid_Circular_A"]'>
|
||||
Find what goes viral
|
||||
</h4>
|
||||
<p className="mt-4 mb-8 text-balance">
|
||||
AI will find the best content for you that you can
|
||||
easily repurpose into TikTok videos.
|
||||
</p>
|
||||
<ul className="*:flex *:items-start *:space-x-1.5 space-y-4 lg:space-y-3 *:text-[#eafff2] *:text-sm">
|
||||
<li>
|
||||
<div className="min-w-5 min-h-5">
|
||||
<Image
|
||||
alt="typeframes.ai logo"
|
||||
fetchPriority="high"
|
||||
width={20}
|
||||
height={20}
|
||||
decoding="async"
|
||||
style={{ color: "transparent" }}
|
||||
src={IconHowItWorks}
|
||||
/>
|
||||
</div>
|
||||
<span>Search with complex queries</span>
|
||||
</li>
|
||||
<li>
|
||||
<div className="min-w-5 min-h-5">
|
||||
<Image
|
||||
alt="typeframes.ai logo"
|
||||
fetchPriority="high"
|
||||
width={20}
|
||||
height={20}
|
||||
decoding="async"
|
||||
style={{ color: "transparent" }}
|
||||
src={IconHowItWorks}
|
||||
/>
|
||||
</div>
|
||||
<span>
|
||||
Get full video transcript to fuel your next
|
||||
script
|
||||
</span>
|
||||
</li>
|
||||
<li>
|
||||
<div className="min-w-5 min-h-5">
|
||||
<Image
|
||||
alt="typeframes.ai logo"
|
||||
fetchPriority="high"
|
||||
width={20}
|
||||
height={20}
|
||||
decoding="async"
|
||||
style={{ color: "transparent" }}
|
||||
src={IconHowItWorks}
|
||||
/>
|
||||
</div>
|
||||
<span>Use advanced search</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="relative pl-4 sm:pl-6 lg:pl-8 pt-6 lg:pt-8">
|
||||
<div className="relative h-64 sm:h-96 lg:h-full w-full">
|
||||
{/* <Image
|
||||
alt="from text to video in typeframes.ai"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
layout="fill"
|
||||
className="ml-auto aspect-video overflow-hidden rounded-tl-xl rounded-br-xl object-cover object-top"
|
||||
sizes="100vw"
|
||||
srcSet="/images/tth_search.jpg"
|
||||
/> */}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="gradient-bg rounded-lg px-4 sm:px-6 md:px-8 pb-8 border border-[#252629] text-neutral-dark">
|
||||
<div className="rounded-lg rounded-t-none border border-t-0 border-[#353e3b] divide-y lg:divide-y-0 lg:divide-x divide-[#353e3b] grid lg:grid-cols-2">
|
||||
<div className="p-4 sm:p-6 lg:p-8 pt-0 lg:pb-14 my-auto lg:order-last lg:border-l border-[#353e3b]">
|
||||
<div className="my-6 md:mb-8 overflow-hidden relative flex items-center justify-center size-12 rounded-xl border border-[#353e3b]">
|
||||
<span className='font-bold text-2xl font-["Euclid_Circular_A"]'>
|
||||
2
|
||||
</span>
|
||||
<span className='absolute blur-xl bg-white font-bold text-2xl font-["Euclid_Circular_A"]'>
|
||||
1
|
||||
</span>
|
||||
</div>
|
||||
<h4 className='text-white font-medium text-2xl font-["Euclid_Circular_A"]'>
|
||||
Generate scripts in seconds
|
||||
</h4>
|
||||
<p className="mt-4 mb-8 text-balance">
|
||||
Our AI understands what makes videos go viral and
|
||||
use the same proven methods to write scripts for
|
||||
you.
|
||||
</p>
|
||||
<ul className="*:flex *:items-start *:space-x-1.5 space-y-4 lg:space-y-3 *:text-[#eafff2] *:text-sm">
|
||||
<li>
|
||||
<div className="min-w-5 min-h-5">
|
||||
<Image
|
||||
alt="typeframes.ai logo"
|
||||
fetchPriority="high"
|
||||
width={20}
|
||||
height={20}
|
||||
decoding="async"
|
||||
style={{ color: "transparent" }}
|
||||
src={IconHowItWorks}
|
||||
/>
|
||||
</div>
|
||||
<span>Ask it what you want to talk about</span>
|
||||
</li>
|
||||
<li>
|
||||
<div className="min-w-5 min-h-5">
|
||||
<Image
|
||||
alt="typeframes.ai logo"
|
||||
fetchPriority="high"
|
||||
width={20}
|
||||
height={20}
|
||||
decoding="async"
|
||||
style={{ color: "transparent" }}
|
||||
src={IconHowItWorks}
|
||||
/>
|
||||
</div>
|
||||
<span>
|
||||
The AI will find relevant content to get
|
||||
inspired by
|
||||
</span>
|
||||
</li>
|
||||
<li>
|
||||
<div className="min-w-5 min-h-5">
|
||||
<Image
|
||||
alt="typeframes.ai logo"
|
||||
fetchPriority="high"
|
||||
width={20}
|
||||
height={20}
|
||||
decoding="async"
|
||||
style={{ color: "transparent" }}
|
||||
src={IconHowItWorks}
|
||||
/>
|
||||
</div>
|
||||
<span>
|
||||
Drop any link and it will be automatically
|
||||
parsed and formatted for a video
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="relative pr-4 sm:pr-6 lg:pr-8 pt-6 lg:pt-8">
|
||||
<div className="relative h-64 sm:h-96 lg:h-full w-full">
|
||||
{/* <Image
|
||||
alt="from text to video in typeframes.ai"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
layout="fill"
|
||||
className="ml-auto aspect-video overflow-hidden rounded-tr-xl rounded-bl-xl object-cover object-top"
|
||||
sizes="100vw"
|
||||
srcSet="/images/tth_script.jpg"
|
||||
/> */}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="gradient-bg rounded-lg px-4 sm:px-6 md:px-8 pb-8 border border-[#252629] text-neutral-dark">
|
||||
<div className="rounded-lg rounded-t-none border border-t-0 border-[#353e3b] divide-y lg:divide-y-0 lg:divide-x divide-[#353e3b] grid lg:grid-cols-2">
|
||||
<div className="p-4 sm:p-6 lg:p-8 pt-0 lg:pb-14 my-auto">
|
||||
<div className="my-6 md:my-8 overflow-hidden relative flex items-center justify-center size-12 rounded-xl border border-[#353e3b]">
|
||||
<span className='font-bold text-2xl font-["Euclid_Circular_A"]'>
|
||||
3
|
||||
</span>
|
||||
<span className='absolute blur-xl bg-white font-bold text-2xl font-["Euclid_Circular_A"]'>
|
||||
1
|
||||
</span>
|
||||
</div>
|
||||
<h4 className='text-white font-medium text-2xl font-["Euclid_Circular_A"]'>
|
||||
Viral-First Video Creation
|
||||
</h4>
|
||||
<p className="mt-4 mb-8">
|
||||
Create perfect videos for social media, grab
|
||||
attention, and grow your business.
|
||||
</p>
|
||||
<ul className="*:flex *:items-start *:space-x-1.5 space-y-4 lg:space-y-3 *:text-[#eafff2] *:text-sm">
|
||||
<li>
|
||||
<div className="min-w-5 min-h-5">
|
||||
<Image
|
||||
alt="typeframes.ai logo"
|
||||
fetchPriority="high"
|
||||
width={20}
|
||||
height={20}
|
||||
decoding="async"
|
||||
style={{ color: "transparent" }}
|
||||
src={IconHowItWorks}
|
||||
/>
|
||||
</div>
|
||||
<span>
|
||||
Create vertical videos from any content,
|
||||
ready to be published
|
||||
</span>
|
||||
</li>
|
||||
<li>
|
||||
<div className="min-w-5 min-h-5">
|
||||
<Image
|
||||
alt="typeframes.ai logo"
|
||||
fetchPriority="high"
|
||||
width={20}
|
||||
height={20}
|
||||
decoding="async"
|
||||
style={{ color: "transparent" }}
|
||||
src={IconHowItWorks}
|
||||
/>
|
||||
</div>
|
||||
<span>
|
||||
Generate voice, add animations and create
|
||||
super engaging content
|
||||
</span>
|
||||
</li>
|
||||
<li>
|
||||
<div className="min-w-5 min-h-5">
|
||||
<Image
|
||||
alt="typeframes.ai logo"
|
||||
fetchPriority="high"
|
||||
width={20}
|
||||
height={20}
|
||||
decoding="async"
|
||||
style={{ color: "transparent" }}
|
||||
src={IconHowItWorks}
|
||||
/>
|
||||
</div>
|
||||
<span>
|
||||
Create automations, typeframes.ai will watch
|
||||
your blog, a subreddit, Twitter or Linkedin
|
||||
account and create content automatically
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="relative pl-4 sm:pl-6 lg:pl-8 pt-6 lg:pt-8">
|
||||
<div className="relative h-64 sm:h-96 lg:h-full w-full">
|
||||
{/* <Image
|
||||
alt="from text to video in typeframes.ai"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
layout="fill"
|
||||
className="ml-auto aspect-video overflow-hidden rounded-tl-xl rounded-br-xl object-cover object-top"
|
||||
sizes="100vw"
|
||||
srcSet="/images/tth_editor.jpg"
|
||||
/> */}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mx-auto flex flex-col items-center space-y-4 py-8">
|
||||
{/* <Image
|
||||
alt="typeframes.ai logo"
|
||||
loading="lazy"
|
||||
width={154}
|
||||
height={30}
|
||||
decoding="async"
|
||||
srcSet="/images/typeframes_logo_rec_light.png 1x, /images/typeframes_logo_rec_light.png 2x"
|
||||
src="/images/typeframes_logo_rec_light.png"
|
||||
/> */}
|
||||
<p className="text-neutral-dark">
|
||||
Your shortcut to effortless video story telling.
|
||||
</p>
|
||||
<div className="btn-tf-primary-container mx-auto lg:mx-0">
|
||||
<a className="btn-tf btn-tf-primary" href="/create">
|
||||
Create Videos Now
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
119
src/ui/page/page-expect.tsx
Normal file
119
src/ui/page/page-expect.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export default function PageExpect() {
|
||||
const t = useTranslations("pageExpect");
|
||||
return (
|
||||
<div className="content-visibility-auto text-neutral-dark rounded-lg px-4 sm:px-6 md:px-8 border border-[#252629] w-full gradient-bg mt-24">
|
||||
<div className="border-x border-[#353e3b]">
|
||||
<div className="space-y-12 overflow-hidden border-x border-b rounded-b-xl border-[#353e3b] py-12 lg:py-24">
|
||||
<div className="space-y-4">
|
||||
<div className="mx-auto text-green-200 border border-[#c8eed6]/25 shadow-lg shadow-[#4add80]/10 w-fit font-medium text-sm rounded-full bg-gradient-to-b from-[#737b88]/20 to-[#191b1e]/20 p-[1.5px]">
|
||||
<div className="bg-[#15171a]/50 flex items-center space-x-1 py-1 px-2 rounded-full">
|
||||
{/* <IconWhatToExpect
|
||||
width={17}
|
||||
height={16}
|
||||
/> */}
|
||||
<span>{t("whatToExpect")}</span>
|
||||
</div>
|
||||
</div>
|
||||
<h2 className='text-white font-["Euclid_Circular_A"] text-3xl lg:text-4xl xl:text-5xl text-center w-11/12 max-w-3xl text-pretty mx-auto'>
|
||||
{t("heading")}
|
||||
</h2>
|
||||
<p className="text-center w-[90%] max-w-xl mx-auto text-balance">
|
||||
{t("description")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center relative">
|
||||
<div className="bg-white outline w-1/2 md:max-w-[300px] absolute hidden sm:block sm:-left-1/4 md:right-1/4 mx-auto outline-[6px] outline-[#636363]/25 rounded-xl object-cover">
|
||||
<video
|
||||
className="content-visibility-auto bg-white shadow-2xl rounded-[13px] border border-base-200"
|
||||
playsInline
|
||||
loop
|
||||
muted
|
||||
autoPlay
|
||||
src="./video/create-tiktok-video/en/demo_image.webm"
|
||||
preload="auto"
|
||||
></video>
|
||||
</div>
|
||||
<div className="bg-white outline w-5/6 md:w-3/4 md:max-w-[450px] relative z-10 mx-auto outline-[6px] outline-[#636363]/25 rounded-xl object-cover">
|
||||
<video
|
||||
className="content-visibility-auto bg-white shadow-2xl rounded-[13px] border border-base-200"
|
||||
playsInline
|
||||
loop
|
||||
muted
|
||||
autoPlay
|
||||
src="./video/\music-to-video/cn/demo_image.webm"
|
||||
preload="auto"
|
||||
></video>
|
||||
</div>
|
||||
<div className="bg-white outline w-1/2 md:max-w-[300px] absolute hidden sm:block sm:-right-1/4 md:left-1/4 -z-0 mx-auto outline-[6px] outline-[#636363]/25 rounded-xl object-cover">
|
||||
<video
|
||||
className="content-visibility-auto bg-white shadow-2xl rounded-[13px] border border-base-200"
|
||||
playsInline
|
||||
loop
|
||||
muted
|
||||
autoPlay
|
||||
src="https://cdn.tfrv.xyz/renders/gjcIDd3JXNbmRF7DcHp7/HH0T7EVxK75TtVr4cYiM-1721573984672.mp4"
|
||||
preload="auto"
|
||||
></video>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="diagonal-pattern border-[#353e3b]">
|
||||
<div className="grid md:grid-cols-2 lg:grid-cols-3 border border-[#353e3b] rounded-xl w-[90%] lg:w-[80%] mx-auto divide-y md:divide-x lg:divide-y-0 divide-[#353e3b]">
|
||||
<div className="bg-[#1f2425] px-5 py-7 rounded-t-xl md:rounded-l-xl md:rounded-bl-none lg:rounded-bl-xl md:rounded-tr-none">
|
||||
<div className="relative w-full ">
|
||||
{/* <Image alt="easy to use" loading="lazy" width={500} height={300} decoding="async" className="mx-auto aspect-auto rounded-t-xl object-cover object-center" style={{ color: 'transparent', width: '100%', height: 'auto' }} srcSet="/images/easy-to-use.png 1x, /images/easy-to-use.png 2x" src="/images/easy-to-use.png" /> */}
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<h5 className="text-white font-medium">
|
||||
{t("easyToUse.title")}
|
||||
</h5>
|
||||
<p className="text-sm">
|
||||
{t("easyToUse.description")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-[#1f2425] px-5 py-7 rounded-none md:rounded-tr-xl lg:rounded-tr-none">
|
||||
<div className="relative w-full ">
|
||||
{/* <Image alt="customisable templates" loading="lazy" width={500} height={300} decoding="async" className="mx-auto aspect-auto rounded-t-xl object-cover object-center" style={{ color: 'transparent', width: '100%', height: 'auto' }} srcSet="/images/customisable-templates.png 1x, /images/customisable-templates.png 2x" src="/images/customisable-templates.png" /> */}
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<h5 className="text-white font-medium">
|
||||
{t("customisableTemplates.title")}
|
||||
</h5>
|
||||
<p className="text-sm">
|
||||
{t("customisableTemplates.description")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-[#1f2425] px-5 py-7 lg:rounded-r-xl md:rounded-tr-none rounded-b-xl lg:rounded-bl-none md:col-span-2 lg:col-span-1 md:grid md:grid-cols-2 md:place-items-center lg:block">
|
||||
<div className="relative w-full ">
|
||||
{/* <Image alt="high quality exports" loading="lazy" width={500} height={300} decoding="async" className="mx-auto aspect-auto rounded-t-xl object-cover object-center" style={{ color: 'transparent', width: '100%', height: 'auto' }} srcSet="/images/high-quality-exports.png 1x, /images/high-quality-exports.png 2x" src="/images/high-quality-exports.png" /> */}
|
||||
</div>
|
||||
<div className="space-y-3 md:pl-6 lg:pl-0">
|
||||
<h5 className="text-white font-medium">
|
||||
{t("highQualityExports.title")}
|
||||
</h5>
|
||||
<p className="text-sm">
|
||||
{t("highQualityExports.description")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-x border-t rounded-t-xl border-[#353e3b] mx-auto flex flex-col items-center space-y-5 py-12 lg:py-24">
|
||||
{/* <Image alt="typeframes.ai logo" loading="lazy" width={154} height={30} decoding="async" srcSet="/images/typeframes_logo_rec_light.png 1x, /images/typeframes_logo_rec_light.png 2x" src="/images/typeframes_logo_rec_light.png" /> */}
|
||||
<p className="text-neutral-dark max-w-lg w-[95%] mx-auto text-center text-pretty">
|
||||
{t("aiPowered")}
|
||||
</p>
|
||||
<div className="btn-tf-primary-container mx-auto lg:mx-0">
|
||||
<a className="btn-tf btn-tf-primary" href="/create">
|
||||
{t("createVideosNow")}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
449
src/ui/page/page-faqs.tsx
Normal file
449
src/ui/page/page-faqs.tsx
Normal file
@@ -0,0 +1,449 @@
|
||||
export default function PageFaqs() {
|
||||
return (
|
||||
<article className="content-visibility-auto rounded-lg px-4 sm:px-6 md:px-8 border border-[#252629] w-full bg-[#15171a]">
|
||||
<div className="w-full relative border-x border-[#353e3b]">
|
||||
<div className="absolute z-0 w-full h-full grid lg:grid-cols-2 gap-8 items-center">
|
||||
<section className="-z-0 absolute w-full h-full col-span-2 grid grid-cols-2 place-content-between">
|
||||
<div className="bg-[#4BDE81] rounded-full w-1 h-1 my-8 outline outline-8 outline-[#15171A] -mx-[2.5px] "></div>
|
||||
<div className="bg-[#4BDE81] rounded-full w-1 h-1 my-8 outline outline-8 outline-[#15171A] -mx-[2px] place-self-end"></div>
|
||||
<div className="bg-[#4BDE81] rounded-full w-1 h-1 my-8 outline outline-8 outline-[#15171A] -mx-[2.5px] "></div>
|
||||
<div className="bg-[#4BDE81] rounded-full w-1 h-1 my-8 outline outline-8 outline-[#15171A] -mx-[2px] place-self-end"></div>
|
||||
</section>
|
||||
</div>
|
||||
<div className="relative z-10 w-[95%] lg:w-2/3 mx-auto py-12 lg:py-24">
|
||||
<div className="space-y-4 mb-14">
|
||||
<div className="mx-auto text-green-200 border border-[#c8eed6]/25 shadow-lg shadow-[#4add80]/10 w-fit font-medium text-sm rounded-full bg-gradient-to-b from-[#737b88]/20 to-[#191b1e]/20 p-[1.5px]">
|
||||
<div className="bg-[#15171a]/50 flex items-center space-x-1 py-1 px-2 rounded-full">
|
||||
<svg
|
||||
width="17"
|
||||
height="16"
|
||||
viewBox="0 0 17 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M11.834 12.2868H9.16732L6.20064 14.2601C5.76064 14.5535 5.16732 14.2402 5.16732 13.7068V12.2868C3.16732 12.2868 1.83398 10.9535 1.83398 8.95349V4.95345C1.83398 2.95345 3.16732 1.62012 5.16732 1.62012H11.834C13.834 1.62012 15.1673 2.95345 15.1673 4.95345V8.95349C15.1673 10.9535 13.834 12.2868 11.834 12.2868Z"
|
||||
stroke="#BEFFD6"
|
||||
stroke-width="1.5"
|
||||
stroke-miterlimit="10"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
></path>
|
||||
<path
|
||||
d="M8.49923 7.57324V7.43327C8.49923 6.97993 8.77924 6.73992 9.05924 6.54659C9.33258 6.35992 9.60588 6.11993 9.60588 5.67993C9.60588 5.0666 9.11256 4.57324 8.49923 4.57324C7.88589 4.57324 7.39258 5.0666 7.39258 5.67993"
|
||||
stroke="#BEFFD6"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
></path>
|
||||
<path
|
||||
d="M8.49635 9.16634H8.50235"
|
||||
stroke="#BEFFD6"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
></path>
|
||||
</svg>
|
||||
<span className="">FAQs</span>
|
||||
</div>
|
||||
</div>
|
||||
<h2 className='text-white font-["Euclid_Circular_A"] text-3xl lg:text-4xl xl:text-5xl text-center w-11/12 max-w-3xl text-pretty mx-auto'>
|
||||
Frequently Asked Questions
|
||||
</h2>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div className="bg-[#1c1e21] rounded-xl flex flex-col justify-center px-6 md:pt-10 space-y-2 transition-all py-4 lg:py-3">
|
||||
<div className="flex items-start justify-between lg:pt-2 ">
|
||||
<h5 className="faq-question text-white font-medium hover:cursor-pointer my-auto">
|
||||
How can typeframes.ai transform my content
|
||||
creation game?
|
||||
</h5>
|
||||
<div className="relative flex items-center justify-center h-fit">
|
||||
<span className="cursor-pointer text-xl text-green-400 transition-all rotate-45">
|
||||
<svg
|
||||
stroke="currentColor"
|
||||
fill="currentColor"
|
||||
stroke-width="0"
|
||||
viewBox="0 0 256 256"
|
||||
height="18"
|
||||
width="18"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path d="M228,128a12,12,0,0,1-12,12H140v76a12,12,0,0,1-24,0V140H40a12,12,0,0,1,0-24h76V40a12,12,0,0,1,24,0v76h76A12,12,0,0,1,228,128Z"></path>
|
||||
</svg>
|
||||
</span>
|
||||
<span className="absolute blur-md bg-green-300/50 font-bold text-xs top-1/2 pointer-event-none h-2 w-2"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-neutral-dark text-sm transition-all duration-200 max-h-[20rem] whitespace-pre-line">
|
||||
typeframes.ai is your secret weapon for
|
||||
creating irresistible vertical videos in a
|
||||
snap. Our AI-powered platform analyzes
|
||||
millions of viral videos to craft scripts
|
||||
and generate stunning visuals that are
|
||||
optimized for maximum impact. Whether you're
|
||||
creating product demos, explainer videos, or
|
||||
social media ads, typeframes.ai helps you
|
||||
produce content that consistently captivates
|
||||
your audience.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-[#1c1e21] rounded-xl flex flex-col justify-center px-6 md:pt-10 space-y-2 transition-all py-4 lg:py-3">
|
||||
<div className="flex items-start justify-between lg:pt-2 ">
|
||||
<h5 className="faq-question text-white font-medium hover:cursor-pointer my-auto">
|
||||
I'm not a video editing pro. Can I still use
|
||||
typeframes.ai?
|
||||
</h5>
|
||||
<div className="relative flex items-center justify-center h-fit">
|
||||
<span className="cursor-pointer text-xl text-green-400 transition-all rotate-0">
|
||||
<svg
|
||||
stroke="currentColor"
|
||||
fill="currentColor"
|
||||
stroke-width="0"
|
||||
viewBox="0 0 256 256"
|
||||
height="18"
|
||||
width="18"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path d="M228,128a12,12,0,0,1-12,12H140v76a12,12,0,0,1-24,0V140H40a12,12,0,0,1,0-24h76V40a12,12,0,0,1,24,0v76h76A12,12,0,0,1,228,128Z"></path>
|
||||
</svg>
|
||||
</span>
|
||||
<span className="absolute blur-md bg-green-300/50 font-bold text-xs top-1/2 pointer-event-none h-2 w-2"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-neutral-dark text-sm transition-all duration-200 max-h-0 overflow-hidden whitespace-pre-line">
|
||||
Absolutely! typeframes.ai is designed with
|
||||
creators like you in mind. Our intuitive
|
||||
interface and AI-driven tools make it a
|
||||
breeze to produce professional-grade videos,
|
||||
even if you've never edited before. Simply
|
||||
input your text or link, and let our AI work
|
||||
its magic. It's like having a video editing
|
||||
genius at your fingertips!
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-[#1c1e21] rounded-xl flex flex-col justify-center px-6 md:pt-10 space-y-2 transition-all py-4 lg:py-3">
|
||||
<div className="flex items-start justify-between lg:pt-2 ">
|
||||
<h5 className="faq-question text-white font-medium hover:cursor-pointer my-auto">
|
||||
What kind of content can I create with
|
||||
typeframes.ai?
|
||||
</h5>
|
||||
<div className="relative flex items-center justify-center h-fit">
|
||||
<span className="cursor-pointer text-xl text-green-400 transition-all rotate-0">
|
||||
<svg
|
||||
stroke="currentColor"
|
||||
fill="currentColor"
|
||||
stroke-width="0"
|
||||
viewBox="0 0 256 256"
|
||||
height="18"
|
||||
width="18"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path d="M228,128a12,12,0,0,1-12,12H140v76a12,12,0,0,1-24,0V140H40a12,12,0,0,1,0-24h76V40a12,12,0,0,1,24,0v76h76A12,12,0,0,1,228,128Z"></path>
|
||||
</svg>
|
||||
</span>
|
||||
<span className="absolute blur-md bg-green-300/50 font-bold text-xs top-1/2 pointer-event-none h-2 w-2"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-neutral-dark text-sm transition-all duration-200 max-h-0 overflow-hidden whitespace-pre-line">
|
||||
The possibilities are endless! Whether you
|
||||
want to repurpose a blog post, turn a
|
||||
podcast into a video, create a viral TikTok
|
||||
from scratch, or produce engaging product
|
||||
demos and explainer videos, typeframes.ai
|
||||
has you covered. Our AI can generate scripts
|
||||
from any text or URL, find the perfect viral
|
||||
hooks, and even create videos automatically
|
||||
from your favorite content sources. If you
|
||||
can dream it, typeframes.ai can help you
|
||||
create it.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-[#1c1e21] rounded-xl flex flex-col justify-center px-6 md:pt-10 space-y-2 transition-all py-4 lg:py-3">
|
||||
<div className="flex items-start justify-between lg:pt-2 ">
|
||||
<h5 className="faq-question text-white font-medium hover:cursor-pointer my-auto">
|
||||
How much control do I have over the
|
||||
AI-generated content?
|
||||
</h5>
|
||||
<div className="relative flex items-center justify-center h-fit">
|
||||
<span className="cursor-pointer text-xl text-green-400 transition-all rotate-0">
|
||||
<svg
|
||||
stroke="currentColor"
|
||||
fill="currentColor"
|
||||
stroke-width="0"
|
||||
viewBox="0 0 256 256"
|
||||
height="18"
|
||||
width="18"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path d="M228,128a12,12,0,0,1-12,12H140v76a12,12,0,0,1-24,0V140H40a12,12,0,0,1,0-24h76V40a12,12,0,0,1,24,0v76h76A12,12,0,0,1,228,128Z"></path>
|
||||
</svg>
|
||||
</span>
|
||||
<span className="absolute blur-md bg-green-300/50 font-bold text-xs top-1/2 pointer-event-none h-2 w-2"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-neutral-dark text-sm transition-all duration-200 max-h-0 overflow-hidden whitespace-pre-line">
|
||||
While our AI is incredibly powerful, you
|
||||
always remain in the driver's seat.
|
||||
typeframes.ai provides a foundation of
|
||||
high-quality, engaging content that you can
|
||||
then customize to your heart's content. From
|
||||
adding a professional sounding voice-over
|
||||
and branding elements to fine-tuning the
|
||||
visuals and pacing, our platform empowers
|
||||
you to create videos that are authentically
|
||||
yours.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-[#1c1e21] rounded-xl flex flex-col justify-center px-6 md:pt-10 space-y-2 transition-all py-4 lg:py-3">
|
||||
<div className="flex items-start justify-between lg:pt-2 ">
|
||||
<h5 className="faq-question text-white font-medium hover:cursor-pointer my-auto">
|
||||
How can typeframes.ai help me grow my
|
||||
audience and business?
|
||||
</h5>
|
||||
<div className="relative flex items-center justify-center h-fit">
|
||||
<span className="cursor-pointer text-xl text-green-400 transition-all rotate-0">
|
||||
<svg
|
||||
stroke="currentColor"
|
||||
fill="currentColor"
|
||||
stroke-width="0"
|
||||
viewBox="0 0 256 256"
|
||||
height="18"
|
||||
width="18"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path d="M228,128a12,12,0,0,1-12,12H140v76a12,12,0,0,1-24,0V140H40a12,12,0,0,1,0-24h76V40a12,12,0,0,1,24,0v76h76A12,12,0,0,1,228,128Z"></path>
|
||||
</svg>
|
||||
</span>
|
||||
<span className="absolute blur-md bg-green-300/50 font-bold text-xs top-1/2 pointer-event-none h-2 w-2"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-neutral-dark text-sm transition-all duration-200 max-h-0 overflow-hidden whitespace-pre-line">
|
||||
typeframes.ai is your partner in audience
|
||||
growth and business success. Our AI is
|
||||
trained on millions of viral videos, so it
|
||||
knows exactly what makes content
|
||||
irresistible. From attention-grabbing hooks
|
||||
to mesmerizing visuals, typeframes.ai helps
|
||||
you create videos that demand to be watched
|
||||
and shared. Our users have reported an
|
||||
average of 600% increase in video
|
||||
engagement, 200% monthly growth in their
|
||||
businesses, and a staggering 10,000+ videos
|
||||
created in just 8 minutes each. Rest
|
||||
assured, your content creation success is
|
||||
our #1 incentive.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-[#1c1e21] rounded-xl flex flex-col justify-center px-6 md:pt-10 space-y-2 transition-all py-4 lg:py-3">
|
||||
<div className="flex items-start justify-between lg:pt-2 ">
|
||||
<h5 className="faq-question text-white font-medium hover:cursor-pointer my-auto">
|
||||
Is my data safe with typeframes.ai?
|
||||
</h5>
|
||||
<div className="relative flex items-center justify-center h-fit">
|
||||
<span className="cursor-pointer text-xl text-green-400 transition-all rotate-0">
|
||||
<svg
|
||||
stroke="currentColor"
|
||||
fill="currentColor"
|
||||
stroke-width="0"
|
||||
viewBox="0 0 256 256"
|
||||
height="18"
|
||||
width="18"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path d="M228,128a12,12,0,0,1-12,12H140v76a12,12,0,0,1-24,0V140H40a12,12,0,0,1,0-24h76V40a12,12,0,0,1,24,0v76h76A12,12,0,0,1,228,128Z"></path>
|
||||
</svg>
|
||||
</span>
|
||||
<span className="absolute blur-md bg-green-300/50 font-bold text-xs top-1/2 pointer-event-none h-2 w-2"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-neutral-dark text-sm transition-all duration-200 max-h-0 overflow-hidden whitespace-pre-line">
|
||||
Absolutely. We take data privacy and
|
||||
security very seriously. typeframes.ai
|
||||
employs industry-standard encryption and
|
||||
security measures to protect your content
|
||||
and personal information. We never share
|
||||
your data with third parties without your
|
||||
explicit consent. With typeframes.ai, you
|
||||
can focus on creating amazing videos while
|
||||
we take care of keeping your data safe and
|
||||
sound.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-[#1c1e21] rounded-xl flex flex-col justify-center px-6 md:pt-10 space-y-2 transition-all py-4 lg:py-3">
|
||||
<div className="flex items-start justify-between lg:pt-2 ">
|
||||
<h5 className="faq-question text-white font-medium hover:cursor-pointer my-auto">
|
||||
How much time and effort can typeframes.ai
|
||||
save me?
|
||||
</h5>
|
||||
<div className="relative flex items-center justify-center h-fit">
|
||||
<span className="cursor-pointer text-xl text-green-400 transition-all rotate-0">
|
||||
<svg
|
||||
stroke="currentColor"
|
||||
fill="currentColor"
|
||||
stroke-width="0"
|
||||
viewBox="0 0 256 256"
|
||||
height="18"
|
||||
width="18"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path d="M228,128a12,12,0,0,1-12,12H140v76a12,12,0,0,1-24,0V140H40a12,12,0,0,1,0-24h76V40a12,12,0,0,1,24,0v76h76A12,12,0,0,1,228,128Z"></path>
|
||||
</svg>
|
||||
</span>
|
||||
<span className="absolute blur-md bg-green-300/50 font-bold text-xs top-1/2 pointer-event-none h-2 w-2"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-neutral-dark text-sm transition-all duration-200 max-h-0 overflow-hidden whitespace-pre-line">
|
||||
typeframes.ai is like having a full video
|
||||
production team at your beck and call, 24/7.
|
||||
Our AI handles the heavy lifting, from
|
||||
researching viral trends to generating
|
||||
scripts and visuals. What used to take hours
|
||||
or even days can now be accomplished in
|
||||
minutes. And with our Automations feature,
|
||||
you can even set typeframes.ai to create
|
||||
videos for you on autopilot. It's the
|
||||
ultimate time-saver for busy creators who
|
||||
don't want to compromise on quality!
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-[#1c1e21] rounded-xl flex flex-col justify-center px-6 md:pt-10 space-y-2 transition-all py-4 lg:py-3">
|
||||
<div className="flex items-start justify-between lg:pt-2 ">
|
||||
<h5 className="faq-question text-white font-medium hover:cursor-pointer my-auto">
|
||||
How does typeframes.ai stay ahead of the
|
||||
curve in video creation?
|
||||
</h5>
|
||||
<div className="relative flex items-center justify-center h-fit">
|
||||
<span className="cursor-pointer text-xl text-green-400 transition-all rotate-0">
|
||||
<svg
|
||||
stroke="currentColor"
|
||||
fill="currentColor"
|
||||
stroke-width="0"
|
||||
viewBox="0 0 256 256"
|
||||
height="18"
|
||||
width="18"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path d="M228,128a12,12,0,0,1-12,12H140v76a12,12,0,0,1-24,0V140H40a12,12,0,0,1,0-24h76V40a12,12,0,0,1,24,0v76h76A12,12,0,0,1,228,128Z"></path>
|
||||
</svg>
|
||||
</span>
|
||||
<span className="absolute blur-md bg-green-300/50 font-bold text-xs top-1/2 pointer-event-none h-2 w-2"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-neutral-dark text-sm transition-all duration-200 max-h-0 overflow-hidden whitespace-pre-line">
|
||||
Our team of video experts and AI engineers
|
||||
are constantly pushing the boundaries of
|
||||
what's possible. We stay on top of the
|
||||
latest trends, platform updates, and best
|
||||
practices to ensure that typeframes.ai
|
||||
remains the cutting-edge tool for creating
|
||||
irresistible videos. As the digital
|
||||
landscape evolves, so does typeframes.ai,
|
||||
giving you a competitive edge in your
|
||||
content creation game.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-[#1c1e21] rounded-xl flex flex-col justify-center px-6 md:pt-10 space-y-2 transition-all py-4 lg:py-3">
|
||||
<div className="flex items-start justify-between lg:pt-2 ">
|
||||
<h5 className="faq-question text-white font-medium hover:cursor-pointer my-auto">
|
||||
What if I need help or have questions?
|
||||
</h5>
|
||||
<div className="relative flex items-center justify-center h-fit">
|
||||
<span className="cursor-pointer text-xl text-green-400 transition-all rotate-0">
|
||||
<svg
|
||||
stroke="currentColor"
|
||||
fill="currentColor"
|
||||
stroke-width="0"
|
||||
viewBox="0 0 256 256"
|
||||
height="18"
|
||||
width="18"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path d="M228,128a12,12,0,0,1-12,12H140v76a12,12,0,0,1-24,0V140H40a12,12,0,0,1,0-24h76V40a12,12,0,0,1,24,0v76h76A12,12,0,0,1,228,128Z"></path>
|
||||
</svg>
|
||||
</span>
|
||||
<span className="absolute blur-md bg-green-300/50 font-bold text-xs top-1/2 pointer-event-none h-2 w-2"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-neutral-dark text-sm transition-all duration-200 max-h-0 overflow-hidden whitespace-pre-line">
|
||||
We're here for you every step of the way!
|
||||
Our friendly support team, made up of video
|
||||
experts and creators like you, is always
|
||||
ready to answer your questions, provide
|
||||
guidance, and help you get the most out of
|
||||
typeframes.ai. We're not just a software
|
||||
company – we're a community of passionate
|
||||
creators dedicated to helping you succeed.
|
||||
Consider us your personal video creation
|
||||
cheerleaders!
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-[#1c1e21] rounded-xl flex flex-col justify-center px-6 md:pt-10 space-y-2 transition-all py-4 lg:py-3">
|
||||
<div className="flex items-start justify-between lg:pt-2 ">
|
||||
<h5 className="faq-question text-white font-medium hover:cursor-pointer my-auto">
|
||||
Can I try typeframes.ai for free?
|
||||
</h5>
|
||||
<div className="relative flex items-center justify-center h-fit">
|
||||
<span className="cursor-pointer text-xl text-green-400 transition-all rotate-0">
|
||||
<svg
|
||||
stroke="currentColor"
|
||||
fill="currentColor"
|
||||
stroke-width="0"
|
||||
viewBox="0 0 256 256"
|
||||
height="18"
|
||||
width="18"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path d="M228,128a12,12,0,0,1-12,12H140v76a12,12,0,0,1-24,0V140H40a12,12,0,0,1,0-24h76V40a12,12,0,0,1,24,0v76h76A12,12,0,0,1,228,128Z"></path>
|
||||
</svg>
|
||||
</span>
|
||||
<span className="absolute blur-md bg-green-300/50 font-bold text-xs top-1/2 pointer-event-none h-2 w-2"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-neutral-dark text-sm transition-all duration-200 max-h-0 overflow-hidden whitespace-pre-line">
|
||||
<div>
|
||||
While theres no free version of
|
||||
typeframes.ai, we do offer a suite of{" "}
|
||||
<a href="/tools" className="underline">
|
||||
AI-powered mini-tools
|
||||
</a>{" "}
|
||||
that you can take for a spin.Get a taste
|
||||
of the magic by creating clips from
|
||||
YouTube videos, generating AI avatar
|
||||
videos, and more. And when youre ready
|
||||
to unleash the full power of
|
||||
typeframes.ai, our flexible pricing
|
||||
plans make it easy to find the perfect
|
||||
fit for your needs and budget. Trust us,
|
||||
once you experience the typeframes.ai
|
||||
difference, youll wonder how you ever
|
||||
created content without it!
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
37
src/ui/page/page-footer.tsx
Normal file
37
src/ui/page/page-footer.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { useTranslations } from "next-intl";
|
||||
export default function PageFooter() {
|
||||
const t = useTranslations("pageFooter");
|
||||
return (
|
||||
<footer className="content-visibility-auto bg-[#15171A] border-t border-[#252629] w-full">
|
||||
<section className="mx-auto max-w-screen-2xl py-16 px-4 sm:px-6 md:px-16 text-sm space-y-8">
|
||||
{/* 这里可以添加您的其他 footer 内容 */}
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="text-center text-gray-300 text-sm">
|
||||
© {new Date().getFullYear()}{" "}
|
||||
<span>{t("companyName")}</span> |{" "}
|
||||
<span>{t("companyNameEn")}</span>
|
||||
</div>
|
||||
<div className="text-center text-gray-300 text-sm mt-2">
|
||||
<a href={t("domain1")} target="_blank" rel="noopener noreferrer">
|
||||
{t("domain1")}
|
||||
</a>{" "}
|
||||
|{" "}
|
||||
<a href={t("domain2")} target="_blank" rel="noopener noreferrer">
|
||||
{t("domain2")}
|
||||
</a>
|
||||
</div>
|
||||
<div className="text-center text-gray-300 text-sm mt-2">
|
||||
{t("icpNumber")}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col-reverse md:flex-row justify-between mt-8">
|
||||
{/* 其他内容 */}
|
||||
<div className="text-[#BEFFD6] underline underline-offset-2 space-x-3">
|
||||
<a href="/terms">{t("termsOfService")}</a>
|
||||
<a href="/terms">{t("privacyPolicy")}</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
64
src/ui/page/page-header.tsx
Normal file
64
src/ui/page/page-header.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import Image from "next/image";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import logoImage from "/public/images/logo.png";
|
||||
import Link from "next/link";
|
||||
export default function PageHeader() {
|
||||
const t = useTranslations("pageHeader");
|
||||
return (
|
||||
<div className="max-w-screen-2xl box-border bg-opacity-0 z-[99] absolute px-4 md:px-16 lg:px-20 top-0 w-screen flex justify-center ">
|
||||
<div
|
||||
className="relative w-full px-4 flex items-center justify-between"
|
||||
style={{ height: "96px" }}
|
||||
>
|
||||
<Link href="/">
|
||||
<Image
|
||||
alt="typeframes.ai logo"
|
||||
fetchPriority="high"
|
||||
width={150}
|
||||
height={30}
|
||||
decoding="async"
|
||||
style={{ color: "transparent" }}
|
||||
src={logoImage}
|
||||
/>
|
||||
</Link>
|
||||
<div
|
||||
id="navbar"
|
||||
className="flex items-center justify-end gap-8"
|
||||
>
|
||||
<div
|
||||
id="navlinks"
|
||||
className="text-[#BEC0C7] text-base font-medium invisible absolute top-full left-0 z-20 w-full origin-top-right -translate-y-4 bg-[#101215] flex-col flex-wrap justify-end gap-6 p-6 shadow-2xl shadow-gray-600/10 transition-all duration-300 dark:border-gray-700 dark:bg-gray-800 dark:shadow-none lg:visible lg:relative lg:flex lg:w-auto lg:translate-y-0 lg:scale-100 lg:flex-row lg:items-center lg:gap-0 lg:border-none lg:bg-transparent lg:p-0 lg:opacity-100 lg:shadow-none lg:peer-checked:translate-y-0 dark:lg:bg-transparent"
|
||||
>
|
||||
<div className="lg:pr-4 lg:ml-8">
|
||||
<ul className="divide-y lg:divide-y-0 divide-white/5 gap-8 py-4 lg:hover:bg-transparent lg:hover:pl-0 tracking-wide lg:flex lg:space-y-0 lg:text-sm lg:items-center">
|
||||
<li>
|
||||
<Link
|
||||
className="btn-tf btn-tf-secondary lg:flex"
|
||||
href="/create"
|
||||
>
|
||||
{t("button")}
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
{/* <button
|
||||
aria-label="hamburger"
|
||||
id="hamburger"
|
||||
className="relative -mr-6 p-6 lg:hidden"
|
||||
>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="m-auto h-0.5 w-5 rounded bg-sky-50 transition duration-300 dark:bg-gray-300"
|
||||
></div>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="m-auto mt-1.5 h-0.5 w-5 rounded bg-sky-50 transition duration-300 dark:bg-gray-300"
|
||||
></div>
|
||||
</button> */}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
74
src/ui/page/page-intro.tsx
Normal file
74
src/ui/page/page-intro.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import { useTranslations } from "next-intl";
|
||||
import Image from "next/image";
|
||||
import starsImage from "/public/images/stars.avif";
|
||||
|
||||
export default function PageIntro() {
|
||||
const t = useTranslations("pageIntro");
|
||||
|
||||
return (
|
||||
<article className="rounded-lg px-4 sm:px-6 md:px-8 border border-[#252629] w-full gradient-bg lg:bg-none lg:bg-[#171B1C]">
|
||||
<div className="w-full relative border-x border-[#353e3b] md:px-8 py-[7.5%] grid lg:grid-cols-2 gap-4 xl:gap-8 items-start">
|
||||
<section className="space-y-6 mt-12 lg:mt-0 pl-4 xl:pl-8 flex-2">
|
||||
<h1 className='xl:min-w-[520px] text-4xl sm:text-[47px] xl:text-6xl 2xl:text-7xl lg:text-left leading-[50px] text-center font-["Euclid_Circular_A"] text-white'>
|
||||
{t("heading")}{" "}
|
||||
<span className="font-medium from-[#45EC82] from-[0.16%] via-[#7079F3] via-[47.81%] to-[#75CEFC] to-100% bg-gradient-to-r bg-clip-text text-transparent">
|
||||
{t("highlightedText")}
|
||||
</span>
|
||||
</h1>
|
||||
<p className="text-neutral-dark text-sm 2xl:text-base text-center lg:text-left mx-auto lg:mx-0 leading-normal">
|
||||
{t("description.line1")}
|
||||
<br />
|
||||
{t("description.line2")}
|
||||
<br />
|
||||
{t("description.line3")}
|
||||
</p>
|
||||
<div className="btn-tf-primary-container w-fit mx-auto lg:mx-0">
|
||||
<a className="btn-tf btn-tf-primary" href="/projects">
|
||||
{t("startForFree")}
|
||||
</a>
|
||||
</div>
|
||||
<div className="space-y-5 flex flex-col items-center lg:items-start">
|
||||
<div className="space-y-2 flex flex-col items-center lg:items-start">
|
||||
<Image
|
||||
alt="ratings"
|
||||
loading="lazy"
|
||||
width={88}
|
||||
height={16}
|
||||
decoding="async"
|
||||
className="object-contain"
|
||||
src={starsImage}
|
||||
/>
|
||||
<article className="flex items-center flex-col w-full mt-20 space-y-2">
|
||||
<div className="mt-0 flex flex-col justify-center items-center">
|
||||
<div className="flex gap-4 flex-col items-center md:flex-row md:items-end">
|
||||
<div className="text-info text-sm italic">
|
||||
{t("ratings")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section className="invisible lg:visible absolute lg:relative space-y-5 w-full flex flex-col justify-center">
|
||||
<div className="flex justify-center">
|
||||
<div className="flex w-auto h-[600px]">
|
||||
<video
|
||||
className="content-visibility-auto bg-white shadow-2xl rounded-[13px] border border-base-200"
|
||||
playsInline
|
||||
loop
|
||||
muted
|
||||
autoPlay
|
||||
src="https://cdn.tfrv.xyz/renders/gjcIDd3JXNbmRF7DcHp7/7FnJlEDbR1qYD35MGZ6U-1716820116050.mp4"
|
||||
preload="auto"
|
||||
></video>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-center text-sm text-neutral-dark">
|
||||
{t("madeWithLove")}
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
110
src/ui/page/page-remark.tsx
Normal file
110
src/ui/page/page-remark.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export default function PageRemark() {
|
||||
const t = useTranslations("pageRemark");
|
||||
return (
|
||||
<section className="content-visibility-auto text-neutral-dark mt-24 relative -left-[10%] lg:-left-16 flex w-[120%] lg:w-[110%] justify-center items-center mx-auto">
|
||||
<div className="relative w-[10%] lg:w-[50%] mr-auto space-y-2">
|
||||
<div className="rfm-marquee-container">
|
||||
<div className="rfm-marquee">
|
||||
<div className="rfm-initial-child-container">
|
||||
<div className="rfm-child">
|
||||
{/* <img alt="easy to use" loading="lazy" width="500" height="300" decoding="async" data-nimg="1" className="rotate-180 mx-auto aspect-auto rounded-t-xl object-cover object-center" src="/images/purple-right-neon-line.svg" style={{ color: 'transparent', width: '100%', height: 'auto' }} /> */}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rfm-marquee">
|
||||
<div className="rfm-child">
|
||||
{/* <img alt="easy to use" loading="lazy" width="500" height="300" decoding="async" data-nimg="1" className="rotate-180 mx-auto aspect-auto rounded-t-xl object-cover object-center" src="/images/purple-right-neon-line.svg" style={{ color: 'transparent', width: '100%', height: 'auto' }} /> */}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rfm-marquee-container">
|
||||
<div className="rfm-marquee">
|
||||
<div className="rfm-initial-child-container">
|
||||
<div className="rfm-child">
|
||||
{/* <img alt="easy to use" loading="lazy" width="500" height="300" decoding="async" data-nimg="1" className="mx-auto aspect-auto rounded-t-xl object-cover object-center" src="/images/green-left-neon-line.svg" style={{ color: 'transparent', width: '100%', height: 'auto' }} /> */}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rfm-marquee">
|
||||
<div className="rfm-child">
|
||||
{/* <img alt="easy to use" loading="lazy" width="500" height="300" decoding="async" data-nimg="1" className="mx-auto aspect-auto rounded-t-xl object-cover object-center" src="/images/green-left-neon-line.svg" style={{ color: 'transparent', width: '100%', height: 'auto' }} /> */}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rfm-marquee-container">
|
||||
<div className="rfm-marquee">
|
||||
<div className="rfm-initial-child-container">
|
||||
<div className="rfm-child">
|
||||
{/* <img alt="easy to use" loading="lazy" width="500" height="300" decoding="async" data-nimg="1" className="mx-auto aspect-auto rounded-t-xl object-cover w-1/2 object-right" src="/images/blue-left-neon-line.svg" style={{ color: 'transparent', width: '100%', height: 'auto' }} /> */}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rfm-marquee">
|
||||
<div className="rfm-child">
|
||||
{/* <img alt="easy to use" loading="lazy" width="500" height="300" decoding="async" data-nimg="1" className="mx-auto aspect-auto rounded-t-xl object-cover w-1/2 object-right" src="/images/blue-left-neon-line.svg" style={{ color: 'transparent', width: '100%', height: 'auto' }} /> */}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-5 py-4 flex flex-col items-center w-[80%] lg:w-full border-x border-[#1a1b1e]">
|
||||
<h2 className='text-white font-["Euclid_Circular_A"] text-3xl lg:text-4xl xl:text-5xl text-center w-11/12 text-balance mx-auto'>
|
||||
{t("heading")}
|
||||
</h2>
|
||||
<p className="text-center w-[95%] mx-auto text-balance">
|
||||
{t("description")}
|
||||
</p>
|
||||
<div className="btn-tf-primary-container mx-auto lg:mx-0">
|
||||
<a className="btn-tf btn-tf-primary" href="/create">
|
||||
{t("createVideosNow")}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative w-[10%] lg:w-[50%] ml-auto space-y-2">
|
||||
<div className="rfm-marquee-container">
|
||||
<div className="rfm-marquee">
|
||||
<div className="rfm-initial-child-container">
|
||||
<div className="rfm-child">
|
||||
{/* <img alt="easy to use" loading="lazy" width="500" height="300" decoding="async" data-nimg="1" className="mx-auto aspect-auto rounded-t-xl object-cover w-1/2 object-right" src="/images/blue-right-neon-line.svg" style={{ color: 'transparent', width: '100%', height: 'auto' }} /> */}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rfm-marquee">
|
||||
<div className="rfm-child">
|
||||
{/* <img alt="easy to use" loading="lazy" width="500" height="300" decoding="async" data-nimg="1" className="mx-auto aspect-auto rounded-t-xl object-cover w-1/2 object-right" src="/images/blue-right-neon-line.svg" style={{ color: 'transparent', width: '100%', height: 'auto' }} /> */}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rfm-marquee-container">
|
||||
<div className="rfm-marquee">
|
||||
<div className="rfm-initial-child-container">
|
||||
<div className="rfm-child">
|
||||
{/* <img alt="easy to use" loading="lazy" width="500" height="300" decoding="async" data-nimg="1" className="mx-auto aspect-auto rounded-t-xl object-cover object-center" src="/images/green-right-neon-line.svg" style={{ color: 'transparent', width: '100%', height: 'auto' }} /> */}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rfm-marquee">
|
||||
<div className="rfm-child">
|
||||
{/* <img alt="easy to use" loading="lazy" width="500" height="300" decoding="async" data-nimg="1" className="mx-auto aspect-auto rounded-t-xl object-cover object-center" src="/images/green-right-neon-line.svg" style={{ color: 'transparent', width: '100%', height: 'auto' }} /> */}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rfm-marquee-container">
|
||||
<div className="rfm-marquee">
|
||||
<div className="rfm-initial-child-container">
|
||||
<div className="rfm-child">
|
||||
{/* <img alt="easy to use" loading="lazy" width="500" height="300" decoding="async" data-nimg="1" className="mx-auto aspect-auto rounded-t-xl object-cover object-center" src="/images/purple-right-neon-line.svg" style={{ color: 'transparent', width: '100%', height: 'auto' }} /> */}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rfm-marquee">
|
||||
<div className="rfm-child">
|
||||
{/* <img alt="easy to use" loading="lazy" width="500" height="300" decoding="async" data-nimg="1" className="mx-auto aspect-auto rounded-t-xl object-cover object-center" src="/images/purple-right-neon-line.svg" style={{ color: 'transparent', width: '100%', height: 'auto' }} /> */}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
44
src/ui/page/page-statis.tsx
Normal file
44
src/ui/page/page-statis.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { useTranslations } from "next-intl";
|
||||
export default function PageStatis() {
|
||||
const t = useTranslations("pageStatis");
|
||||
|
||||
return (
|
||||
<article className="gradient-bg rounded-lg px-4 sm:px-6 md:px-8 pb-8 border border-[#252629] mt-4">
|
||||
<div className="rounded-b-xl h-8 sm:h-10 border border-t-0 border-[#353E3B]"></div>
|
||||
<section className="-mt-3 lg:pt-10 py-8 rounded-lg rounded-t-none border border-t-0 border-[#353e3b] divide-y sm:divide-x sm:divide-y-0 divide-[#353e3b] grid sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div className="text-center sm:px-8 lg:px-12 mx-8 sm:mx-0 py-10 sm:py-4 space-y-2">
|
||||
<h2 className='font-["Euclid_Circular_A"] text-3xl font-bold bg-gradient-to-tr from-[#5AE88F] from-25% to-[#78CDF9] to-60% bg-clip-text text-transparent'>
|
||||
{t("creatorsUsing.number")}
|
||||
</h2>
|
||||
<p className="text-neutral-dark text-sm">
|
||||
{t("creatorsUsing.description")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-center sm:px-8 lg:px-12 mx-8 sm:mx-0 py-10 sm:py-4 space-y-2">
|
||||
<h2 className='font-["Euclid_Circular_A"] text-3xl font-bold bg-gradient-to-tr from-[#5AE88F] from-25% to-[#78CDF9] to-60% bg-clip-text text-transparent'>
|
||||
{t("videoEngagement.percentage")}
|
||||
</h2>
|
||||
<p className="text-neutral-dark text-sm">
|
||||
{t("videoEngagement.description")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-center sm:px-8 lg:px-12 mx-8 sm:mx-0 py-10 sm:py-4 space-y-2">
|
||||
<h2 className='font-["Euclid_Circular_A"] text-3xl font-bold bg-gradient-to-tr from-[#5AE88F] from-25% to-[#78CDF9] to-60% bg-clip-text text-transparent'>
|
||||
{t("videosMade.number")}
|
||||
</h2>
|
||||
<p className="text-neutral-dark text-sm">
|
||||
{t("videosMade.description")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-center sm:px-8 lg:px-12 mx-8 sm:mx-0 py-10 sm:py-4 space-y-2">
|
||||
<h2 className='font-["Euclid_Circular_A"] text-3xl font-bold bg-gradient-to-tr from-[#5AE88F] from-25% to-[#78CDF9] to-60% bg-clip-text text-transparent'>
|
||||
{t("monthlyGrowth.percentage")}
|
||||
</h2>
|
||||
<p className="text-neutral-dark text-sm">
|
||||
{t("monthlyGrowth.description")}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
368
src/ui/page/page-tools.tsx
Normal file
368
src/ui/page/page-tools.tsx
Normal file
@@ -0,0 +1,368 @@
|
||||
export default function PageTools() {
|
||||
return (
|
||||
<article className="mt-8 content-visibility-auto relative text-neutral-dark rounded-lg px-4 sm:px-6 md:px-8 border border-[#252629] w-full bg-[#15171a] mb-6">
|
||||
<div className="h-px w-full bg-[#353e3b] top-4 sm:top-6 md:top-8 absolute -z-0 left-0"></div>
|
||||
<div className="h-px w-full bg-[#353e3b] bottom-4 sm:bottom-6 md:bottom-8 absolute z-0 left-0"></div>
|
||||
<div className="w-full relative border-x border-[#353e3b]">
|
||||
<div className="absolute z-0 w-full h-full grid lg:grid-cols-2 gap-8 items-center">
|
||||
<section className="z-0 absolute w-full h-full col-span-2 grid grid-cols-2 place-content-between">
|
||||
<div className="bg-[#4BDE81] rounded-full w-1 h-1 my-4 sm:my-6 md:my-8 outline outline-8 outline-[#15171A] -mx-[2.5px] "></div>
|
||||
<div className="bg-[#4BDE81] rounded-full w-1 h-1 my-4 sm:my-6 md:my-8 outline outline-8 outline-[#15171A] -mx-[2px] place-self-end"></div>
|
||||
<div className="bg-[#4BDE81] rounded-full w-1 h-1 my-4 sm:my-6 md:my-8 outline outline-8 outline-[#15171A] -mx-[2.5px] "></div>
|
||||
<div className="bg-[#4BDE81] rounded-full w-1 h-1 my-4 sm:my-6 md:my-8 outline outline-8 outline-[#15171A] -mx-[2px] place-self-end"></div>
|
||||
</section>
|
||||
</div>
|
||||
<div className="relative z-20 mx-auto py-12 lg:py-24">
|
||||
<div className="space-y-4">
|
||||
<div className="mx-auto text-green-200 border border-[#c8eed6]/25 shadow-lg shadow-[#4add80]/10 w-fit font-medium text-sm rounded-full bg-gradient-to-b from-[#737b88]/20 to-[#191b1e]/20 p-[1.5px]">
|
||||
<div className="bg-[#15171a]/50 flex items-center space-x-1 py-1 px-2 rounded-full ">
|
||||
{/* <IconTools width={17} height={16} /> */}
|
||||
<span className="text-sm">Tools</span>
|
||||
</div>
|
||||
</div>
|
||||
<h2 className='text-white font-["Euclid_Circular_A"] text-3xl lg:text-4xl xl:text-5xl text-center w-11/12 max-w-3xl text-pretty mx-auto'>
|
||||
Easily create TikTok videos with pre-made tools
|
||||
</h2>
|
||||
<p className="text-center w-[90%] max-w-lg mx-auto text-pretty">
|
||||
Pick the right tool, provides your input, and youll
|
||||
create a video in no time - customize it however you
|
||||
want.
|
||||
</p>
|
||||
</div>
|
||||
<div className="diagonal-pattern w-full border-y border-[#353e3b] my-12">
|
||||
<div className="*:p-1.5 *:bg-transparent *:h-full border-x border-[#353e3b] bg-[#15171a] grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 w-[90%] md:w-[85%] lg:w-[75%] sm:divide-x divide-y divide-[#353e3b] mx-auto">
|
||||
<div>
|
||||
<div className="bg-[#1c1e21] rounded p-6 h-full">
|
||||
<div className="relative w-10 ">
|
||||
{/* <img alt="youtube" loading="lazy" width="500" height="300" decoding="async" data-nimg="1" className="mx-auto aspect-auto object-cover" style={{ color: 'transparent', width: '100%', height: 'auto' }} srcSet="/images/youtube.png 1x, /images/youtube.png 2x" src="/images/youtube.png" /> */}
|
||||
</div>
|
||||
<h4 className="text-white text-lg font-medium mt-4">
|
||||
Create clips from YouTube
|
||||
</h4>
|
||||
<p>
|
||||
Convert your YouTube video into a short
|
||||
vertical
|
||||
</p>
|
||||
<a
|
||||
className="btn-tf-sm btn-tf-secondary font-medium flex items-center mt-12 w-fit"
|
||||
href="/tools/create-short-video-clip"
|
||||
>
|
||||
Try it out
|
||||
<svg
|
||||
width="16"
|
||||
height="17"
|
||||
viewBox="0 0 16 17"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="ml-2"
|
||||
>
|
||||
<path
|
||||
d="M9.61914 4.45312L13.6658 8.49979L9.61914 12.5465"
|
||||
stroke="white"
|
||||
stroke-width="1.5"
|
||||
stroke-miterlimit="10"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
></path>
|
||||
<path
|
||||
d="M2.33398 8.5H13.554"
|
||||
stroke="white"
|
||||
stroke-width="1.5"
|
||||
stroke-miterlimit="10"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
></path>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="bg-[#1c1e21] rounded p-6 h-full">
|
||||
<div className="relative w-10 ">
|
||||
{/* <img alt="tiktok" loading="lazy" width="500" height="300" decoding="async" data-nimg="1" className="mx-auto aspect-auto object-cover" style={{ color: 'transparent', width: '100%', height: 'auto' }} srcSet="/images/tiktok.png 1x, /images/tiktok.png 2x" src="/images/tiktok.png" /> */}
|
||||
</div>
|
||||
<h4 className="text-white text-lg font-medium mt-4">
|
||||
Create TikTok videos
|
||||
</h4>
|
||||
<p>Convert your text into a TikTok video</p>
|
||||
<a
|
||||
className="btn-tf-sm btn-tf-secondary font-medium flex items-center mt-12 w-fit"
|
||||
href="/tools/create-tiktok-video"
|
||||
>
|
||||
Try it out
|
||||
<svg
|
||||
width="16"
|
||||
height="17"
|
||||
viewBox="0 0 16 17"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="ml-2"
|
||||
>
|
||||
<path
|
||||
d="M9.61914 4.45312L13.6658 8.49979L9.61914 12.5465"
|
||||
stroke="white"
|
||||
stroke-width="1.5"
|
||||
stroke-miterlimit="10"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
></path>
|
||||
<path
|
||||
d="M2.33398 8.5H13.554"
|
||||
stroke="white"
|
||||
stroke-width="1.5"
|
||||
stroke-miterlimit="10"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
></path>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="bg-[#1c1e21] rounded p-6 h-full">
|
||||
<div className="relative w-10 ">
|
||||
<svg
|
||||
stroke="currentColor"
|
||||
fill="currentColor"
|
||||
stroke-width="0"
|
||||
viewBox="0 0 256 256"
|
||||
className="mx-auto text-white"
|
||||
height="40"
|
||||
width="40"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path d="M160,40a32,32,0,1,0-32,32A32,32,0,0,0,160,40ZM128,56a16,16,0,1,1,16-16A16,16,0,0,1,128,56Zm90.34,78.05L173.17,82.83a32,32,0,0,0-24-10.83H106.83a32,32,0,0,0-24,10.83L37.66,134.05a20,20,0,0,0,28.13,28.43l16.3-13.08L65.55,212.28A20,20,0,0,0,102,228.8l26-44.87,26,44.87a20,20,0,0,0,36.41-16.52L173.91,149.4l16.3,13.08a20,20,0,0,0,28.13-28.43Zm-11.51,16.77a4,4,0,0,1-5.66,0c-.21-.2-.42-.4-.65-.58L165,121.76A8,8,0,0,0,152.26,130L175.14,217a7.72,7.72,0,0,0,.48,1.35,4,4,0,1,1-7.25,3.38,6.25,6.25,0,0,0-.33-.63L134.92,164a8,8,0,0,0-13.84,0L88,221.05a6.25,6.25,0,0,0-.33.63,4,4,0,0,1-2.26,2.07,4,4,0,0,1-5-5.45,7.72,7.72,0,0,0,.48-1.35L103.74,130A8,8,0,0,0,91,121.76L55.48,150.24c-.23.18-.44.38-.65.58a4,4,0,1,1-5.66-5.65c.12-.12.23-.24.34-.37L94.83,93.41a16,16,0,0,1,12-5.41h42.34a16,16,0,0,1,12,5.41l45.32,51.39c.11.13.22.25.34.37A4,4,0,0,1,206.83,150.82Z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<h4 className="text-white text-lg font-medium mt-4">
|
||||
Create An AI avatar Video
|
||||
</h4>
|
||||
<p>
|
||||
Generate a vertical video with a talking
|
||||
avatar
|
||||
</p>
|
||||
<a
|
||||
className="btn-tf-sm btn-tf-secondary font-medium flex items-center mt-12 w-fit"
|
||||
href="/tools/create-avatar-video"
|
||||
>
|
||||
Try it out
|
||||
<svg
|
||||
width="16"
|
||||
height="17"
|
||||
viewBox="0 0 16 17"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="ml-2"
|
||||
>
|
||||
<path
|
||||
d="M9.61914 4.45312L13.6658 8.49979L9.61914 12.5465"
|
||||
stroke="white"
|
||||
stroke-width="1.5"
|
||||
stroke-miterlimit="10"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
></path>
|
||||
<path
|
||||
d="M2.33398 8.5H13.554"
|
||||
stroke="white"
|
||||
stroke-width="1.5"
|
||||
stroke-miterlimit="10"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
></path>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="bg-[#1c1e21] rounded p-6 h-full">
|
||||
<div className="relative w-10 ">
|
||||
<svg
|
||||
stroke="currentColor"
|
||||
fill="currentColor"
|
||||
stroke-width="0"
|
||||
viewBox="0 0 24 24"
|
||||
className="mx-auto text-white"
|
||||
height="40"
|
||||
width="40"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path d="M11.0526 7.8154L11.8042 4.27941C12.0339 3.19898 13.0959 2.50929 14.1764 2.73894L17.3725 3.4183C17.8351 2.90546 18.5509 2.64644 19.275 2.80035C20.3554 3.03 21.0451 4.09204 20.8155 5.17247C20.5858 6.2529 19.5238 6.94259 18.4434 6.71294C17.7193 6.55903 17.1707 6.03124 16.9567 5.3746L13.7605 4.69523L13.0943 7.82978C14.8789 7.96683 16.6522 8.56079 18.2581 9.52961C19.0892 9.06763 20.0992 8.99337 21.007 9.36091C22.1731 9.833 22.9531 10.9459 22.9991 12.2031L22.9996 12.2241C23.0151 13.2277 22.559 14.1657 21.792 14.7742C21.7899 14.8178 21.7871 14.859 21.7836 14.8971C21.7836 18.8949 17.3341 21.9267 11.9852 21.9267C6.65232 21.9267 2.27693 18.9027 2.27987 14.9738C2.27523 14.9134 2.27162 14.853 2.26905 14.7926C1.46701 14.1873 0.984722 13.2277 1.00037 12.1962C1.01955 10.9317 1.78341 9.79777 2.94804 9.30491C3.85909 8.91936 4.881 8.98299 5.72371 9.44381C7.3578 8.46653 9.15777 7.91241 11.0526 7.8154ZM20.3385 13.341C20.7466 13.1382 21.003 12.7207 21.0001 12.2656C20.9789 11.8005 20.6887 11.3897 20.2565 11.2147C19.821 11.0384 19.3226 11.1343 18.9837 11.4597L18.3991 12.0207L17.729 11.5652C16.1137 10.4672 14.2771 9.8397 12.4995 9.80134L11.493 9.80123C9.61791 9.8295 7.84136 10.4002 6.25552 11.4757L5.59246 11.9254L5.00897 11.3764C4.66501 11.0528 4.16243 10.9627 3.7275 11.1468C3.29257 11.3308 3.0073 11.7543 3 12.2265C2.99298 12.6987 3.26526 13.1307 3.69441 13.3278L4.32738 13.6186L4.27399 14.3132C4.261 14.482 4.261 14.6516 4.27693 14.8971C4.27693 17.6071 7.63313 19.9267 11.9852 19.9267C16.3561 19.9267 19.7836 17.5913 19.7865 14.8205C19.7995 14.6516 19.7995 14.482 19.7865 14.3132L19.7348 13.6411L20.3385 13.341ZM6.95075 13.4999C6.95075 12.6715 7.62232 11.9999 8.45075 11.9999C9.27918 11.9999 9.95075 12.6715 9.95075 13.4999C9.95075 14.3283 9.27918 14.9999 8.45075 14.9999C8.05292 14.9999 7.67139 14.8419 7.39009 14.5606C7.10878 14.2793 6.95075 13.8977 6.95075 13.4999ZM13.9507 13.4999C13.9507 12.6715 14.6223 11.9999 15.4507 11.9999C16.2792 11.9999 16.9507 12.6715 16.9507 13.4999C16.9507 14.3283 16.2792 14.9999 15.4507 14.9999C15.0529 14.9999 14.6714 14.8419 14.3901 14.5606C14.1088 14.2793 13.9507 13.8977 13.9507 13.4999ZM11.9665 18.6028C10.5693 18.6028 9.19993 18.2329 8.08503 17.3928C7.94659 17.2241 7.95868 16.9779 8.11299 16.8236C8.2673 16.6693 8.51349 16.6572 8.68218 16.7956C9.62697 17.4886 10.805 17.7856 11.9507 17.7856C13.0965 17.7856 14.2813 17.5112 15.235 16.8271C15.3473 16.7176 15.5095 16.6763 15.6604 16.7188C15.8114 16.7613 15.9282 16.8811 15.9669 17.0331C16.0055 17.1851 15.9517 17.3346 15.8479 17.4556C15.1638 18.2531 13.3636 18.6028 11.9665 18.6028Z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<h4 className="text-white text-lg font-medium mt-4">
|
||||
Turn Audio into Video
|
||||
</h4>
|
||||
<p>
|
||||
Make engaging videos from your podcasts,
|
||||
interviews, or any audio content
|
||||
</p>
|
||||
<a
|
||||
className="btn-tf-sm btn-tf-secondary font-medium flex items-center mt-12 w-fit"
|
||||
href="/tools/audio-to-video"
|
||||
>
|
||||
Try it out
|
||||
<svg
|
||||
width="16"
|
||||
height="17"
|
||||
viewBox="0 0 16 17"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="ml-2"
|
||||
>
|
||||
<path
|
||||
d="M9.61914 4.45312L13.6658 8.49979L9.61914 12.5465"
|
||||
stroke="white"
|
||||
stroke-width="1.5"
|
||||
stroke-miterlimit="10"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
></path>
|
||||
<path
|
||||
d="M2.33398 8.5H13.554"
|
||||
stroke="white"
|
||||
stroke-width="1.5"
|
||||
stroke-miterlimit="10"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
></path>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="bg-[#1c1e21] rounded p-6 h-full">
|
||||
<div className="relative w-10 ">
|
||||
<svg
|
||||
stroke="currentColor"
|
||||
fill="currentColor"
|
||||
stroke-width="0"
|
||||
viewBox="0 0 24 24"
|
||||
className="mx-auto text-white"
|
||||
height="40"
|
||||
width="40"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path d="M11.0526 7.8154L11.8042 4.27941C12.0339 3.19898 13.0959 2.50929 14.1764 2.73894L17.3725 3.4183C17.8351 2.90546 18.5509 2.64644 19.275 2.80035C20.3554 3.03 21.0451 4.09204 20.8155 5.17247C20.5858 6.2529 19.5238 6.94259 18.4434 6.71294C17.7193 6.55903 17.1707 6.03124 16.9567 5.3746L13.7605 4.69523L13.0943 7.82978C14.8789 7.96683 16.6522 8.56079 18.2581 9.52961C19.0892 9.06763 20.0992 8.99337 21.007 9.36091C22.1731 9.833 22.9531 10.9459 22.9991 12.2031L22.9996 12.2241C23.0151 13.2277 22.559 14.1657 21.792 14.7742C21.7899 14.8178 21.7871 14.859 21.7836 14.8971C21.7836 18.8949 17.3341 21.9267 11.9852 21.9267C6.65232 21.9267 2.27693 18.9027 2.27987 14.9738C2.27523 14.9134 2.27162 14.853 2.26905 14.7926C1.46701 14.1873 0.984722 13.2277 1.00037 12.1962C1.01955 10.9317 1.78341 9.79777 2.94804 9.30491C3.85909 8.91936 4.881 8.98299 5.72371 9.44381C7.3578 8.46653 9.15777 7.91241 11.0526 7.8154ZM20.3385 13.341C20.7466 13.1382 21.003 12.7207 21.0001 12.2656C20.9789 11.8005 20.6887 11.3897 20.2565 11.2147C19.821 11.0384 19.3226 11.1343 18.9837 11.4597L18.3991 12.0207L17.729 11.5652C16.1137 10.4672 14.2771 9.8397 12.4995 9.80134L11.493 9.80123C9.61791 9.8295 7.84136 10.4002 6.25552 11.4757L5.59246 11.9254L5.00897 11.3764C4.66501 11.0528 4.16243 10.9627 3.7275 11.1468C3.29257 11.3308 3.0073 11.7543 3 12.2265C2.99298 12.6987 3.26526 13.1307 3.69441 13.3278L4.32738 13.6186L4.27399 14.3132C4.261 14.482 4.261 14.6516 4.27693 14.8971C4.27693 17.6071 7.63313 19.9267 11.9852 19.9267C16.3561 19.9267 19.7836 17.5913 19.7865 14.8205C19.7995 14.6516 19.7995 14.482 19.7865 14.3132L19.7348 13.6411L20.3385 13.341ZM6.95075 13.4999C6.95075 12.6715 7.62232 11.9999 8.45075 11.9999C9.27918 11.9999 9.95075 12.6715 9.95075 13.4999C9.95075 14.3283 9.27918 14.9999 8.45075 14.9999C8.05292 14.9999 7.67139 14.8419 7.39009 14.5606C7.10878 14.2793 6.95075 13.8977 6.95075 13.4999ZM13.9507 13.4999C13.9507 12.6715 14.6223 11.9999 15.4507 11.9999C16.2792 11.9999 16.9507 12.6715 16.9507 13.4999C16.9507 14.3283 16.2792 14.9999 15.4507 14.9999C15.0529 14.9999 14.6714 14.8419 14.3901 14.5606C14.1088 14.2793 13.9507 13.8977 13.9507 13.4999ZM11.9665 18.6028C10.5693 18.6028 9.19993 18.2329 8.08503 17.3928C7.94659 17.2241 7.95868 16.9779 8.11299 16.8236C8.2673 16.6693 8.51349 16.6572 8.68218 16.7956C9.62697 17.4886 10.805 17.7856 11.9507 17.7856C13.0965 17.7856 14.2813 17.5112 15.235 16.8271C15.3473 16.7176 15.5095 16.6763 15.6604 16.7188C15.8114 16.7613 15.9282 16.8811 15.9669 17.0331C16.0055 17.1851 15.9517 17.3346 15.8479 17.4556C15.1638 18.2531 13.3636 18.6028 11.9665 18.6028Z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<h4 className="text-white text-lg font-medium mt-4">
|
||||
Turn a Reddit post into a Video
|
||||
</h4>
|
||||
<p>
|
||||
Make engaging videos from your podcasts,
|
||||
interviews, or any audio content
|
||||
</p>
|
||||
<a
|
||||
className="btn-tf-sm btn-tf-secondary font-medium flex items-center mt-12 w-fit"
|
||||
href="/tools/audio-to-video"
|
||||
>
|
||||
Try it out
|
||||
<svg
|
||||
width="16"
|
||||
height="17"
|
||||
viewBox="0 0 16 17"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="ml-2"
|
||||
>
|
||||
<path
|
||||
d="M9.61914 4.45312L13.6658 8.49979L9.61914 12.5465"
|
||||
stroke="white"
|
||||
stroke-width="1.5"
|
||||
stroke-miterlimit="10"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
></path>
|
||||
<path
|
||||
d="M2.33398 8.5H13.554"
|
||||
stroke="white"
|
||||
stroke-width="1.5"
|
||||
stroke-miterlimit="10"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
></path>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="bg-[#1c1e21] rounded p-6 h-full">
|
||||
<div className="relative w-10 ">
|
||||
{/* <img alt="twitter/x" loading="lazy" width="500" height="300" decoding="async" data-nimg="1" className="mx-auto aspect-auto object-cover" style={{ color: 'transparent', width: '100%', height: 'auto' }} srcSet="/images/x-logo-white.png 1x, /images/x-logo-white.png 2x" src="/images/x-logo-white.png" /> */}
|
||||
</div>
|
||||
<h4 className="text-white text-lg font-medium mt-4">
|
||||
Tweet/X to Video
|
||||
</h4>
|
||||
<p>Convert your tweets to video</p>
|
||||
<a
|
||||
className="btn-tf-sm btn-tf-secondary font-medium flex items-center mt-12 w-fit"
|
||||
href="/tools/tweet-to-video"
|
||||
>
|
||||
Try it out
|
||||
<svg
|
||||
width="16"
|
||||
height="17"
|
||||
viewBox="0 0 16 17"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="ml-2"
|
||||
>
|
||||
<path
|
||||
d="M9.61914 4.45312L13.6658 8.49979L9.61914 12.5465"
|
||||
stroke="white"
|
||||
stroke-width="1.5"
|
||||
stroke-miterlimit="10"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
></path>
|
||||
<path
|
||||
d="M2.33398 8.5H13.554"
|
||||
stroke="white"
|
||||
stroke-width="1.5"
|
||||
stroke-miterlimit="10"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
></path>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<a
|
||||
className="btn-tf btn-tf-secondary font-normal flex items-center mx-auto w-fit"
|
||||
href="/tools"
|
||||
>
|
||||
See all tools
|
||||
<svg
|
||||
width="16"
|
||||
height="17"
|
||||
viewBox="0 0 16 17"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="ml-2"
|
||||
>
|
||||
<path
|
||||
d="M9.61914 4.45312L13.6658 8.49979L9.61914 12.5465"
|
||||
stroke="white"
|
||||
stroke-width="1.5"
|
||||
stroke-miterlimit="10"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
></path>
|
||||
<path
|
||||
d="M2.33398 8.5H13.554"
|
||||
stroke="white"
|
||||
stroke-width="1.5"
|
||||
stroke-miterlimit="10"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
></path>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
138
src/utils/request.ts
Normal file
138
src/utils/request.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import { redirect } from 'next/navigation'; // 如果在 SSR 中需要使用
|
||||
|
||||
import queryString from 'query-string';
|
||||
type Method = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
|
||||
|
||||
interface Params {
|
||||
cacheTime?: number; //缓存时间,单位为s。默认强缓存,0为不缓存
|
||||
params?: Record<string, any>;
|
||||
}
|
||||
|
||||
interface Props extends Params {
|
||||
url: string;
|
||||
method: Method;
|
||||
}
|
||||
|
||||
type Config = { next: { revalidate: number } } | { cache: 'no-store' } | { cache: 'force-cache' };
|
||||
|
||||
class Request {
|
||||
/**
|
||||
* 请求拦截器
|
||||
*/
|
||||
interceptorsRequest({ url, method, params, cacheTime }: Props) {
|
||||
let queryParams = ''; //url参数
|
||||
let requestPayload = ''; //请求体数据
|
||||
//请求头
|
||||
const headers = {
|
||||
authorization: `Bearer ...`,
|
||||
};
|
||||
|
||||
const config: Config =
|
||||
cacheTime || cacheTime === 0
|
||||
? cacheTime > 0
|
||||
? { next: { revalidate: cacheTime } }
|
||||
: { cache: 'no-store' }
|
||||
: { cache: 'force-cache' };
|
||||
|
||||
if (method === 'GET' || method === 'DELETE') {
|
||||
//fetch对GET请求等,不支持将参数传在body上,只能拼接url
|
||||
if (params) {
|
||||
queryParams = queryString.stringify(params);
|
||||
url = `${url}?${queryParams}`;
|
||||
}
|
||||
} else {
|
||||
//非form-data传输JSON数据格式
|
||||
if (!['[object FormData]', '[object URLSearchParams]'].includes(Object.prototype.toString.call(params))) {
|
||||
Object.assign(headers, { 'Content-Type': 'application/json' });
|
||||
requestPayload = JSON.stringify(params);
|
||||
}
|
||||
}
|
||||
return {
|
||||
url,
|
||||
options: {
|
||||
method,
|
||||
headers,
|
||||
body: method !== 'GET' && method !== 'DELETE' ? requestPayload : undefined,
|
||||
...config,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 响应拦截器
|
||||
*/
|
||||
interceptorsResponse<T>(res: Response): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const requestUrl = res.url;
|
||||
if (res.ok) {
|
||||
res
|
||||
.clone()
|
||||
.text()
|
||||
.then((text) => {
|
||||
try {
|
||||
const data = JSON.parse(text);
|
||||
console.log("resp code:", data.code)
|
||||
if (data.code == 200) {
|
||||
return resolve(res.json() as Promise<T>);
|
||||
} else {
|
||||
return reject({ code: data.code, message: data.message, url: requestUrl });
|
||||
}
|
||||
} catch (err) {
|
||||
return reject({ message: text, url: requestUrl });
|
||||
}
|
||||
});
|
||||
} else {
|
||||
res
|
||||
.clone()
|
||||
.text()
|
||||
.then((text) => {
|
||||
try {
|
||||
const errorData = JSON.parse(text);
|
||||
return reject({ code: res.status, message: errorData || '接口错误', url: requestUrl });
|
||||
} catch {
|
||||
return reject({ code: res.status, message: text, url: requestUrl });
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async httpFactory<T>({ url = '', params = {}, method }: Props): Promise<T> {
|
||||
const req = this.interceptorsRequest({
|
||||
url: process.env.NEXT_PUBLIC_BASEURL + url,
|
||||
method,
|
||||
params: params.params,
|
||||
cacheTime: params.cacheTime,
|
||||
});
|
||||
const res = await fetch(req.url, req.options);
|
||||
return this.interceptorsResponse<T>(res);
|
||||
}
|
||||
|
||||
async request<T>(method: Method, url: string, params?: Params): Promise<T> {
|
||||
return this.httpFactory<T>({ url, params, method });
|
||||
}
|
||||
|
||||
get<T>(url: string, params?: Params): Promise<T> {
|
||||
return this.request('GET', url, params);
|
||||
}
|
||||
|
||||
post<T>(url: string, params?: Params): Promise<T> {
|
||||
return this.request('POST', url, params);
|
||||
}
|
||||
|
||||
put<T>(url: string, params?: Params): Promise<T> {
|
||||
return this.request('PUT', url, params);
|
||||
}
|
||||
|
||||
delete<T>(url: string, params?: Params): Promise<T> {
|
||||
return this.request('DELETE', url, params);
|
||||
}
|
||||
|
||||
patch<T>(url: string, params?: Params): Promise<T> {
|
||||
return this.request('PATCH', url, params);
|
||||
}
|
||||
}
|
||||
|
||||
const request = new Request();
|
||||
|
||||
export default request;
|
||||
10
src/utils/utils.ts
Normal file
10
src/utils/utils.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* 校验手机号码的函数
|
||||
* @param phoneNumber - 要校验的手机号码
|
||||
* @returns 如果手机号码有效则返回 true,否则返回 false
|
||||
*/
|
||||
export function isValidPhoneNumber(phoneNumber: string): boolean {
|
||||
// 正则表达式,用于匹配有效的手机号码(中国大陆的手机号码格式)
|
||||
const phoneNumberRegex = /^1[3-9]\d{9}$/;
|
||||
return phoneNumberRegex.test(phoneNumber);
|
||||
}
|
||||
81
tailwind.config.ts
Normal file
81
tailwind.config.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import type { Config } from "tailwindcss";
|
||||
import daisyui from "daisyui"
|
||||
|
||||
const config: Config = {
|
||||
content: [
|
||||
"./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
"./src/components/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
"./src/app/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
"./src/ui/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
"./src/contexts/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
backgroundImage: {
|
||||
"gradient-radial": "radial-gradient(var(--tw-gradient-stops))",
|
||||
"gradient-conic":
|
||||
"conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))",
|
||||
},
|
||||
colors: {
|
||||
|
||||
},
|
||||
keyframes: {
|
||||
'accordion-up': {
|
||||
'0%': { height: 'var(--radix-collapsible-content-height)', opacity: "1" },
|
||||
'100%': { height: '0', opacity: "0" },
|
||||
},
|
||||
'accordion-down': {
|
||||
'0%': { height: '0', opacity: "0" },
|
||||
'100%': { height: 'var(--radix-collapsible-content-height)', opacity: "" },
|
||||
},
|
||||
},
|
||||
animation: {
|
||||
'accordion-up': 'accordion-up 0.3s ease-out',
|
||||
'accordion-down': 'accordion-down 0.3s ease-out',
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
daisyui,
|
||||
require('@tailwindcss/forms'),
|
||||
],
|
||||
daisyui: {
|
||||
themes: [
|
||||
{
|
||||
typeframes: {
|
||||
// 定义主要颜色和相关变量
|
||||
"primary": "hsl(210.34, 92.708%, 62.353%)", // 对应 --p
|
||||
"--pf": "210.34, 92.708%, 49.882%", // 对应 --pf
|
||||
"secondary": "hsl(141.89, 69.159%, 58.039%)", // 对应 --s
|
||||
"--sf": "141.89, 69.159%, 46.431%", // 对应 --sf
|
||||
"accent": "hsl(0, 0%, 0%)", // 对应 --af
|
||||
"neutral": "hsl(0, 0%, 13.333%)", // 对应 --n
|
||||
"base-100": "hsl(0, 0%, 96.471%)", // 对应 --b1
|
||||
"base-200": "hsl(210, 5.5556%, 92.941%)", // 对应 --b2
|
||||
"base-300": "hsl(210, 5.5556%, 83.647%)", // 对应 --b3
|
||||
"info": "hsl(208.57, 9.7674%, 57.843%)", // 对应 --in
|
||||
"success": "hsl(142.09, 70.563%, 45.294%)", // 对应 --su
|
||||
"warning": "hsl(43, 96%, 56%)", // 对应 --wa
|
||||
"error": "hsl(0, 90.541%, 70.98%)", // 对应 --er
|
||||
|
||||
// 边框和圆角相关变量
|
||||
"--rounded-box": "1rem", // 对应 --rounded-box
|
||||
"--rounded-btn": "0.5rem", // 对应 --rounded-btn
|
||||
"--rounded-badge": "1.9rem", // 对应 --rounded-badge
|
||||
"--border-btn": "1px", // 对应 --border-btn
|
||||
"--tab-border": "1px", // 对应 --tab-border
|
||||
"--tab-radius": "0.5rem", // 对应 --tab-radius
|
||||
|
||||
// 动画相关变量
|
||||
"--animation-btn": "0.25s", // 对应 --animation-btn
|
||||
"--animation-input": "0.2s", // 对应 --animation-input
|
||||
|
||||
// 文本和焦点样式
|
||||
"--btn-text-case": "uppercase", // 对应 --btn-text-case
|
||||
"--btn-focus-scale": "0.95", // 对应 --btn-focus-scale
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
export default config;
|
||||
33
tsconfig.json
Normal file
33
tsconfig.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"noImplicitAny": false,
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
"src/ui/page/page-remark"
|
||||
],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
Reference in New Issue
Block a user