├── .env.example
├── .eslintrc.json
├── .gitignore
├── .npmrc
├── .vscode
└── settings.json
├── README-zh.md
├── README.md
├── app
├── GoogleAnalytics.tsx
└── [locale]
│ ├── layout.tsx
│ └── page.tsx
├── components.json
├── components
├── TailwindIndicator.tsx
├── ThemeProvider.tsx
├── ThemedButton.tsx
├── footer
│ ├── Footer.tsx
│ ├── FooterLinks.tsx
│ └── FooterProducts.tsx
├── header
│ ├── Header.tsx
│ ├── HeaderLinks.tsx
│ └── LanguageSwitcher.tsx
├── icons
│ ├── expanding-arrow.tsx
│ ├── eye.tsx
│ ├── index.tsx
│ ├── loading-circle.tsx
│ ├── loading-dots.module.css
│ ├── loading-dots.tsx
│ ├── loading-spinner.module.css
│ ├── loading-spinner.tsx
│ ├── moon.tsx
│ └── sun.tsx
└── social-icons
│ ├── icons.tsx
│ └── index.tsx
├── config
└── site.ts
├── gtag.js
├── i18n
├── request.ts
└── routing.ts
├── lib
├── logger.ts
└── utils.ts
├── messages
├── en.json
└── zh.json
├── middleware.ts
├── next-env.d.ts
├── next-sitemap.config.js
├── next.config.mjs
├── package-lock.json
├── package.json
├── pnpm-lock.yaml
├── postcss.config.js
├── public
├── afd.png
├── card.png
├── favicon.ico
├── logo.png
├── logo.svg
└── og.png
├── styles
├── globals.css
└── loading.css
├── tailwind.config.ts
├── tsconfig.json
└── types
└── siteConfig.ts
/.env.example:
--------------------------------------------------------------------------------
1 | SITE_URL=
2 | NEXT_PUBLIC_GOOGLE_ID=
--------------------------------------------------------------------------------
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "next/core-web-vitals"
3 | }
4 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | ### Node template
2 | .idea
3 | .DS_Store
4 | dist
5 |
6 | # Logs
7 | logs
8 | *.log
9 | npm-debug.log*
10 | yarn-debug.log*
11 | yarn-error.log*
12 | lerna-debug.log*
13 | .temp
14 | yarn.lock
15 |
16 | # Diagnostic reports (https://nodejs.org/api/report.html)
17 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
18 |
19 | # Runtime data
20 | pids
21 | *.pid
22 | *.seed
23 | *.pid.lock
24 |
25 | # Directory for instrumented libs generated by jscoverage/JSCover
26 | lib-cov
27 |
28 | # Coverage directory used by tools like istanbul
29 | coverage
30 | *.lcov
31 |
32 | # nyc test coverage
33 | .nyc_output
34 |
35 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
36 | .grunt
37 |
38 | # Bower dependency directory (https://bower.io/)
39 | bower_components
40 |
41 | # node-waf configuration
42 | .lock-wscript
43 |
44 | # Compiled binary addons (https://nodejs.org/api/addons.html)
45 | build/Release
46 |
47 | # Dependency directories
48 | node_modules/
49 | jspm_packages/
50 |
51 | # Snowpack dependency directory (https://snowpack.dev/)
52 | web_modules/
53 |
54 | # TypeScript cache
55 | *.tsbuildinfo
56 |
57 | # Optional npm cache directory
58 | .npm
59 |
60 | # Optional eslint cache
61 | .eslintcache
62 |
63 | # Microbundle cache
64 | .rpt2_cache/
65 | .rts2_cache_cjs/
66 | .rts2_cache_es/
67 | .rts2_cache_umd/
68 |
69 | # Optional REPL history
70 | .node_repl_history
71 |
72 | # Output of 'npm pack'
73 | *.tgz
74 |
75 | # Yarn Integrity file
76 | .yarn-integrity
77 |
78 | # dotenv environment variables file
79 | .env
80 | .env.test
81 |
82 | # parcel-bundler cache (https://parceljs.org/)
83 | .cache
84 | .parcel-cache
85 |
86 | # Next.js build output
87 | .next
88 | out
89 |
90 | # Nuxt.js build / generate output
91 | .nuxt
92 | dist
93 |
94 | # Gatsby files
95 | .cache/
96 | # Comment in the assets line in if your project uses Gatsby and not Next.js
97 | # https://nextjs.org/blog/next-9-1#public-directory-support
98 | # assets
99 |
100 | # vuepress build output
101 | .vuepress/dist
102 |
103 | # Serverless directories
104 | .serverless/
105 |
106 | # FuseBox cache
107 | .fusebox/
108 |
109 | # DynamoDB Local files
110 | .dynamodb/
111 |
112 | # TernJS port file
113 | .tern-port
114 |
115 | # Stores VSCode versions used for testing VSCode extensions
116 | .vscode-test
117 |
118 | # yarn v2
119 | .yarn/cache
120 | .yarn/unplugged
121 | .yarn/build-state.yml
122 | .yarn/install-state.gz
123 | .pnp.*
124 |
125 | /.vuepress/dist/
126 |
127 | # sitemap
128 | */sitemap*.xml
129 | */robots.txt
--------------------------------------------------------------------------------
/.npmrc:
--------------------------------------------------------------------------------
1 | # if use pnpm
2 | enable-pre-post-scripts=true
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "css.validate": false,
3 | "editor.formatOnSave": true,
4 | "editor.tabSize": 2,
5 | "editor.codeActionsOnSave": {
6 | "source.fixAll.eslint": "explicit",
7 | "source.organizeImports": "explicit"
8 | },
9 | "headwind.runOnSave": false,
10 | "typescript.preferences.importModuleSpecifier": "non-relative",
11 | "eslint.validate": ["javascript", "javascriptreact", "typescript"],
12 | "typescript.tsdk": "node_modules/typescript/lib",
13 | "commentTranslate.source": "Bing",
14 | "cSpell.words": ["contentlayer", "lemonsqueezy"],
15 | "i18n-ally.localesPaths": ["i18n", "messages"]
16 | }
17 |
--------------------------------------------------------------------------------
/README-zh.md:
--------------------------------------------------------------------------------
1 | # 文字转图片生成器
2 |
3 | 一个基于 Next.js 14+ 和 TypeScript 构建的现代网页应用,可以将文字转换成精美的图片,支持自定义样式和模板。
4 |
5 | ⭐ 如果您觉得这个项目有用,请考虑在 GitHub 上给我们一个星标!您的支持将帮助我们不断改进这个项目。
6 |
7 | [English](README.md) | [中文](README-zh.md)
8 |
9 | 
10 |
11 | ## 特性
12 |
13 | - 🎨 多种背景模板(纯色、渐变、图案)
14 | - 🖌️ 文字马克笔高亮效果
15 | - 🌈 柔和色调的文字颜色自定义
16 | - 📏 多种图片尺寸选项
17 | - 🌓 明暗主题切换
18 | - 🌍 国际化支持(中文和英文)
19 | - 📊 集成 Google Analytics
20 | - 💅 使用 Tailwind CSS 构建的响应式设计
21 |
22 | ## 演示
23 |
24 | 访问 [https://text-image.tool.vin](https://text-image.tool.vin) 查看在线演示。
25 |
26 | ## 快速开始
27 |
28 | ### Vercel 部署
29 |
30 | [](https://vercel.com/new/clone?repository-url=https://github.com/shadowDragons/text-image-generator)
31 |
32 | ### 本地开发
33 |
34 | 1. 克隆仓库
35 |
36 | ```bash
37 | git clone https://github.com/shadowDragons/text-image-generator.git
38 | cd text-image-generator
39 | ```
40 |
41 | 2. 安装依赖
42 |
43 | ```bash
44 | npm install
45 | # 或
46 | yarn install
47 | # 或
48 | pnpm install
49 | ```
50 |
51 | 3. 创建环境变量文件
52 |
53 | ```bash
54 | cp .env.example .env.local
55 | ```
56 |
57 | 4. 启 开发服务器
58 |
59 | ```bash
60 | npm run dev
61 | # 或
62 | yarn dev
63 | # 或
64 | pnpm dev
65 | ```
66 |
67 | 在浏览器中打开 [http://localhost:3000](http://localhost:3000) 查看结果。
68 |
69 | ## 环境变量
70 |
71 | 在根目录创建 `.env.local` 文件,包含以下变量:
72 |
73 | ```env
74 | NEXT_PUBLIC_GA_ID=你的GA跟踪ID
75 | ```
76 |
77 | ## 技术栈
78 |
79 | - [Next.js 14](https://nextjs.org/) - React 框架
80 | - [TypeScript](https://www.typescriptlang.org/) - 类型安全
81 | - [Tailwind CSS](https://tailwindcss.com/) - 样式框架
82 | - [next-intl](https://next-intl-docs.vercel.app/) - 国际化
83 | - [next-themes](https://github.com/pacocoursey/next-themes) - 主题管理
84 |
85 | ## 项目结构
86 |
87 | ```
88 | .
89 | ├── app/ # Next.js 应用目录
90 | ├── components/ # React 组件
91 | ├── config/ # 站点配置
92 | ├── lib/ # 工具函数
93 | ├── messages/ # 国际化翻译文件
94 | ├── public/ # 静态资源
95 | └── styles/ # 全局样式
96 | ```
97 |
98 | ## 贡献
99 |
100 | 欢迎提交 Pull Request!
101 |
102 | ## 许可证
103 |
104 | 本项目采用 MIT 许可证 - 查看 [LICENSE](LICENSE) 文件了解详情。
105 |
106 | ## 作者
107 |
108 | Junexus ([https://sphrag.com](https://sphrag.com))
109 |
110 | ## 支持项目
111 |
112 | 如果这个项目对您有帮助,可以请我喝杯咖啡:
113 |
114 | [](https://sphrag.com/zh/sponsor)
115 |
116 | ## 开发计划
117 |
118 | - [ ] 社交媒体卡片
119 | - [ ] 文章封面图
120 | - [ ] 多字体支持
121 | - [ ] 表情符号支持
122 |
123 | ## 致谢
124 |
125 | - [Next.js](https://nextjs.org/)
126 | - [Tailwind CSS](https://tailwindcss.com/)
127 | - [next-intl](https://next-intl-docs.vercel.app/)
128 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Text to Image Generator
2 |
3 | A modern web application that converts text into beautiful images with customizable styles and templates. Built with Next.js 14+ and TypeScript.
4 |
5 | ⭐ If you find this project useful, please consider giving it a star on GitHub! Your support helps us grow and improve the project.
6 |
7 | [English](README.md) | [中文](README-zh.md)
8 |
9 | 
10 |
11 | ## Features
12 |
13 | - 🎨 Multiple background templates (solid colors, gradients, patterns)
14 | - 🖌️ Text highlighting with marker effects
15 | - 🌈 Text color customization with soft palette options
16 | - 📏 Multiple image size options
17 | - 🌓 Dark/Light mode support
18 | - 🌍 i18n support (English & Chinese)
19 | - 📊 Google Analytics integration
20 | - 💅 Responsive design with Tailwind CSS
21 |
22 | ## Demo
23 |
24 | Visit [https://text-image.tool.vin](https://text-image.tool.vin) to see the live demo.
25 |
26 | ## Quick Start
27 |
28 | ### Deploy on Vercel
29 |
30 | [](https://vercel.com/new/clone?repository-url=https://github.com/shadowDragons/text-image-generator)
31 |
32 | ### Local Development
33 |
34 | 1. Clone the repository
35 |
36 | ```bash
37 | git clone https://github.com/shadowDragons/text-image-generator.git
38 | cd text-image-generator
39 | ```
40 |
41 | 2. Install dependencies
42 |
43 | ```bash
44 | npm install
45 | or
46 | yarn install
47 | or
48 | pnpm install
49 | ```
50 |
51 | 3. Create environment variables file
52 |
53 | ```bash
54 | cp .env.example .env.local
55 | ```
56 |
57 | 4. Start the development server
58 |
59 | ```bash
60 | npm run dev
61 | or
62 | yarn dev
63 | or
64 | pnpm dev
65 | ```
66 |
67 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
68 |
69 | ## Environment Variables
70 |
71 | Create a `.env.local` file in the root directory with the following variables:
72 |
73 | ```env
74 | NEXT_PUBLIC_GA_ID=your-ga-id
75 | ```
76 |
77 | ## Tech Stack
78 |
79 | - [Next.js 14](https://nextjs.org/) - React framework
80 | - [TypeScript](https://www.typescriptlang.org/) - Type safety
81 | - [Tailwind CSS](https://tailwindcss.com/) - Styling
82 | - [next-intl](https://next-intl-docs.vercel.app/) - Internationalization
83 | - [next-themes](https://github.com/pacocoursey/next-themes) - Theme management
84 |
85 | ## Project Structure
86 |
87 | ```
88 | .
89 | ├── app/ # Next.js app directory
90 | ├── components/ # React components
91 | ├── config/ # Site configuration
92 | ├── lib/ # Utility functions
93 | ├── messages/ # i18n translation files
94 | ├── public/ # Static assets
95 | └── styles/ # Global styles
96 | ```
97 |
98 | ## Contributing
99 |
100 | We welcome contributions! Please feel free to submit a Pull Request. For major changes, please open an issue first to discuss what you would like to change.
101 |
102 | 1. Fork the repository
103 | 2. Create your feature branch (`git checkout -b feature/AmazingFeature`)
104 | 3. Commit your changes (`git commit -m 'Add some AmazingFeature'`)
105 | 4. Push to the branch (`git push origin feature/AmazingFeature`)
106 | 5. Open a Pull Request
107 |
108 | ## License
109 |
110 | This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
111 |
112 | ## Author
113 |
114 | Junexus ([https://sphrag.com](https://sphrag.com))
115 |
116 | ## Support
117 |
118 | If you find this project helpful, consider buying me a coffee:
119 |
120 | [](https://sphrag.com/en/sponsor)
121 |
122 | ## Roadmap
123 |
124 | - [ ] Social Media Cards
125 | - [ ] Article Covers
126 | - [ ] Multiple Font Support
127 | - [ ] Emoji Support
128 |
129 | ## Acknowledgments
130 |
131 | - [Next.js](https://nextjs.org/)
132 | - [Tailwind CSS](https://tailwindcss.com/)
133 | - [next-intl](https://next-intl-docs.vercel.app/)
134 |
--------------------------------------------------------------------------------
/app/GoogleAnalytics.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import Script from "next/script";
4 | import * as gtag from "../gtag.js";
5 |
6 | const GoogleAnalytics = () => {
7 | return (
8 | <>
9 | {gtag.GA_TRACKING_ID ? (
10 | <>
11 |
15 |
29 | >
30 | ) : (
31 | <>>
32 | )}
33 | >
34 | );
35 | };
36 |
37 | export default GoogleAnalytics;
38 |
--------------------------------------------------------------------------------
/app/[locale]/layout.tsx:
--------------------------------------------------------------------------------
1 | import GoogleAnalytics from '@/app/GoogleAnalytics'
2 | import Footer from '@/components/footer/Footer'
3 | import Header from '@/components/header/Header'
4 | import { TailwindIndicator } from '@/components/TailwindIndicator'
5 | import { ThemeProvider } from '@/components/ThemeProvider'
6 | import { siteConfig } from '@/config/site'
7 | import { routing } from '@/i18n/routing'
8 | import { cn } from '@/lib/utils'
9 | import '@/styles/globals.css'
10 | import '@/styles/loading.css'
11 | import { Analytics } from '@vercel/analytics/react'
12 | import { Viewport } from 'next'
13 | import { NextIntlClientProvider } from 'next-intl'
14 | import { getMessages } from 'next-intl/server'
15 | import { notFound } from 'next/navigation'
16 |
17 | export const metadata = {
18 | title: siteConfig.name,
19 | description: siteConfig.description,
20 | keywords: siteConfig.keywords,
21 | authors: siteConfig.authors,
22 | creator: siteConfig.creator,
23 | icons: siteConfig.icons,
24 | metadataBase: siteConfig.metadataBase,
25 | openGraph: siteConfig.openGraph,
26 | twitter: siteConfig.twitter,
27 | }
28 | export const viewport: Viewport = {
29 | themeColor: siteConfig.themeColors,
30 | }
31 |
32 | export default async function RootLayout({ children, params: { locale } }: { children: React.ReactNode; params: { locale: string } }) {
33 | // Ensure that the incoming `locale` is valid
34 | if (!routing.locales.includes(locale as any)) {
35 | notFound()
36 | }
37 |
38 | // Providing all messages to the client
39 | // side is the easiest way to get started
40 | const messages = await getMessages()
41 | return (
42 |
43 |
45 |
46 |
47 |
48 | {children}
49 |
50 |
51 |
52 |
53 |
54 | {process.env.NODE_ENV === 'development' ? (
55 | <>>
56 | ) : (
57 | <>
58 |
59 | >
60 | )}
61 |
62 |
63 | )
64 | }
65 |
--------------------------------------------------------------------------------
/app/[locale]/page.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 | import { useTranslations } from 'next-intl'
3 | import { useEffect, useRef, useState } from 'react'
4 |
5 | // Image size options
6 | const sizeOptions = [
7 | { width: 1080, height: 1350, key: 'default' },
8 | { width: 1080, height: 1080, key: 'square' },
9 | { width: 1920, height: 1080, key: 'landscape' },
10 | { width: 800, height: 600, key: 'medium' },
11 | { width: 500, height: 500, key: 'small' },
12 | ]
13 |
14 | // Template definitions
15 | const templates = [
16 | {
17 | id: 1,
18 | key: 'solid',
19 | type: 'solid',
20 | backgroundColor: '#2c3e50',
21 | textPosition: { x: 0.5, y: 0.5 },
22 | },
23 | {
24 | id: 2,
25 | key: 'gradient1',
26 | type: 'gradient',
27 | backgroundColor: 'linear-gradient(45deg, #ff6b6b, #4ecdc4)',
28 | textPosition: { x: 0.5, y: 0.5 },
29 | },
30 | {
31 | id: 3,
32 | key: 'gradient2',
33 | type: 'gradient',
34 | backgroundColor: 'linear-gradient(120deg, #f6d365, #fda085)',
35 | textPosition: { x: 0.5, y: 0.5 },
36 | },
37 | {
38 | id: 4,
39 | key: 'gradient3',
40 | type: 'gradient',
41 | backgroundColor: 'linear-gradient(to right, #8e2de2, #4a00e0)',
42 | textPosition: { x: 0.5, y: 0.5 },
43 | },
44 | {
45 | id: 5,
46 | key: 'gradient4',
47 | type: 'gradient',
48 | backgroundColor: 'linear-gradient(135deg, #00dbde, #fc00ff)',
49 | textPosition: { x: 0.5, y: 0.5 },
50 | },
51 | {
52 | id: 6,
53 | key: 'gradient5',
54 | type: 'gradient',
55 | backgroundColor: 'linear-gradient(to right, #2c3e50, #3498db)',
56 | textPosition: { x: 0.5, y: 0.5 },
57 | },
58 | {
59 | id: 7,
60 | key: 'gradient6',
61 | type: 'gradient',
62 | backgroundColor: 'linear-gradient(60deg, #abecd6, #fbed96)',
63 | textPosition: { x: 0.5, y: 0.5 },
64 | },
65 | {
66 | id: 8,
67 | key: 'wave',
68 | type: 'pattern',
69 | backgroundColor: '#3498db',
70 | pattern: 'wave',
71 | textPosition: { x: 0.5, y: 0.5 },
72 | },
73 | {
74 | id: 9,
75 | key: 'dots',
76 | type: 'pattern',
77 | backgroundColor: '#2ecc71',
78 | pattern: 'dots',
79 | textPosition: { x: 0.5, y: 0.5 },
80 | },
81 | {
82 | id: 10,
83 | key: 'hexagon',
84 | type: 'pattern',
85 | backgroundColor: '#9b59b6',
86 | pattern: 'hexagon',
87 | textPosition: { x: 0.5, y: 0.5 },
88 | },
89 | {
90 | id: 11,
91 | key: 'grid',
92 | type: 'pattern',
93 | backgroundColor: '#ffffff',
94 | pattern: 'grid',
95 | textPosition: { x: 0.5, y: 0.5 },
96 | },
97 | {
98 | id: 12,
99 | key: 'gradient7',
100 | type: 'gradient',
101 | backgroundColor: 'linear-gradient(to right, #4facfe, #00f2fe)',
102 | textPosition: { x: 0.5, y: 0.5 },
103 | },
104 | {
105 | id: 13,
106 | key: 'gradient8',
107 | type: 'gradient',
108 | backgroundColor: 'linear-gradient(135deg, #667eea, #764ba2)',
109 | textPosition: { x: 0.5, y: 0.5 },
110 | },
111 | {
112 | id: 14,
113 | key: 'gradient9',
114 | type: 'gradient',
115 | backgroundColor: 'linear-gradient(to right, #ff758c, #ff7eb3)',
116 | textPosition: { x: 0.5, y: 0.5 },
117 | },
118 | {
119 | id: 15,
120 | key: 'gradient10',
121 | type: 'gradient',
122 | backgroundColor: 'linear-gradient(45deg, #08AEEA, #2AF598)',
123 | textPosition: { x: 0.5, y: 0.5 },
124 | },
125 | {
126 | id: 16,
127 | key: 'gradient11',
128 | type: 'gradient',
129 | backgroundColor: 'linear-gradient(to right, #434343, #000000)',
130 | textPosition: { x: 0.5, y: 0.5 },
131 | },
132 | {
133 | id: 17,
134 | key: 'gradient12',
135 | type: 'gradient',
136 | backgroundColor: 'linear-gradient(to right, #93a5cf, #e4efe9)',
137 | textPosition: { x: 0.5, y: 0.5 },
138 | },
139 | {
140 | id: 18,
141 | key: 'stripes',
142 | type: 'pattern',
143 | backgroundColor: '#f39c12',
144 | pattern: 'stripes',
145 | textPosition: { x: 0.5, y: 0.5 },
146 | },
147 | {
148 | id: 19,
149 | key: 'circles',
150 | type: 'pattern',
151 | backgroundColor: '#e74c3c',
152 | pattern: 'circles',
153 | textPosition: { x: 0.5, y: 0.5 },
154 | },
155 | {
156 | id: 20,
157 | key: 'triangles',
158 | type: 'pattern',
159 | backgroundColor: '#27ae60',
160 | pattern: 'triangles',
161 | textPosition: { x: 0.5, y: 0.5 },
162 | },
163 | ]
164 |
165 | // Marker style options
166 | const markerStyles = [
167 | { id: 'none', color: 'none', key: 'none' },
168 | { id: 'yellow', color: '#ffd700', key: 'yellow' },
169 | { id: 'green', color: '#98fb98', key: 'green' },
170 | { id: 'pink', color: '#ffb6c1', key: 'pink' },
171 | { id: 'blue', color: '#87cefa', key: 'blue' },
172 | ]
173 |
174 | // Text color options
175 | const textColorOptions = [
176 | { id: 'auto', color: 'auto', key: 'auto' },
177 | { id: 'white', color: '#ffffff', key: 'white' },
178 | { id: 'black', color: '#000000', key: 'black' },
179 | { id: 'red', color: '#fab1a0', key: 'red' },
180 | { id: 'blue', color: '#a6c0fe', key: 'blue' },
181 | { id: 'green', color: '#b5e6b5', key: 'green' },
182 | { id: 'yellow', color: '#ffeaa7', key: 'yellow' },
183 | { id: 'orange', color: '#fad390', key: 'orange' },
184 | { id: 'purple', color: '#d6a2e8', key: 'purple' },
185 | ]
186 |
187 | interface CharacterStyle {
188 | marker: string // Marker style ID
189 | textColor: string // Text color ID
190 | }
191 |
192 | interface Character {
193 | char: string
194 | style: CharacterStyle
195 | }
196 |
197 | // TextStyle interface definition
198 | interface TextStyle {
199 | fontSize: number
200 | }
201 |
202 | export default function Home() {
203 | const t = useTranslations('Generator')
204 | const [characters, setCharacters] = useState([])
205 | const [selectedIndexes, setSelectedIndexes] = useState([])
206 | const [selectedTemplate, setSelectedTemplate] = useState(templates[0])
207 | const canvasRef = useRef(null)
208 | const [textStyle, setTextStyle] = useState({
209 | fontSize: 48,
210 | })
211 | const [selectedSize, setSelectedSize] = useState(sizeOptions[0])
212 | const [backgroundColor, setBackgroundColor] = useState('#2c3e50')
213 | const [selectedTextColor, setSelectedTextColor] = useState(textColorOptions[0])
214 | const [globalTextColor, setGlobalTextColor] = useState('auto')
215 |
216 | // Convert input text to characters array
217 | const handleTextChange = (e: React.FormEvent) => {
218 | const newText = e.currentTarget.textContent || ''
219 | const newCharacters = newText.split('').map(char => ({
220 | char,
221 | style: {
222 | marker: 'none',
223 | textColor: globalTextColor,
224 | },
225 | }))
226 | setCharacters(newCharacters)
227 | }
228 |
229 | // Handle text selection
230 | const handleTextSelect = (e: React.MouseEvent) => {
231 | const selection = window.getSelection()
232 | if (!selection) return
233 |
234 | const target = e.currentTarget
235 | const text = target.textContent || ''
236 | const start = selection.anchorOffset
237 | const end = selection.focusOffset
238 |
239 | // 确保开始和结束索引在正确范围内
240 | if (start === end) return
241 |
242 | // 计算选中的字符索引
243 | const indexes = []
244 | for (let i = Math.min(start, end); i < Math.max(start, end) && i < text.length; i++) {
245 | indexes.push(i)
246 | }
247 |
248 | if (indexes.length > 0) {
249 | setSelectedIndexes(indexes)
250 | // 在控制台输出选中的索引,帮助调试
251 | console.log('Selected indexes:', indexes)
252 | }
253 | }
254 |
255 | // Apply marker style
256 | const applyMarker = (markerId: string) => {
257 | setCharacters(prev => {
258 | const newCharacters = [...prev]
259 | selectedIndexes.forEach(index => {
260 | if (newCharacters[index]) {
261 | newCharacters[index] = {
262 | ...newCharacters[index],
263 | style: {
264 | ...newCharacters[index].style,
265 | marker: markerId,
266 | },
267 | }
268 | }
269 | })
270 | return newCharacters
271 | })
272 | }
273 |
274 | // Apply text color
275 | const applyTextColor = (colorId: string) => {
276 | if (selectedIndexes.length === 0) return
277 |
278 | console.log('Applying color:', colorId, 'to indexes:', selectedIndexes)
279 |
280 | setCharacters(prev => {
281 | const newCharacters = [...prev]
282 | selectedIndexes.forEach(index => {
283 | if (index >= 0 && index < newCharacters.length) {
284 | newCharacters[index] = {
285 | ...newCharacters[index],
286 | style: {
287 | ...newCharacters[index].style,
288 | textColor: colorId,
289 | },
290 | }
291 | }
292 | })
293 | // 手动触发重新渲染
294 | generateImage()
295 | return newCharacters
296 | })
297 | }
298 |
299 | // 应用全局文本颜色
300 | const applyGlobalTextColor = (colorId: string) => {
301 | setGlobalTextColor(colorId)
302 |
303 | // 更新所有现有字符的颜色
304 | setCharacters(prev => {
305 | return prev.map(char => ({
306 | ...char,
307 | style: {
308 | ...char.style,
309 | textColor: colorId,
310 | },
311 | }))
312 | })
313 | }
314 |
315 | const wrapText = (context: CanvasRenderingContext2D, characters: Character[], x: number, y: number, maxWidth: number, lineHeight: number) => {
316 | let currentLine: Character[] = []
317 | const lines: Character[][] = []
318 |
319 | // Calculate line breaks
320 | for (let char of characters) {
321 | const testLine = [...currentLine, char]
322 | const testText = testLine.map(c => c.char).join('')
323 | const metrics = context.measureText(testText)
324 | const testWidth = metrics.width
325 |
326 | if (testWidth > maxWidth && currentLine.length > 0) {
327 | lines.push(currentLine)
328 | currentLine = [char]
329 | } else {
330 | currentLine.push(char)
331 | }
332 | }
333 | if (currentLine.length > 0) {
334 | lines.push(currentLine)
335 | }
336 |
337 | // Calculate total height and center
338 | const totalHeight = lines.length * lineHeight
339 | const startY = y - totalHeight / 2 + lineHeight / 2
340 |
341 | // Draw each line of text
342 | lines.forEach((line, lineIndex) => {
343 | const lineY = startY + lineIndex * lineHeight
344 | const lineText = line.map(c => c.char).join('')
345 | const lineWidth = context.measureText(lineText).width
346 | let currentX = x - lineWidth / 2
347 |
348 | // Draw character by character
349 | line.forEach(char => {
350 | const charWidth = context.measureText(char.char).width
351 |
352 | // Draw marker effect background if present
353 | if (char.style.marker !== 'none') {
354 | const markerStyle = markerStyles.find(style => style.id === char.style.marker)
355 | if (markerStyle) {
356 | context.save()
357 | context.fillStyle = markerStyle.color
358 | context.globalAlpha = 0.3
359 |
360 | // Draw marker effect (rectangle)
361 | const markerHeight = textStyle.fontSize
362 | context.fillRect(currentX, lineY - markerHeight / 2, charWidth, markerHeight)
363 |
364 | context.restore()
365 | }
366 | }
367 |
368 | // Draw text
369 | context.save()
370 |
371 | // 使用全局颜色或字符特定颜色
372 | if (globalTextColor !== 'auto') {
373 | // 使用全局颜色
374 | const textColorOption = textColorOptions.find(option => option.id === globalTextColor)
375 | if (textColorOption && textColorOption.color !== 'auto') {
376 | context.fillStyle = textColorOption.color
377 | }
378 | } else if (char.style.textColor && char.style.textColor !== 'auto') {
379 | // 使用自定义颜色
380 | const textColorOption = textColorOptions.find(option => option.id === char.style.textColor)
381 | if (textColorOption && textColorOption.color !== 'auto') {
382 | context.fillStyle = textColorOption.color
383 | }
384 | } else {
385 | // 使用自动颜色
386 | if (selectedTemplate.type === 'solid') {
387 | const rgb = hexToRgb(backgroundColor)
388 | if (rgb) {
389 | const brightness = (rgb.r * 299 + rgb.g * 587 + rgb.b * 114) / 1000
390 | context.fillStyle = brightness > 128 ? '#000000' : '#ffffff'
391 | }
392 | } else if (selectedTemplate.type === 'pattern') {
393 | // 在图案背景上使用黑色文本
394 | context.fillStyle = '#000000'
395 | } else {
396 | context.fillStyle = '#ffffff'
397 | }
398 | }
399 |
400 | // Draw text
401 | context.fillText(char.char, currentX, lineY)
402 |
403 | context.restore()
404 |
405 | // Update X coordinate
406 | currentX += charWidth
407 | })
408 | })
409 | }
410 |
411 | const generateImage = () => {
412 | const canvas = canvasRef.current
413 | if (!canvas) return
414 |
415 | const ctx = canvas.getContext('2d')
416 | if (!ctx) return
417 |
418 | // 确保清除画布
419 | ctx.clearRect(0, 0, canvas.width, canvas.height)
420 |
421 | canvas.width = selectedSize.width
422 | canvas.height = selectedSize.height
423 |
424 | // Draw different backgrounds based on template type
425 | if (selectedTemplate.type === 'solid') {
426 | // Solid background uses color picker color
427 | ctx.fillStyle = backgroundColor
428 | ctx.fillRect(0, 0, canvas.width, canvas.height)
429 | } else if (selectedTemplate.type === 'gradient') {
430 | const gradient = ctx.createLinearGradient(0, 0, canvas.width, canvas.height)
431 | const colors = selectedTemplate.backgroundColor.match(/#[a-fA-F0-9]{6}/g) || ['#000000', '#ffffff']
432 | gradient.addColorStop(0, colors[0])
433 | gradient.addColorStop(1, colors[1])
434 | ctx.fillStyle = gradient
435 | ctx.fillRect(0, 0, canvas.width, canvas.height)
436 | } else if (selectedTemplate.type === 'pattern') {
437 | // Fill background first
438 | ctx.fillStyle = selectedTemplate.backgroundColor
439 | ctx.fillRect(0, 0, canvas.width, canvas.height)
440 |
441 | // Draw different patterns based on pattern type
442 | switch (selectedTemplate.pattern) {
443 | case 'wave':
444 | drawWavePattern(ctx, canvas.width, canvas.height)
445 | break
446 | case 'dots':
447 | drawDotsPattern(ctx, canvas.width, canvas.height)
448 | break
449 | case 'hexagon':
450 | drawHexagonPattern(ctx, canvas.width, canvas.height)
451 | break
452 | case 'grid':
453 | drawGridPattern(ctx, canvas.width, canvas.height)
454 | break
455 | case 'stripes':
456 | drawStripesPattern(ctx, canvas.width, canvas.height)
457 | break
458 | case 'circles':
459 | drawCirclesPattern(ctx, canvas.width, canvas.height)
460 | break
461 | case 'triangles':
462 | drawTrianglesPattern(ctx, canvas.width, canvas.height)
463 | break
464 | }
465 | }
466 |
467 | // Set font
468 | const fontString = `${textStyle.fontSize}px "Microsoft YaHei"`
469 | ctx.font = fontString
470 | ctx.textAlign = 'left' // Change to left alignment, we handle centering manually
471 | ctx.textBaseline = 'middle' // Keep baseline aligned
472 |
473 | const textX = canvas.width * selectedTemplate.textPosition.x
474 | const textY = canvas.height * selectedTemplate.textPosition.y
475 |
476 | wrapText(ctx, characters, textX, textY, canvas.width - canvas.width * 0.2, textStyle.fontSize * 1.5)
477 | }
478 |
479 | // Add pattern drawing functions
480 | const drawWavePattern = (ctx: CanvasRenderingContext2D, width: number, height: number) => {
481 | ctx.strokeStyle = 'rgba(255, 255, 255, 0.2)'
482 | ctx.lineWidth = 2
483 |
484 | for (let y = 0; y < height; y += 20) {
485 | ctx.beginPath()
486 | ctx.moveTo(0, y)
487 | for (let x = 0; x < width; x += 10) {
488 | ctx.lineTo(x, y + Math.sin(x * 0.03) * 10)
489 | }
490 | ctx.stroke()
491 | }
492 | }
493 |
494 | const drawDotsPattern = (ctx: CanvasRenderingContext2D, width: number, height: number) => {
495 | ctx.fillStyle = 'rgba(255, 255, 255, 0.2)'
496 | const size = 4
497 | const spacing = 20
498 |
499 | for (let x = 0; x < width; x += spacing) {
500 | for (let y = 0; y < height; y += spacing) {
501 | ctx.beginPath()
502 | ctx.arc(x, y, size, 0, Math.PI * 2)
503 | ctx.fill()
504 | }
505 | }
506 | }
507 |
508 | const drawHexagonPattern = (ctx: CanvasRenderingContext2D, width: number, height: number) => {
509 | ctx.strokeStyle = 'rgba(255, 255, 255, 0.2)'
510 | ctx.lineWidth = 2
511 | const size = 30
512 |
513 | for (let y = 0; y < height + size * 2; y += size * 1.5) {
514 | for (let x = 0; x < width + size * 2; x += size * 2) {
515 | ctx.beginPath()
516 | for (let i = 0; i < 6; i++) {
517 | const angle = (i * Math.PI) / 3
518 | const px = x + size * Math.cos(angle)
519 | const py = y + size * Math.sin(angle)
520 | if (i === 0) ctx.moveTo(px, py)
521 | else ctx.lineTo(px, py)
522 | }
523 | ctx.closePath()
524 | ctx.stroke()
525 | }
526 | }
527 | }
528 |
529 | const drawGridPattern = (ctx: CanvasRenderingContext2D, width: number, height: number) => {
530 | ctx.strokeStyle = 'rgba(0, 0, 0, 0.1)'
531 | ctx.lineWidth = 1
532 |
533 | const gridSize = 20
534 | for (let x = 0; x <= width; x += gridSize) {
535 | ctx.beginPath()
536 | ctx.moveTo(x, 0)
537 | ctx.lineTo(x, height)
538 | ctx.stroke()
539 | }
540 | for (let y = 0; y <= height; y += gridSize) {
541 | ctx.beginPath()
542 | ctx.moveTo(0, y)
543 | ctx.lineTo(width, y)
544 | ctx.stroke()
545 | }
546 | }
547 |
548 | const drawStripesPattern = (ctx: CanvasRenderingContext2D, width: number, height: number) => {
549 | ctx.strokeStyle = 'rgba(255, 255, 255, 0.2)'
550 | ctx.lineWidth = 2
551 | const gap = 20
552 |
553 | for (let x = 0; x < width + height; x += gap) {
554 | ctx.beginPath()
555 | ctx.moveTo(x, 0)
556 | ctx.lineTo(x - height, height)
557 | ctx.stroke()
558 | }
559 | }
560 |
561 | const drawCirclesPattern = (ctx: CanvasRenderingContext2D, width: number, height: number) => {
562 | ctx.strokeStyle = 'rgba(255, 255, 255, 0.2)'
563 | ctx.lineWidth = 1
564 | const size = 30
565 | const gap = 60
566 |
567 | for (let x = 0; x < width + size; x += gap) {
568 | for (let y = 0; y < height + size; y += gap) {
569 | ctx.beginPath()
570 | ctx.arc(x, y, size, 0, Math.PI * 2)
571 | ctx.stroke()
572 | }
573 | }
574 | }
575 |
576 | const drawTrianglesPattern = (ctx: CanvasRenderingContext2D, width: number, height: number) => {
577 | ctx.strokeStyle = 'rgba(255, 255, 255, 0.2)'
578 | ctx.lineWidth = 1
579 | const size = 30
580 | const h = size * Math.sqrt(3)
581 |
582 | for (let y = 0; y < height + h; y += h) {
583 | for (let x = 0; x < width + size * 2; x += size * 2) {
584 | ctx.beginPath()
585 | ctx.moveTo(x, y)
586 | ctx.lineTo(x + size, y)
587 | ctx.lineTo(x + size / 2, y - h)
588 | ctx.closePath()
589 | ctx.stroke()
590 | }
591 | }
592 | }
593 |
594 | // Color conversion helper function
595 | const hexToRgb = (hex: string) => {
596 | const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)
597 | return result
598 | ? {
599 | r: parseInt(result[1], 16),
600 | g: parseInt(result[2], 16),
601 | b: parseInt(result[3], 16),
602 | }
603 | : null
604 | }
605 |
606 | // Generate image in real-time
607 | useEffect(() => {
608 | generateImage()
609 | }, [characters, selectedTemplate, textStyle, selectedSize, backgroundColor])
610 |
611 | const downloadImage = () => {
612 | const canvas = canvasRef.current
613 | if (!canvas) return
614 |
615 | const link = document.createElement('a')
616 | link.download = 'generated-image.png'
617 | link.href = canvas.toDataURL()
618 | link.click()
619 | }
620 |
621 | // Modify handleFontSizeChange type
622 | const handleFontSizeChange = (e: React.ChangeEvent) => {
623 | setTextStyle(prev => ({ ...prev, fontSize: Number(e.target.value) }))
624 | }
625 |
626 | return (
627 |
628 | {/* Use grid layout to create left-right structure */}
629 |
630 | {/* Left settings panel */}
631 |
632 | {/* Image size selection */}
633 |
634 |
635 |
636 | {sizeOptions.map((size, index) => (
637 |
648 | ))}
649 |
650 |
651 |
652 | {/* Template selection */}
653 |
654 |
655 |
656 | {templates.map(template => (
657 |
setSelectedTemplate(template)}
663 | >
664 |
673 | {template.pattern &&
}
674 |
675 |
{t(`templates.${template.key}`)}
676 |
677 | ))}
678 |
679 |
680 |
681 | {/* Background color picker */}
682 | {selectedTemplate.type === 'solid' && (
683 |
684 |
685 |
686 | setBackgroundColor(e.target.value)} className='w-12 h-12 rounded cursor-pointer' />
687 | {backgroundColor.toUpperCase()}
688 |
689 |
690 | )}
691 |
692 | {/* Text style settings */}
693 |
694 |
695 |
696 |
697 | {markerStyles.map(style => (
698 |
710 | ))}
711 |
712 |
713 | {/* Text color selection */}
714 |
715 |
716 |
717 | {textColorOptions.map(color => (
718 |
739 | ))}
740 |
741 |
742 |
743 |
744 |
745 |
746 | {textStyle.fontSize}px
747 |
748 |
749 |
750 |
751 | {/* Text input */}
752 |
753 |
754 |
761 |
762 |
763 | {/* Download button */}
764 |
765 |
768 |
769 |
770 |
771 | {/* Right preview panel - fixed position */}
772 |
777 |
778 |
779 | )
780 | }
781 |
--------------------------------------------------------------------------------
/components.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://ui.shadcn.com/schema.json",
3 | "style": "default",
4 | "rsc": true,
5 | "tsx": true,
6 | "tailwind": {
7 | "config": "tailwind.config.ts",
8 | "css": "styles/globals.css",
9 | "baseColor": "slate",
10 | "cssVariables": true,
11 | "prefix": ""
12 | },
13 | "aliases": {
14 | "components": "@/components",
15 | "utils": "@/lib/utils"
16 | }
17 | }
--------------------------------------------------------------------------------
/components/TailwindIndicator.tsx:
--------------------------------------------------------------------------------
1 | export function TailwindIndicator() {
2 | if (process.env.NODE_ENV === "production") return null
3 |
4 | return (
5 |
6 |
xs
7 |
8 | sm
9 |
10 |
md
11 |
lg
12 |
xl
13 |
2xl
14 |
15 | )
16 | }
17 |
--------------------------------------------------------------------------------
/components/ThemeProvider.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import { ThemeProvider as NextThemesProvider } from "next-themes"
5 | import { ThemeProviderProps } from "next-themes/dist/types"
6 |
7 | export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
8 | return {children}
9 | }
10 |
--------------------------------------------------------------------------------
/components/ThemedButton.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import PhMoonFill from "@/components/icons/moon";
3 | import PhSunBold from "@/components/icons/sun";
4 | import { useTheme } from "next-themes";
5 | import { useEffect, useState } from "react";
6 |
7 | export function ThemedButton() {
8 | const [mounted, setMounted] = useState(false);
9 | const { theme, setTheme } = useTheme();
10 |
11 | // useEffect only runs on the client, so now we can safely show the UI
12 | useEffect(() => {
13 | setMounted(true);
14 | }, []);
15 |
16 | if (!mounted) {
17 | return ;
18 | }
19 |
20 | return (
21 | setTheme(theme === "light" ? "dark" : "light")}>
22 | {theme === "light" ?
:
}
23 |
24 | );
25 | }
26 |
--------------------------------------------------------------------------------
/components/footer/Footer.tsx:
--------------------------------------------------------------------------------
1 | import FooterLinks from '@/components/footer/FooterLinks'
2 | import FooterProducts from '@/components/footer/FooterProducts'
3 | import { siteConfig } from '@/config/site'
4 | import { useTranslations } from 'next-intl'
5 | import Link from 'next/link'
6 |
7 | const Footer = () => {
8 | const t = useTranslations('Footer')
9 | const d = new Date()
10 | const currentYear = d.getFullYear()
11 | const { authors } = siteConfig
12 |
13 | return (
14 |
27 | )
28 | }
29 |
30 | export default Footer
31 |
--------------------------------------------------------------------------------
/components/footer/FooterLinks.tsx:
--------------------------------------------------------------------------------
1 | import { siteConfig } from "@/config/site";
2 | import Link from "next/link";
3 | import React from "react";
4 |
5 | const FooterLinks = () => {
6 | const links = siteConfig.footerLinks;
7 |
8 | return (
9 |
10 | {links.map((link) => (
11 |
18 | {link.icon &&
19 | React.createElement(link.icon, { className: "text-lg" })}
20 |
21 | ))}
22 |
23 | );
24 | };
25 |
26 | export default FooterLinks;
27 |
--------------------------------------------------------------------------------
/components/footer/FooterProducts.tsx:
--------------------------------------------------------------------------------
1 | import { siteConfig } from "@/config/site";
2 | import Link from "next/link";
3 |
4 | const FooterProducts = () => {
5 | const footerProducts = siteConfig.footerProducts;
6 |
7 | return (
8 |
9 | {footerProducts.map((product, index) => {
10 | return (
11 |
12 |
13 | {product.name}
14 |
15 | {index !== footerProducts.length - 1 ? (
16 | <>
17 | {" • "}
18 | >
19 | ) : (
20 | <>>
21 | )}
22 |
23 | );
24 | })}
25 |
26 | );
27 | };
28 |
29 | export default FooterProducts;
30 |
--------------------------------------------------------------------------------
/components/header/Header.tsx:
--------------------------------------------------------------------------------
1 | import { Link } from '@/i18n/routing'
2 | import { useTranslations } from 'next-intl'
3 | import Image from 'next/image'
4 | import { ThemedButton } from '../ThemedButton'
5 | import HeaderLinks from './HeaderLinks'
6 | import LanguageSwitcher from './LanguageSwitcher'
7 |
8 | const Header = () => {
9 | const t = useTranslations('Header')
10 |
11 | return (
12 |
13 |
14 |
34 |
35 |
36 | )
37 | }
38 |
39 | export default Header
40 |
--------------------------------------------------------------------------------
/components/header/HeaderLinks.tsx:
--------------------------------------------------------------------------------
1 | import { siteConfig } from "@/config/site";
2 | import Link from "next/link";
3 | import React from "react";
4 |
5 | const HeaderLinks = () => {
6 | const links = siteConfig.headerLinks;
7 |
8 | return (
9 |
10 | {links.map((link) => (
11 |
18 | {link.icon &&
19 | React.createElement(link.icon, { className: "text-lg" })}
20 |
21 | ))}
22 |
23 | );
24 | };
25 | export default HeaderLinks;
26 |
--------------------------------------------------------------------------------
/components/header/LanguageSwitcher.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { usePathname, useRouter } from '@/i18n/routing'
4 | import { useLocale, useTranslations } from 'next-intl'
5 | import { useTransition } from 'react'
6 |
7 | export default function LanguageSwitcher() {
8 | const t = useTranslations('Header')
9 | const locale = useLocale()
10 | const router = useRouter()
11 | const pathname = usePathname()
12 | const [isPending, startTransition] = useTransition()
13 |
14 | const switchLanguage = (newLocale: string) => {
15 | startTransition(() => {
16 | router.replace(pathname, { locale: newLocale })
17 | })
18 | }
19 |
20 | return (
21 |
22 |
31 |
32 | )
33 | }
34 |
--------------------------------------------------------------------------------
/components/icons/expanding-arrow.tsx:
--------------------------------------------------------------------------------
1 | export default function ExpandingArrow({ className }: { className?: string }) {
2 | return (
3 |
4 |
19 |
34 |
35 | );
36 | }
37 |
--------------------------------------------------------------------------------
/components/icons/eye.tsx:
--------------------------------------------------------------------------------
1 | export default function TablerEyeFilled(props: any) {
2 | return (
3 |
23 | );
24 | }
25 |
--------------------------------------------------------------------------------
/components/icons/index.tsx:
--------------------------------------------------------------------------------
1 | export { default as LoadingDots } from "./loading-dots";
2 | export { default as LoadingCircle } from "./loading-circle";
3 | export { default as LoadingSpinner } from "./loading-spinner";
4 | export { default as ExpandingArrow } from "./expanding-arrow";
5 |
--------------------------------------------------------------------------------
/components/icons/loading-circle.tsx:
--------------------------------------------------------------------------------
1 | export default function LoadingCircle() {
2 | return (
3 |
19 | );
20 | }
21 |
--------------------------------------------------------------------------------
/components/icons/loading-dots.module.css:
--------------------------------------------------------------------------------
1 | .loading {
2 | display: inline-flex;
3 | align-items: center;
4 | }
5 |
6 | .loading .spacer {
7 | margin-right: 2px;
8 | }
9 |
10 | .loading span {
11 | animation-name: blink;
12 | animation-duration: 1.4s;
13 | animation-iteration-count: infinite;
14 | animation-fill-mode: both;
15 | width: 5px;
16 | height: 5px;
17 | border-radius: 50%;
18 | display: inline-block;
19 | margin: 0 1px;
20 | }
21 |
22 | .loading span:nth-of-type(2) {
23 | animation-delay: 0.2s;
24 | }
25 |
26 | .loading span:nth-of-type(3) {
27 | animation-delay: 0.4s;
28 | }
29 |
30 | @keyframes blink {
31 | 0% {
32 | opacity: 0.2;
33 | }
34 | 20% {
35 | opacity: 1;
36 | }
37 | 100% {
38 | opacity: 0.2;
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/components/icons/loading-dots.tsx:
--------------------------------------------------------------------------------
1 | import styles from "./loading-dots.module.css";
2 |
3 | const LoadingDots = ({ color = "#000" }: { color?: string }) => {
4 | return (
5 |
6 |
7 |
8 |
9 |
10 | );
11 | };
12 |
13 | export default LoadingDots;
14 |
--------------------------------------------------------------------------------
/components/icons/loading-spinner.module.css:
--------------------------------------------------------------------------------
1 | .spinner {
2 | color: gray;
3 | display: inline-block;
4 | position: relative;
5 | width: 80px;
6 | height: 80px;
7 | transform: scale(0.3) translateX(-95px);
8 | }
9 | .spinner div {
10 | transform-origin: 40px 40px;
11 | animation: spinner 1.2s linear infinite;
12 | }
13 | .spinner div:after {
14 | content: " ";
15 | display: block;
16 | position: absolute;
17 | top: 3px;
18 | left: 37px;
19 | width: 6px;
20 | height: 20px;
21 | border-radius: 20%;
22 | background: black;
23 | }
24 | .spinner div:nth-child(1) {
25 | transform: rotate(0deg);
26 | animation-delay: -1.1s;
27 | }
28 | .spinner div:nth-child(2) {
29 | transform: rotate(30deg);
30 | animation-delay: -1s;
31 | }
32 | .spinner div:nth-child(3) {
33 | transform: rotate(60deg);
34 | animation-delay: -0.9s;
35 | }
36 | .spinner div:nth-child(4) {
37 | transform: rotate(90deg);
38 | animation-delay: -0.8s;
39 | }
40 | .spinner div:nth-child(5) {
41 | transform: rotate(120deg);
42 | animation-delay: -0.7s;
43 | }
44 | .spinner div:nth-child(6) {
45 | transform: rotate(150deg);
46 | animation-delay: -0.6s;
47 | }
48 | .spinner div:nth-child(7) {
49 | transform: rotate(180deg);
50 | animation-delay: -0.5s;
51 | }
52 | .spinner div:nth-child(8) {
53 | transform: rotate(210deg);
54 | animation-delay: -0.4s;
55 | }
56 | .spinner div:nth-child(9) {
57 | transform: rotate(240deg);
58 | animation-delay: -0.3s;
59 | }
60 | .spinner div:nth-child(10) {
61 | transform: rotate(270deg);
62 | animation-delay: -0.2s;
63 | }
64 | .spinner div:nth-child(11) {
65 | transform: rotate(300deg);
66 | animation-delay: -0.1s;
67 | }
68 | .spinner div:nth-child(12) {
69 | transform: rotate(330deg);
70 | animation-delay: 0s;
71 | }
72 | @keyframes spinner {
73 | 0% {
74 | opacity: 1;
75 | }
76 | 100% {
77 | opacity: 0;
78 | }
79 | }
80 |
--------------------------------------------------------------------------------
/components/icons/loading-spinner.tsx:
--------------------------------------------------------------------------------
1 | import styles from "./loading-spinner.module.css";
2 |
3 | export default function LoadingSpinner() {
4 | return (
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 | );
20 | }
21 |
--------------------------------------------------------------------------------
/components/icons/moon.tsx:
--------------------------------------------------------------------------------
1 | export default function PhMoonFill(props: any) {
2 | return (
3 |
15 | );
16 | }
17 |
--------------------------------------------------------------------------------
/components/icons/sun.tsx:
--------------------------------------------------------------------------------
1 | export default function PhSunBold(props: any) {
2 | return (
3 |
15 | );
16 | }
17 |
--------------------------------------------------------------------------------
/components/social-icons/icons.tsx:
--------------------------------------------------------------------------------
1 | import { SVGProps } from 'react'
2 |
3 | // Icons taken from: https://simpleicons.org/
4 | // To add a new icon, add a new function here and add it to components in social-icons/index.tsx
5 |
6 | export function Facebook(svgProps: SVGProps) {
7 | return (
8 |
11 | )
12 | }
13 |
14 | export function Github(svgProps: SVGProps) {
15 | return (
16 |
19 | )
20 | }
21 |
22 | export function Linkedin(svgProps: SVGProps) {
23 | return (
24 |
27 | )
28 | }
29 |
30 | export function Mail(svgProps: SVGProps) {
31 | return (
32 |
36 | )
37 | }
38 |
39 | export function Twitter(svgProps: SVGProps) {
40 | return (
41 |
44 | )
45 | }
46 |
47 | export function TwitterX(svgProps: SVGProps) {
48 | return (
49 |
55 | )
56 | }
57 |
58 | export function Youtube(svgProps: SVGProps) {
59 | return (
60 |
63 | )
64 | }
65 |
66 | export function Mastodon(svgProps: SVGProps) {
67 | return (
68 |
71 | )
72 | }
73 |
74 | export function Threads(svgProps: SVGProps) {
75 | return (
76 |
79 | )
80 | }
81 |
82 | export function Instagram(svgProps: SVGProps) {
83 | return (
84 |
87 | )
88 | }
89 |
90 | export function WeChat(svgProps: SVGProps) {
91 | return (
92 |
102 | )
103 | }
104 |
105 | export function JueJin(svgProps: SVGProps) {
106 | return (
107 |
113 | )
114 | }
115 |
--------------------------------------------------------------------------------
/components/social-icons/index.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Facebook,
3 | Github,
4 | Instagram,
5 | JueJin,
6 | Linkedin,
7 | Mail,
8 | Mastodon,
9 | Threads,
10 | Twitter,
11 | TwitterX,
12 | WeChat,
13 | Youtube
14 | } from './icons'
15 |
16 | const components = {
17 | mail: Mail,
18 | github: Github,
19 | facebook: Facebook,
20 | youtube: Youtube,
21 | linkedin: Linkedin,
22 | twitter: Twitter,
23 | twitterX: TwitterX,
24 | weChat: WeChat,
25 | jueJin: JueJin,
26 | mastodon: Mastodon,
27 | threads: Threads,
28 | instagram: Instagram
29 | }
30 |
31 | type SocialIconProps = {
32 | kind: keyof typeof components
33 | href: string | undefined
34 | size?: number
35 | }
36 |
37 | const SocialIcon = ({ kind, href, size = 8 }: SocialIconProps) => {
38 | if (
39 | !href ||
40 | (kind === 'mail' &&
41 | !/^mailto:\w+([.-]?\w+)@\w+([.-]?\w+)(.\w{2,3})+$/.test(href))
42 | )
43 | return null
44 |
45 | const SocialSvg = components[kind]
46 |
47 | return (
48 |
54 | {kind}
55 |
58 |
59 | )
60 | }
61 |
62 | export default SocialIcon
63 |
--------------------------------------------------------------------------------
/config/site.ts:
--------------------------------------------------------------------------------
1 | import { SiteConfig } from '@/types/siteConfig'
2 | import { BsGithub, BsTwitterX } from 'react-icons/bs'
3 | import { MdEmail } from 'react-icons/md'
4 |
5 | const baseSiteConfig = {
6 | name: 'Byte Text Image Generator',
7 | description: 'Text Image Generator',
8 | url: 'https://text-image.tool.vin/',
9 | metadataBase: '/',
10 | keywords: [],
11 | authors: [
12 | {
13 | name: 'Junexus',
14 | url: 'https://sphrag.com',
15 | twitter: 'https://x.com/Junexus_indie',
16 | },
17 | ],
18 | creator: '@unexus_indie',
19 | themeColors: [
20 | { media: '(prefers-color-scheme: light)', color: 'white' },
21 | { media: '(prefers-color-scheme: dark)', color: 'black' },
22 | ],
23 | defaultNextTheme: 'light', // next-theme option: system | dark | light
24 | icons: {
25 | icon: '/favicon.ico',
26 | shortcut: '/logo.png',
27 | apple: '/logo.png', // apple-touch-icon.png
28 | },
29 | headerLinks: [
30 | {
31 | name: 'github',
32 | href: 'https://github.com/shadowDragons/text-image-generator',
33 | icon: BsGithub,
34 | },
35 | {
36 | name: 'twitter',
37 | href: 'https://x.com/Junexus_indie',
38 | icon: BsTwitterX,
39 | },
40 | ],
41 | footerLinks: [
42 | { name: 'email', href: 'mailto:shadowdragon4399@gmail.com', icon: MdEmail },
43 | { name: 'twitter', href: 'https://x.com/Junexus_indie', icon: BsTwitterX },
44 | { name: 'github', href: 'https://github.com/shadowDragons', icon: BsGithub },
45 | ],
46 | footerProducts: [],
47 | }
48 |
49 | export const siteConfig: SiteConfig = {
50 | ...baseSiteConfig,
51 | openGraph: {
52 | type: 'website',
53 | locale: 'en-US',
54 | url: baseSiteConfig.url,
55 | title: baseSiteConfig.name,
56 | description: baseSiteConfig.description,
57 | siteName: baseSiteConfig.name,
58 | images: [`${baseSiteConfig.url}og.png`],
59 | },
60 | twitter: {
61 | card: 'summary_large_image',
62 | title: baseSiteConfig.name,
63 | description: baseSiteConfig.description,
64 | creator: baseSiteConfig.creator,
65 | },
66 | }
67 |
--------------------------------------------------------------------------------
/gtag.js:
--------------------------------------------------------------------------------
1 | export const GA_TRACKING_ID = process.env.NEXT_PUBLIC_GOOGLE_ID || null;
2 |
3 | export const pageview = (url) => {
4 | window.gtag("config", GA_TRACKING_ID, {
5 | page_path: url,
6 | });
7 | };
8 |
9 | export const event = ({ action, category, label, value }) => {
10 | window.gtag("event", action, {
11 | event_category: category,
12 | event_label: label,
13 | value: value,
14 | });
15 | };
16 |
--------------------------------------------------------------------------------
/i18n/request.ts:
--------------------------------------------------------------------------------
1 | import { getRequestConfig } from 'next-intl/server'
2 | import { routing } from './routing'
3 |
4 | export default getRequestConfig(async ({ requestLocale }) => {
5 | // This typically corresponds to the `[locale]` segment
6 | let locale = await requestLocale
7 |
8 | // Ensure that a valid locale is used
9 | if (!locale || !routing.locales.includes(locale as any)) {
10 | locale = routing.defaultLocale
11 | }
12 |
13 | return {
14 | locale,
15 | messages: (await import(`../messages/${locale}.json`)).default,
16 | }
17 | })
18 |
--------------------------------------------------------------------------------
/i18n/routing.ts:
--------------------------------------------------------------------------------
1 | import { createNavigation } from 'next-intl/navigation'
2 | import { defineRouting } from 'next-intl/routing'
3 |
4 | export const routing = defineRouting({
5 | // A list of all locales that are supported
6 | locales: ['en', 'zh'],
7 |
8 | // Used when no locale matches
9 | defaultLocale: 'en',
10 | })
11 |
12 | // Lightweight wrappers around Next.js' navigation APIs
13 | // that will consider the routing configuration
14 | export const { Link, redirect, usePathname, useRouter, getPathname } = createNavigation(routing)
15 |
--------------------------------------------------------------------------------
/lib/logger.ts:
--------------------------------------------------------------------------------
1 | import * as fs from 'fs'
2 | import * as winston from 'winston'
3 | import 'winston-daily-rotate-file'
4 |
5 | const logDir: string = process.env.LOG_DIR || 'log' // Use environment variable or default value
6 |
7 | if (!fs.existsSync(logDir)) {
8 | fs.mkdirSync(logDir, { recursive: true })
9 | }
10 |
11 | const fileTransport = new winston.transports.DailyRotateFile({
12 | filename: `${logDir}/%DATE%-results.log`,
13 | datePattern: 'YYYY-MM-DD',
14 | zippedArchive: true,
15 | maxSize: '20m',
16 | maxFiles: '3d', // Keep logs for 3 days
17 | level: 'info', // This transport records logs of info level and above (info, warning, error)
18 | })
19 |
20 | const logger: winston.Logger = winston.createLogger({
21 | level: 'debug', // Minimum level
22 | format: winston.format.combine(
23 | winston.format.timestamp({
24 | format: 'YYYY-MM-DD HH:mm:ss',
25 | }),
26 | winston.format.json()
27 | ),
28 | transports: [
29 | fileTransport,
30 | new winston.transports.Console({
31 | level: 'debug', // Console outputs logs of all levels
32 | format: winston.format.combine(winston.format.colorize(), winston.format.simple()),
33 | }),
34 | ],
35 | })
36 |
37 | export default logger
38 |
--------------------------------------------------------------------------------
/lib/utils.ts:
--------------------------------------------------------------------------------
1 | import { type ClassValue, clsx } from "clsx"
2 | import { twMerge } from "tailwind-merge"
3 |
4 | export function cn(...inputs: ClassValue[]) {
5 | return twMerge(clsx(inputs))
6 | }
7 |
--------------------------------------------------------------------------------
/messages/en.json:
--------------------------------------------------------------------------------
1 | {
2 | "HomePage": {
3 | "title": "Hello world!",
4 | "about": "Go to the about page"
5 | },
6 | "Header": {
7 | "title1": "Byte",
8 | "title2": "Text Image Generator",
9 | "language": {
10 | "en": "English",
11 | "zh": "中文"
12 | }
13 | },
14 | "Footer": {
15 | "copyright": "All rights reserved."
16 | },
17 | "Generator": {
18 | "imageSize": "Image Size",
19 | "template": "Select Template",
20 | "backgroundColor": "Background Color",
21 | "textStyle": "Text Style",
22 | "fontSize": "Font Size",
23 | "inputText": "Input Text",
24 | "download": "Download Image",
25 | "textColor": "Text Color",
26 | "textColors": {
27 | "auto": "Auto",
28 | "white": "White",
29 | "black": "Black",
30 | "red": "Red",
31 | "blue": "Blue",
32 | "green": "Green",
33 | "yellow": "Yellow",
34 | "orange": "Orange",
35 | "purple": "Purple"
36 | },
37 | "markerStyles": {
38 | "none": "No Effect",
39 | "yellow": "Yellow Marker",
40 | "green": "Green Marker",
41 | "pink": "Pink Marker",
42 | "blue": "Blue Marker"
43 | },
44 | "sizes": {
45 | "default": "1080×1350 (Default)",
46 | "square": "1080×1080 (Square)",
47 | "landscape": "1920×1080 (Landscape)",
48 | "medium": "800×600",
49 | "small": "500×500 (Small)"
50 | },
51 | "templates": {
52 | "solid": "Custom Solid Color",
53 | "gradient1": "Gradient Template 1",
54 | "gradient2": "Gradient Template 2",
55 | "gradient3": "Gradient Template 3",
56 | "gradient4": "Gradient Template 4",
57 | "gradient5": "Gradient Template 5",
58 | "gradient6": "Gradient Template 6",
59 | "wave": "Wave Template",
60 | "dots": "Dots Template",
61 | "hexagon": "Hexagon Template",
62 | "grid": "Grid Template",
63 | "gradient7": "Gradient Template 7",
64 | "gradient8": "Gradient Template 8",
65 | "gradient9": "Gradient Template 9",
66 | "gradient10": "Gradient Template 10",
67 | "gradient11": "Gradient Template 11",
68 | "gradient12": "Gradient Template 12",
69 | "stripes": "Stripes Pattern",
70 | "circles": "Circles Pattern",
71 | "triangles": "Triangles Pattern"
72 | }
73 | }
74 | }
75 |
--------------------------------------------------------------------------------
/messages/zh.json:
--------------------------------------------------------------------------------
1 | {
2 | "HomePage": {
3 | "title": "你好世界!",
4 | "about": "Go to the about page"
5 | },
6 | "Header": {
7 | "title1": "字节在线",
8 | "title2": "文转图",
9 | "language": {
10 | "en": "English",
11 | "zh": "中文"
12 | }
13 | },
14 | "Footer": {
15 | "copyright": "版权所有"
16 | },
17 | "Generator": {
18 | "imageSize": "图片尺寸",
19 | "template": "选择模板",
20 | "backgroundColor": "背景颜色",
21 | "textStyle": "文字样式",
22 | "fontSize": "字体大小",
23 | "inputText": "输入文字",
24 | "download": "下载图片",
25 | "textColor": "文字颜色",
26 | "textColors": {
27 | "auto": "自动",
28 | "white": "白色",
29 | "black": "黑色",
30 | "red": "红色",
31 | "blue": "蓝色",
32 | "green": "绿色",
33 | "yellow": "黄色",
34 | "orange": "橙色",
35 | "purple": "紫色"
36 | },
37 | "markerStyles": {
38 | "none": "无效果",
39 | "yellow": "黄色马克笔",
40 | "green": "绿色马克笔",
41 | "pink": "粉色马克笔",
42 | "blue": "蓝色马克笔"
43 | },
44 | "sizes": {
45 | "default": "1080×1350 (默认)",
46 | "square": "1080×1080 (方形)",
47 | "landscape": "1920×1080 (横版)",
48 | "medium": "800×600",
49 | "small": "500×500 (小尺寸)"
50 | },
51 | "templates": {
52 | "solid": "自定义纯色",
53 | "gradient1": "渐变模板1",
54 | "gradient2": "渐变模板2",
55 | "gradient3": "渐变模板3",
56 | "gradient4": "渐变模板4",
57 | "gradient5": "渐变模板5",
58 | "gradient6": "渐变模板6",
59 | "wave": "波浪模板",
60 | "dots": "点阵模板",
61 | "hexagon": "六边形模板",
62 | "grid": "网格模板",
63 | "gradient7": "渐变模板7",
64 | "gradient8": "渐变模板8",
65 | "gradient9": "渐变模板9",
66 | "gradient10": "渐变模板10",
67 | "gradient11": "渐变模板11",
68 | "gradient12": "渐变模板12",
69 | "stripes": "条纹模板",
70 | "circles": "圆圈模板",
71 | "triangles": "三角形模板"
72 | }
73 | }
74 | }
75 |
--------------------------------------------------------------------------------
/middleware.ts:
--------------------------------------------------------------------------------
1 | import createMiddleware from 'next-intl/middleware'
2 | import { routing } from './i18n/routing'
3 |
4 | export default createMiddleware(routing)
5 |
6 | export const config = {
7 | // Match only internationalized pathnames
8 | matcher: ['/', '/(zh|en)/:path*'],
9 | }
10 |
--------------------------------------------------------------------------------
/next-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 | ///
3 |
4 | // NOTE: This file should not be edited
5 | // see https://nextjs.org/docs/basic-features/typescript for more information.
6 |
--------------------------------------------------------------------------------
/next-sitemap.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('next-sitemap').IConfig} */
2 |
3 | module.exports = {
4 | siteUrl: process.env.SITE_URL || 'https://text-image.tool.vin',
5 | generateRobotsTxt: true,
6 | sitemapSize: 7000,
7 | }
8 |
--------------------------------------------------------------------------------
/next.config.mjs:
--------------------------------------------------------------------------------
1 | import createNextIntlPlugin from 'next-intl/plugin'
2 |
3 | const withNextIntl = createNextIntlPlugin()
4 |
5 | /** @type {import('next').NextConfig} */
6 | const nextConfig = {}
7 |
8 | export default withNextIntl(nextConfig)
9 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "clean-nextjs-starter",
3 | "version": "0.1.0",
4 | "private": true,
5 | "scripts": {
6 | "dev": "next dev",
7 | "postbuild": "next-sitemap",
8 | "build": "next build",
9 | "start": "next start",
10 | "lint": "next lint"
11 | },
12 | "dependencies": {
13 | "@types/winston": "^2.4.4",
14 | "@vercel/analytics": "^1.1.2",
15 | "class-variance-authority": "^0.7.0",
16 | "clsx": "^2.1.0",
17 | "lucide-react": "^0.316.0",
18 | "next": "14.1.0",
19 | "next-intl": "^3.26.0",
20 | "next-themes": "^0.2.1",
21 | "react": "^18",
22 | "react-dom": "^18",
23 | "react-hot-toast": "^2.4.1",
24 | "react-icons": "^5.0.1",
25 | "tailwind-merge": "^2.2.1",
26 | "tailwindcss-animate": "^1.0.7",
27 | "winston": "^3.13.0",
28 | "winston-daily-rotate-file": "^5.0.0"
29 | },
30 | "devDependencies": {
31 | "@types/node": "^20",
32 | "@types/react": "^18",
33 | "@types/react-dom": "^18",
34 | "autoprefixer": "^10.0.1",
35 | "eslint": "^8",
36 | "eslint-config-next": "14.1.0",
37 | "next-sitemap": "^4.2.3",
38 | "postcss": "^8",
39 | "tailwindcss": "^3.3.0",
40 | "typescript": "^5"
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/postcss.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | };
7 |
--------------------------------------------------------------------------------
/public/afd.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/shadowDragons/text-image-generator/89632884e96cdfc262f41db5b3a56df98d2b5976/public/afd.png
--------------------------------------------------------------------------------
/public/card.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/shadowDragons/text-image-generator/89632884e96cdfc262f41db5b3a56df98d2b5976/public/card.png
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/shadowDragons/text-image-generator/89632884e96cdfc262f41db5b3a56df98d2b5976/public/favicon.ico
--------------------------------------------------------------------------------
/public/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/shadowDragons/text-image-generator/89632884e96cdfc262f41db5b3a56df98d2b5976/public/logo.png
--------------------------------------------------------------------------------
/public/logo.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/og.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/shadowDragons/text-image-generator/89632884e96cdfc262f41db5b3a56df98d2b5976/public/og.png
--------------------------------------------------------------------------------
/styles/globals.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | @layer base {
6 | :root {
7 | --background: 0 0% 100%;
8 | --foreground: 222.2 84% 4.9%;
9 |
10 | --card: 0 0% 100%;
11 | --card-foreground: 222.2 84% 4.9%;
12 |
13 | --popover: 0 0% 100%;
14 | --popover-foreground: 222.2 84% 4.9%;
15 |
16 | --primary: 222.2 47.4% 11.2%;
17 | --primary-foreground: 210 40% 98%;
18 |
19 | --secondary: 210 40% 96.1%;
20 | --secondary-foreground: 222.2 47.4% 11.2%;
21 |
22 | --muted: 210 40% 96.1%;
23 | --muted-foreground: 215.4 16.3% 46.9%;
24 |
25 | --accent: 210 40% 96.1%;
26 | --accent-foreground: 222.2 47.4% 11.2%;
27 |
28 | --destructive: 0 84.2% 60.2%;
29 | --destructive-foreground: 210 40% 98%;
30 |
31 | --border: 214.3 31.8% 91.4%;
32 | --input: 214.3 31.8% 91.4%;
33 | --ring: 222.2 84% 4.9%;
34 |
35 | --radius: 0.5rem;
36 | }
37 |
38 | .dark {
39 | --background: 222.2 84% 4.9%;
40 | --foreground: 210 40% 98%;
41 |
42 | --card: 222.2 84% 4.9%;
43 | --card-foreground: 210 40% 98%;
44 |
45 | --popover: 222.2 84% 4.9%;
46 | --popover-foreground: 210 40% 98%;
47 |
48 | --primary: 210 40% 98%;
49 | --primary-foreground: 222.2 47.4% 11.2%;
50 |
51 | --secondary: 217.2 32.6% 17.5%;
52 | --secondary-foreground: 210 40% 98%;
53 |
54 | --muted: 217.2 32.6% 17.5%;
55 | --muted-foreground: 215 20.2% 65.1%;
56 |
57 | --accent: 217.2 32.6% 17.5%;
58 | --accent-foreground: 210 40% 98%;
59 |
60 | --destructive: 0 62.8% 30.6%;
61 | --destructive-foreground: 210 40% 98%;
62 |
63 | --border: 217.2 32.6% 17.5%;
64 | --input: 217.2 32.6% 17.5%;
65 | --ring: 212.7 26.8% 83.9%;
66 | }
67 | }
68 |
69 | @layer base {
70 | * {
71 | @apply border-border;
72 | }
73 | body {
74 | @apply bg-background text-foreground;
75 | }
76 | }
--------------------------------------------------------------------------------
/styles/loading.css:
--------------------------------------------------------------------------------
1 | .loading {
2 | display: inline-flex;
3 | align-items: center;
4 | }
5 |
6 | .loading .spacer {
7 | margin-right: 2px;
8 | }
9 |
10 | .loading span {
11 | animation-name: blink;
12 | animation-duration: 1.4s;
13 | animation-iteration-count: infinite;
14 | animation-fill-mode: both;
15 | width: 5px;
16 | height: 5px;
17 | border-radius: 50%;
18 | display: inline-block;
19 | margin: 0 1px;
20 | }
21 |
22 | .loading span:nth-of-type(2) {
23 | animation-delay: 0.2s;
24 | }
25 |
26 | .loading span:nth-of-type(3) {
27 | animation-delay: 0.4s;
28 | }
29 |
30 | @keyframes blink {
31 | 0% {
32 | opacity: 0.2;
33 | }
34 | 20% {
35 | opacity: 1;
36 | }
37 | 100% {
38 | opacity: 0.2;
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/tailwind.config.ts:
--------------------------------------------------------------------------------
1 | import type { Config } from "tailwindcss"
2 |
3 | const config = {
4 | darkMode: ["class"],
5 | content: [
6 | './pages/**/*.{ts,tsx}',
7 | './components/**/*.{ts,tsx}',
8 | './app/**/*.{ts,tsx}',
9 | './src/**/*.{ts,tsx}',
10 | ],
11 | prefix: "",
12 | theme: {
13 | container: {
14 | center: true,
15 | padding: "2rem",
16 | screens: {
17 | "2xl": "1400px",
18 | },
19 | },
20 | extend: {
21 | colors: {
22 | border: "hsl(var(--border))",
23 | input: "hsl(var(--input))",
24 | ring: "hsl(var(--ring))",
25 | background: "hsl(var(--background))",
26 | foreground: "hsl(var(--foreground))",
27 | primary: {
28 | DEFAULT: "hsl(var(--primary))",
29 | foreground: "hsl(var(--primary-foreground))",
30 | },
31 | secondary: {
32 | DEFAULT: "hsl(var(--secondary))",
33 | foreground: "hsl(var(--secondary-foreground))",
34 | },
35 | destructive: {
36 | DEFAULT: "hsl(var(--destructive))",
37 | foreground: "hsl(var(--destructive-foreground))",
38 | },
39 | muted: {
40 | DEFAULT: "hsl(var(--muted))",
41 | foreground: "hsl(var(--muted-foreground))",
42 | },
43 | accent: {
44 | DEFAULT: "hsl(var(--accent))",
45 | foreground: "hsl(var(--accent-foreground))",
46 | },
47 | popover: {
48 | DEFAULT: "hsl(var(--popover))",
49 | foreground: "hsl(var(--popover-foreground))",
50 | },
51 | card: {
52 | DEFAULT: "hsl(var(--card))",
53 | foreground: "hsl(var(--card-foreground))",
54 | },
55 | },
56 | borderRadius: {
57 | lg: "var(--radius)",
58 | md: "calc(var(--radius) - 2px)",
59 | sm: "calc(var(--radius) - 4px)",
60 | },
61 | keyframes: {
62 | "accordion-down": {
63 | from: { height: "0" },
64 | to: { height: "var(--radix-accordion-content-height)" },
65 | },
66 | "accordion-up": {
67 | from: { height: "var(--radix-accordion-content-height)" },
68 | to: { height: "0" },
69 | },
70 | },
71 | animation: {
72 | "accordion-down": "accordion-down 0.2s ease-out",
73 | "accordion-up": "accordion-up 0.2s ease-out",
74 | },
75 | },
76 | },
77 | plugins: [require("tailwindcss-animate")],
78 | } satisfies Config
79 |
80 | export default config
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "lib": ["dom", "dom.iterable", "esnext"],
4 | "allowJs": true,
5 | "skipLibCheck": true,
6 | "strict": true,
7 | "noEmit": true,
8 | "esModuleInterop": true,
9 | "module": "esnext",
10 | "moduleResolution": "bundler",
11 | "resolveJsonModule": true,
12 | "isolatedModules": true,
13 | "jsx": "preserve",
14 | "incremental": true,
15 | "plugins": [
16 | {
17 | "name": "next"
18 | }
19 | ],
20 | "paths": {
21 | "@/*": ["./*"]
22 | }
23 | },
24 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
25 | "exclude": ["node_modules"]
26 | }
27 |
--------------------------------------------------------------------------------
/types/siteConfig.ts:
--------------------------------------------------------------------------------
1 | import { IconType } from 'react-icons'
2 |
3 | export type AuthorsConfig = {
4 | name: string
5 | url: string
6 | twitter?: string
7 | }
8 | export type ProductLink = {
9 | url: string
10 | name: string
11 | }
12 | export type Link = {
13 | name: string
14 | href: string
15 | icon: IconType
16 | }
17 | export type ThemeColor = {
18 | media: string
19 | color: string
20 | }
21 | export type SiteConfig = {
22 | name: string
23 | description: string
24 | url: string
25 | keywords: string[]
26 | authors: AuthorsConfig[]
27 | creator: string
28 | headerLinks: Link[]
29 | footerLinks: Link[]
30 | footerProducts: ProductLink[]
31 | metadataBase: URL | string
32 | themeColors?: string | ThemeColor[]
33 | defaultNextTheme?: string
34 | icons: {
35 | icon: string
36 | shortcut?: string
37 | apple?: string
38 | }
39 | openGraph: {
40 | type: string
41 | locale: string
42 | url: string
43 | title: string
44 | description: string
45 | siteName: string
46 | images?: string[]
47 | }
48 | twitter: {
49 | card: string
50 | title: string
51 | description: string
52 | images?: string[]
53 | creator: string
54 | }
55 | }
56 |
--------------------------------------------------------------------------------