first commit
41
.gitignore
vendored
Normal file
@@ -0,0 +1,41 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
4
.roo/rules-code/rules.md
Normal file
@@ -0,0 +1,4 @@
|
||||
- 始终使用中文进行提示,使用英文进行代码输出
|
||||
- 始终使用 @radix-ui/themes 里面的组件,禁止自定义基础组件
|
||||
- 禁止修改 package.json
|
||||
- 始终考虑多语言
|
||||
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2024 Sovers Tonmoy Pandey
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
268
README.md
Normal file
@@ -0,0 +1,268 @@
|
||||
# Next.js 15 Template with i18n and Shadcn UI
|
||||
|
||||
A modern, SEO-optimized template for Next.js 15 applications featuring server components, internationalization support, shadcn UI components, and theme switching capabilities. Perfect for building performant, accessible, and multilingual web applications.
|
||||
|
||||
## ✨ Features
|
||||
|
||||
- **Next.js 15**: Built on the latest [Next.js 15](https://nextjs.org/) React framework with App Router and Server Components for optimal performance
|
||||
- **SEO Optimization**: Includes metadata API, structured data, and optimized page loading strategies
|
||||
- **Internationalization**: Full i18n support using middleware-based routing with [next-intl](https://next-intl-docs.vercel.app/)
|
||||
- **Shadcn UI**: Pre-configured [shadcn UI](https://ui.shadcn.com/) components using the new React Server Components pattern
|
||||
- **Theme System**: CSS Variables-based theme system with light/dark mode toggle and system preference detection
|
||||
- **Language Switching**: Seamless switching between languages (including RTL support for Arabic and other RTL languages)
|
||||
- **OmitRTL Utility**: Helper component to control elements that should maintain LTR (left-to-right) rendering in RTL contexts
|
||||
- **TypeScript**: Type-safe codebase with TypeScript configuration optimized for Next.js 15
|
||||
- **Metadata API**: Built-in SEO metadata management using Next.js 15's metadata API
|
||||
|
||||
## 🚀 Getting Started
|
||||
|
||||
Clone the repository:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/S0vers/next-app-i18n-starter.git
|
||||
```
|
||||
|
||||
Install dependencies:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
# or
|
||||
yarn
|
||||
# or
|
||||
pnpm install
|
||||
# or
|
||||
bun install
|
||||
```
|
||||
|
||||
Start the development server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
# or
|
||||
pnpm dev
|
||||
# or
|
||||
bun dev
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) in your browser to see the result.
|
||||
|
||||
## 📋 Project Structure
|
||||
|
||||
The project follows Next.js 15's recommended App Router structure with additions for internationalization:
|
||||
|
||||
```
|
||||
├── .next # Next.js build output
|
||||
├── dictionary # i18n translation files
|
||||
│ ├── ar.json # Arabic translations
|
||||
│ └── en.json # English translations
|
||||
├── node_modules # Dependencies
|
||||
├── public # Static assets
|
||||
├── src # Source code
|
||||
│ ├── app # Next.js App Router
|
||||
│ │ ├── [locale] # Dynamic locale routing
|
||||
│ │ │ ├── page.tsx # Home page
|
||||
│ │ │ ├── error.tsx # Error handling
|
||||
│ │ │ ├── favicon.ico # Favicon
|
||||
│ │ │ ├── globals.css # Global styles
|
||||
│ │ │ ├── robots.txt # SEO robots file
|
||||
│ │ │ └── sitemap.ts # Dynamic sitemap generation
|
||||
│ │ └── components # Application components
|
||||
│ │ ├── ui # shadcn UI components
|
||||
│ │ ├── LanguageSwitcher.tsx # Language toggle component
|
||||
│ │ ├── ModeToggle.tsx # Theme toggle component
|
||||
│ │ ├── OmitRTL.tsx # RTL handling utility
|
||||
│ │ └── theme-provider.tsx # Theme context provider
|
||||
│ ├── i18n # Internationalization utilities
|
||||
│ │ ├── navigation.ts # Localized navigation helpers
|
||||
│ │ ├── requests.ts # i18n-aware API request helpers
|
||||
│ │ └── routing.ts # Locale routing utilities
|
||||
│ ├── lib # Utility functions and shared code
|
||||
│ │ └── middleware.ts # i18n middleware for route handling
|
||||
│ └── components.json # shadcn UI component configuration
|
||||
├── .eslintrc.json # ESLint configuration
|
||||
├── global.d.ts # Global TypeScript declarations
|
||||
├── LICENSE # Project license
|
||||
├── next-env.d.ts # Next.js TypeScript declarations
|
||||
├── next.config.js # Next.js configuration
|
||||
├── package.json # Project dependencies and scripts
|
||||
├── bun.lock # Bun lock file
|
||||
├── postcss.config.js # PostCSS configuration
|
||||
├── README.md # Project documentation
|
||||
└── tsconfig.json # TypeScript configuration
|
||||
```
|
||||
|
||||
## 🌐 Internationalization
|
||||
|
||||
This template uses middleware-based i18n routing with Next.js 15. Language files are stored in the `dictionary/` directory.
|
||||
|
||||
### Adding a New Language
|
||||
|
||||
1. Create a new JSON file in the `dictionary/` directory (e.g., `fr.json`)
|
||||
2. Add the language to the supported locales in `middleware.ts` and `lib/i18n.ts`
|
||||
3. Add language option to the `LanguageSwitcher` component
|
||||
|
||||
## 🎨 Shadcn UI Components
|
||||
|
||||
Shadcn UI components are configured to work with Next.js 15 Server Components. Import them from the `components/ui/` directory:
|
||||
|
||||
```jsx
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export default function Home() {
|
||||
return <Button>Click me</Button>;
|
||||
}
|
||||
```
|
||||
|
||||
## 🔄 OmitRTL Utility
|
||||
|
||||
The `OmitRTL` utility helps you control which elements should maintain LTR direction even when the site is in RTL mode.
|
||||
|
||||
### How to use the function:
|
||||
|
||||
```jsx
|
||||
import { OmitRTL } from "@/components/OmitRTL";
|
||||
|
||||
function MyComponent() {
|
||||
return (
|
||||
<div>
|
||||
<p>This text will follow the website's direction.</p>
|
||||
<OmitRTL omitRTL={true}>
|
||||
<img src="/logo.png" alt="Logo" />
|
||||
<div>
|
||||
<h2>This heading and content will always be LTR</h2>
|
||||
<p>Regardless of the website's direction.</p>
|
||||
</div>
|
||||
</OmitRTL>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### NPM Package
|
||||
|
||||
If you just need the OmitRTL function, it's also available as an npm package:
|
||||
|
||||
```bash
|
||||
npm i react-omit-rtl
|
||||
```
|
||||
|
||||
```jsx
|
||||
import React from "react";
|
||||
import OmitRTL from "react-omit-rtl";
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<OmitRTL omitRTL={true}>
|
||||
<p>This text will not have RTL direction.</p>
|
||||
</OmitRTL>
|
||||
);
|
||||
}
|
||||
export default App;
|
||||
```
|
||||
|
||||
## 🔍 SEO Optimization
|
||||
|
||||
The template provides comprehensive SEO features with the Next.js 15 Metadata API:
|
||||
|
||||
```jsx
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: { locale: string },
|
||||
}): Promise<Metadata> {
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations({ locale, namespace: "Metadata" });
|
||||
|
||||
return {
|
||||
title: t("title"),
|
||||
description: t("description"),
|
||||
other: {
|
||||
"google-site-verification": "********",
|
||||
},
|
||||
openGraph: {
|
||||
title: t("title"),
|
||||
description: t("description"),
|
||||
url: `next-app-i18n-starter.vercel.app`,
|
||||
siteName: "Next.js i18n Template",
|
||||
images: [
|
||||
{
|
||||
url: "next-app-i18n-starter.vercel.app/og-image.png",
|
||||
width: 1200,
|
||||
height: 630,
|
||||
},
|
||||
],
|
||||
locale: locale,
|
||||
type: "website",
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: t("title"),
|
||||
description: t("description"),
|
||||
images: ["next-app-i18n-starter.vercel.app/og-image.png"],
|
||||
},
|
||||
alternates: {
|
||||
canonical: `next-app-i18n-starter.vercel.app`,
|
||||
languages: {
|
||||
en: "next-app-i18n-starter.vercel.app",
|
||||
ar: "next-app-i18n-starter.vercel.app",
|
||||
},
|
||||
},
|
||||
robots: {
|
||||
index: true,
|
||||
follow: true,
|
||||
googleBot: {
|
||||
index: true,
|
||||
follow: true,
|
||||
"max-video-preview": -1,
|
||||
"max-image-preview": "large",
|
||||
"max-snippet": -1,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
Additionally, structured data is implemented using react-schemaorg for better search engine understanding:
|
||||
|
||||
```jsx
|
||||
<script
|
||||
{...(jsonLdScriptProps <
|
||||
WebSite >
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "WebSite",
|
||||
name: "Next.js i18n Template",
|
||||
description:
|
||||
"A humble Next 15 starter with i18n, shadcn UI, light/dark themes, and language switch.",
|
||||
url: "next-app-i18n-starter.vercel.app",
|
||||
})}
|
||||
/>
|
||||
```
|
||||
|
||||
Other SEO features included in the template:
|
||||
|
||||
- Canonical URLs to prevent duplicate content issues
|
||||
- Language-specific metadata with translations
|
||||
- Proper HTML lang attribute based on current locale
|
||||
- Dynamic sitemap generation
|
||||
- Robots.txt configuration
|
||||
- Google site verification
|
||||
- Optimized OpenGraph and Twitter card images
|
||||
|
||||
These features work together to help search engines better understand, index, and display your content to potential visitors across different languages and regions.
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
We welcome contributions to improve this template! Here's how you can help:
|
||||
|
||||
1. Fork the repository
|
||||
2. Create a new branch (`git checkout -b feature/your-feature`)
|
||||
3. Commit your changes (`git commit -am 'Add some feature'`)
|
||||
4. Push to the branch (`git push origin feature/your-feature`)
|
||||
5. Create a new Pull Request
|
||||
|
||||
## 📄 License
|
||||
|
||||
This project is licensed under the MIT License - see the LICENSE file for details.
|
||||
21
components.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/app/globals.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"iconLibrary": "lucide"
|
||||
}
|
||||
105
dictionary/en.json
Normal file
@@ -0,0 +1,105 @@
|
||||
{
|
||||
"Index": {
|
||||
"appName": "Screen Designer",
|
||||
"heroTitle": "Create Screen Designs",
|
||||
"heroSubtitle": "Design and customize beautiful and useful backgrounds for your devices",
|
||||
"toolTitle": "Design Tool",
|
||||
"getStarted": "Get Started",
|
||||
"featuresTitle": "Key Features",
|
||||
"feature1Title": "Easy Customization",
|
||||
"feature1Desc": "Intuitive tools to create perfect backgrounds",
|
||||
"feature2Title": "Multiple Options",
|
||||
"feature2Desc": "Choose from colors or upload your own images",
|
||||
"feature3Title": "Real-time Preview",
|
||||
"feature3Desc": "See changes instantly as you design",
|
||||
"testimonialsTitle": "What Our Users Say",
|
||||
"testimonial1Text": "This tool saved me hours of work!",
|
||||
"testimonial1Author": "John D., Designer",
|
||||
"testimonial2Text": "The easiest way to create professional backgrounds",
|
||||
"testimonial2Author": "Sarah M., Developer",
|
||||
"ctaTitle": "Ready to Create?",
|
||||
"ctaSubtitle": "Start designing your perfect background today",
|
||||
"ctaButton": "Start Designing",
|
||||
"faqTitle": "Frequently Asked Questions",
|
||||
"faqQuestion1": "How do I download my design?",
|
||||
"faqAnswer1": "Click the download button after finishing your design",
|
||||
"faqQuestion2": "Can I use my own images?",
|
||||
"faqAnswer2": "Yes, you can upload any image as background",
|
||||
"faqQuestion3": "Is this tool free to use?",
|
||||
"faqAnswer3": "Yes, it's completely free with no hidden charges"
|
||||
},
|
||||
"BackgoundSetting": {
|
||||
"title": "Background Setting",
|
||||
"colorOption": "Color",
|
||||
"imageOption": "Image",
|
||||
"selectColor": "choose color",
|
||||
"uploadImage": "upload image"
|
||||
},
|
||||
"PreviewBar": {
|
||||
"previewFullscreen": "Fullscreen Preview",
|
||||
"downloadSize": "Size",
|
||||
"downloadBackground": "Download"
|
||||
},
|
||||
"TextEditor": {
|
||||
"title": "Text Editor",
|
||||
"defaultText": "please input your text",
|
||||
"textColor": "Text Color",
|
||||
"fontFamily": "Font Family",
|
||||
"fontWeight": "Font Weight"
|
||||
},
|
||||
"Metadata": {
|
||||
"title": "Screen Designer",
|
||||
"description": "Tool for designing custom screen backgrounds"
|
||||
},
|
||||
"Footer": {
|
||||
"copyright": "© {year} Screen Designer. All rights reserved."
|
||||
},
|
||||
"BlackScreen": {
|
||||
"toolTitle": "Black Screen Design Tool",
|
||||
"seoTitle": "Black Screen Generator - Create Pure Black Backgrounds",
|
||||
"seoDescription": "Generate pure black screens for OLED displays, dark mode testing, and energy saving. Customize and download high-quality black backgrounds.",
|
||||
"heroTitle": "Pure Black Screen Generator",
|
||||
"heroSubtitle": "Create perfect black backgrounds for your devices",
|
||||
"feature1Title": "OLED Friendly",
|
||||
"feature1Desc": "True black saves power on OLED displays",
|
||||
"feature2Title": "Dark Mode Testing",
|
||||
"feature2Desc": "Perfect for testing dark mode interfaces",
|
||||
"feature3Title": "High Quality",
|
||||
"feature3Desc": "Crisp, pure black backgrounds at any resolution",
|
||||
"ctaTitle": "Ready to Use?",
|
||||
"ctaSubtitle": "Start using your perfect black background now",
|
||||
"ctaButton": "Use Black Screen"
|
||||
},
|
||||
"WhiteScreen": {
|
||||
"toolTitle": "White Screen Design Tool",
|
||||
"seoTitle": "White Screen Generator - Create Pure White Backgrounds",
|
||||
"seoDescription": "Generate pure white screens for display testing, light mode testing, and calibration. Customize and download high-quality white backgrounds.",
|
||||
"heroTitle": "Pure White Screen Generator",
|
||||
"heroSubtitle": "Create perfect white backgrounds for your devices",
|
||||
"feature1Title": "Display Testing",
|
||||
"feature1Desc": "Pure white helps detect dead pixels and screen uniformity",
|
||||
"feature2Title": "Light Mode Testing",
|
||||
"feature2Desc": "Perfect for testing light mode interfaces",
|
||||
"feature3Title": "High Quality",
|
||||
"feature3Desc": "Crisp, pure white backgrounds at any resolution",
|
||||
"ctaTitle": "Ready to Use?",
|
||||
"ctaSubtitle": "Start using your perfect white background now",
|
||||
"ctaButton": "Use White Screen"
|
||||
},
|
||||
"DoNotWriteOnThisPage": {
|
||||
"seoTitle": "Do Not Write On This Page Generator - Create Custom Backgrounds",
|
||||
"seoDescription": "Generate 'Do Not Write On This Page' backgrounds for notebooks, whiteboards and more. Customize text and colors, then download high-quality images.",
|
||||
"toolTitle": "Do Not Write On This Page Generator",
|
||||
"heroTitle": "Create Custom 'Do Not Write On This Page' Backgrounds",
|
||||
"heroSubtitle": "Design perfect backgrounds for your notebooks, whiteboards and devices",
|
||||
"feature1Title": "Customizable Text",
|
||||
"feature1Desc": "Change the text, font and color to suit your needs",
|
||||
"feature2Title": "Background Options",
|
||||
"feature2Desc": "Choose from colors or upload your own background image",
|
||||
"feature3Title": "High Quality",
|
||||
"feature3Desc": "Download crisp, high-resolution images for any use",
|
||||
"ctaTitle": "Ready to Create?",
|
||||
"ctaSubtitle": "Start designing your custom background now",
|
||||
"ctaButton": "Create Background"
|
||||
}
|
||||
}
|
||||
105
dictionary/zh.json
Normal file
@@ -0,0 +1,105 @@
|
||||
{
|
||||
"Index": {
|
||||
"appName": "屏幕背景设计工具",
|
||||
"heroTitle": "创建屏幕背景",
|
||||
"heroSubtitle": "为您的设备设计和定制精美且有用的背景",
|
||||
"toolTitle": "设计工具",
|
||||
"getStarted": "立即开始",
|
||||
"featuresTitle": "主要功能",
|
||||
"feature1Title": "轻松定制",
|
||||
"feature1Desc": "直观的工具帮助您创建完美背景",
|
||||
"feature2Title": "多种选择",
|
||||
"feature2Desc": "可选择纯色或上传自己的图片",
|
||||
"feature3Title": "实时预览",
|
||||
"feature3Desc": "设计时即时查看效果",
|
||||
"testimonialsTitle": "用户评价",
|
||||
"testimonial1Text": "这个工具节省了我大量时间!",
|
||||
"testimonial1Author": "张先生,设计师",
|
||||
"testimonial2Text": "创建专业背景的最简单方式",
|
||||
"testimonial2Author": "李女士,开发者",
|
||||
"ctaTitle": "准备好开始了吗?",
|
||||
"ctaSubtitle": "立即开始设计您的完美背景",
|
||||
"ctaButton": "开始设计",
|
||||
"faqTitle": "常见问题",
|
||||
"faqQuestion1": "如何下载我的设计?",
|
||||
"faqAnswer1": "完成设计后点击下载按钮",
|
||||
"faqQuestion2": "可以使用自己的图片吗?",
|
||||
"faqAnswer2": "可以,您可以上传任何图片作为背景",
|
||||
"faqQuestion3": "这个工具是免费的吗?",
|
||||
"faqAnswer3": "是的,完全免费且无隐藏收费"
|
||||
},
|
||||
"BackgoundSetting": {
|
||||
"title": "背景设置",
|
||||
"colorOption": "颜色背景",
|
||||
"imageOption": "图片背景",
|
||||
"selectColor": "选择颜色",
|
||||
"uploadImage": "上传图片"
|
||||
},
|
||||
"PreviewBar": {
|
||||
"previewFullscreen": "全屏预览",
|
||||
"downloadSize": "尺寸",
|
||||
"downloadBackground": "下载图片"
|
||||
},
|
||||
"TextEditor": {
|
||||
"title": "文字编辑",
|
||||
"defaultText": "输入您的文字",
|
||||
"textColor": "文字颜色",
|
||||
"fontFamily": "选择字体",
|
||||
"fontWeight": "字体粗细"
|
||||
},
|
||||
"Metadata": {
|
||||
"title": "屏幕背景设计工具",
|
||||
"description": "用于设计自定义屏幕背景的工具"
|
||||
},
|
||||
"Footer": {
|
||||
"copyright": "© {year} 屏幕背景设计工具 版权所有"
|
||||
},
|
||||
"BlackScreen": {
|
||||
"toolTitle": "黑色屏幕设计工具",
|
||||
"seoTitle": "纯黑屏幕生成器 - 创建纯黑背景",
|
||||
"seoDescription": "生成纯黑屏幕用于OLED显示、暗黑模式测试和节能。自定义并下载高质量黑色背景。",
|
||||
"heroTitle": "纯黑屏幕生成器",
|
||||
"heroSubtitle": "为您的设备创建完美黑色背景",
|
||||
"feature1Title": "OLED友好",
|
||||
"feature1Desc": "纯黑色可节省OLED显示屏电量",
|
||||
"feature2Title": "暗黑模式测试",
|
||||
"feature2Desc": "完美用于测试暗黑模式界面",
|
||||
"feature3Title": "高质量",
|
||||
"feature3Desc": "任何分辨率下都清晰锐利的纯黑背景",
|
||||
"ctaTitle": "准备好使用了吗?",
|
||||
"ctaSubtitle": "立即开始使用您的完美黑色背景",
|
||||
"ctaButton": "使用纯黑屏幕"
|
||||
},
|
||||
"WhiteScreen": {
|
||||
"toolTitle": "白色屏幕设计工具",
|
||||
"seoTitle": "纯白屏幕生成器 - 创建纯白背景",
|
||||
"seoDescription": "生成纯白屏幕用于显示测试、亮色模式测试和校准。自定义并下载高质量白色背景。",
|
||||
"heroTitle": "纯白屏幕生成器",
|
||||
"heroSubtitle": "为您的设备创建完美白色背景",
|
||||
"feature1Title": "显示测试",
|
||||
"feature1Desc": "纯白色可用于检测显示屏坏点和均匀性",
|
||||
"feature2Title": "亮色模式测试",
|
||||
"feature2Desc": "完美用于测试亮色模式界面",
|
||||
"feature3Title": "高质量",
|
||||
"feature3Desc": "任何分辨率下都清晰锐利的纯白背景",
|
||||
"ctaTitle": "准备好使用了吗?",
|
||||
"ctaSubtitle": "立即开始使用您的完美白色背景",
|
||||
"ctaButton": "使用纯白屏幕"
|
||||
},
|
||||
"DoNotWriteOnThisPage": {
|
||||
"seoTitle": "请勿在此页书写生成器 - 创建自定义背景",
|
||||
"seoDescription": "生成'请勿在此页书写'背景,适用于笔记本、白板等。自定义文字和颜色,下载高质量图片。",
|
||||
"toolTitle": "请勿在此页书写生成器",
|
||||
"heroTitle": "创建自定义'请勿在此页书写'背景",
|
||||
"heroSubtitle": "为您的笔记本、白板和设备设计完美背景",
|
||||
"feature1Title": "可自定义文字",
|
||||
"feature1Desc": "可更改文字、字体和颜色以满足需求",
|
||||
"feature2Title": "背景选项",
|
||||
"feature2Desc": "可选择纯色或上传自定义背景图片",
|
||||
"feature3Title": "高质量",
|
||||
"feature3Desc": "可下载高分辨率图片用于各种用途",
|
||||
"ctaTitle": "准备好开始了吗?",
|
||||
"ctaSubtitle": "立即开始设计您的自定义背景",
|
||||
"ctaButton": "创建背景"
|
||||
}
|
||||
}
|
||||
23
eslint.config.mjs
Normal file
@@ -0,0 +1,23 @@
|
||||
import { dirname } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import { FlatCompat } from "@eslint/eslintrc";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
const compat = new FlatCompat({
|
||||
baseDirectory: __dirname,
|
||||
});
|
||||
|
||||
const eslintConfig = [
|
||||
...compat.config({
|
||||
extends: ['next'],
|
||||
rules: {
|
||||
'react/no-unescaped-entities': 'off',
|
||||
'@typescript-eslint/no-unused-vars': 'off',
|
||||
'@next/next/no-html-link-for-pages': 'off',
|
||||
},
|
||||
}),
|
||||
]
|
||||
|
||||
export default eslintConfig;
|
||||
8
global.d.ts
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
import en from "./messages/en.json";
|
||||
|
||||
type Messages = typeof en;
|
||||
|
||||
declare global {
|
||||
// Use type safe message keys with `next-intl`
|
||||
type IntlMessages = Messages;
|
||||
}
|
||||
7
next.config.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { NextConfig } from "next";
|
||||
import createNextIntlPlugin from "next-intl/plugin";
|
||||
|
||||
const nextConfig: NextConfig = {};
|
||||
|
||||
const withNextIntl = createNextIntlPlugin();
|
||||
export default withNextIntl(nextConfig);
|
||||
46
package.json
Normal file
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"name": "screen",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/themes": "^3.2.1",
|
||||
"@vercel/analytics": "^1.5.0",
|
||||
"@vercel/speed-insights": "^1.2.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.479.0",
|
||||
"motion": "^12.23.11",
|
||||
"next": "15.2.4",
|
||||
"next-intl": "^4.3.4",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "19.0.0",
|
||||
"react-dom": "19.0.0",
|
||||
"react-schemaorg": "^2.0.0",
|
||||
"schema-dts": "^1.1.5",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"three": "^0.178.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3.3.1",
|
||||
"@tailwindcss/postcss": "^4.1.11",
|
||||
"@types/node": "^20.19.9",
|
||||
"@types/react": "19.0.12",
|
||||
"@types/react-dom": "19.0.4",
|
||||
"@types/three": "0.178.0",
|
||||
"eslint": "^9.32.0",
|
||||
"eslint-config-next": "15.2.3",
|
||||
"tailwindcss": "^4.1.11",
|
||||
"typescript": "^5.8.3"
|
||||
},
|
||||
"overrides": {
|
||||
"@types/react": "19.0.12",
|
||||
"@types/react-dom": "19.0.4"
|
||||
}
|
||||
}
|
||||
5849
pnpm-lock.yaml
generated
Normal file
5
postcss.config.mjs
Normal file
@@ -0,0 +1,5 @@
|
||||
const config = {
|
||||
plugins: ["@tailwindcss/postcss"],
|
||||
};
|
||||
|
||||
export default config;
|
||||
1
public/file.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 391 B |
13
public/fonts/LICENSE
Normal file
@@ -0,0 +1,13 @@
|
||||
Copyright @ 2004 by MAGENTA Ltd. All Rights Reserved.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of the fonts accompanying this license ("Fonts") and associated documentation files (the "Font Software"), to reproduce and distribute the Font Software, including without limitation the rights to use, copy, merge, publish, distribute, and/or sell copies of the Font Software, and to permit persons to whom the Font Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright and this permission notice shall be included in all copies of one or more of the Font Software typefaces.
|
||||
|
||||
The Font Software may be modified, altered, or added to, and in particular the designs of glyphs or characters in the Fonts may be modified and additional glyphs or characters may be added to the Fonts, only if the fonts are renamed to names not containing the word "MgOpen", or if the modifications are accepted for inclusion in the Font Software itself by the each appointed Administrator.
|
||||
|
||||
This License becomes null and void to the extent applicable to Fonts or Font Software that has been modified and is distributed under the "MgOpen" name.
|
||||
|
||||
The Font Software may be sold as part of a larger software package but no copy of one or more of the Font Software typefaces may be sold by itself.
|
||||
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL MAGENTA OR PERSONS OR BODIES IN CHARGE OF ADMINISTRATION AND MAINTENANCE OF THE FONT SOFTWARE BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
1
public/fonts/gentilis_bold.typeface.json
Normal file
1
public/fonts/gentilis_regular.typeface.json
Normal file
1
public/fonts/helvetiker_bold.typeface.json
Normal file
1
public/fonts/helvetiker_regular.typeface.json
Normal file
1
public/fonts/optimer_bold.typeface.json
Normal file
1
public/fonts/optimer_regular.typeface.json
Normal file
1
public/globe.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
1
public/next.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
BIN
public/og-image-black-screen.png
Normal file
|
After Width: | Height: | Size: 73 KiB |
BIN
public/og-image-do-not-write-on-this-page.png
Normal file
|
After Width: | Height: | Size: 320 KiB |
BIN
public/og-image-white-screen.png
Normal file
|
After Width: | Height: | Size: 76 KiB |
BIN
public/og-image.png
Normal file
|
After Width: | Height: | Size: 460 KiB |
1
public/vercel.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 283 64"><path fill="black" d="M141 16c-11 0-19 7-19 18s9 18 20 18c7 0 13-3 16-7l-7-5c-2 3-6 4-9 4-5 0-9-3-10-7h28v-3c0-11-8-18-19-18zm-9 15c1-4 4-7 9-7s8 3 9 7h-18zm117-15c-11 0-19 7-19 18s9 18 20 18c6 0 12-3 16-7l-8-5c-2 3-5 4-8 4-5 0-9-3-11-7h28l1-3c0-11-8-18-19-18zm-10 15c2-4 5-7 10-7s8 3 9 7h-19zm-39 3c0 6 4 10 10 10 4 0 7-2 9-5l8 5c-3 5-9 8-17 8-11 0-19-7-19-18s8-18 19-18c8 0 14 3 17 8l-8 5c-2-3-5-5-9-5-6 0-10 4-10 10zm83-29v46h-9V5h9zM37 0l37 64H0L37 0zm92 5-27 48L74 5h10l18 30 17-30h10zm59 12v10l-3-1c-6 0-10 4-10 10v15h-9V17h9v9c0-5 6-9 13-9z"/></svg>
|
||||
|
After Width: | Height: | Size: 629 B |
1
public/window.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
||||
|
After Width: | Height: | Size: 385 B |
5
src/app/[locale]/[...rest]/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
export default function CatchAllPage() {
|
||||
notFound();
|
||||
}
|
||||
140
src/app/[locale]/do-not-write-on-this-page/page.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
import { use } from "react";
|
||||
import { getTranslations, setRequestLocale } from "next-intl/server";
|
||||
import Footer from "@/components/Footer";
|
||||
import Header from "@/components/Header";
|
||||
import { useTranslations } from "next-intl";
|
||||
import DoNotWriteOnThisPage from "@/components/screen/DoNotWriteOnThisPage";
|
||||
import { Metadata } from "next";
|
||||
|
||||
export default function Page({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>;
|
||||
}) {
|
||||
const { locale } = use(params);
|
||||
// Enable static rendering
|
||||
setRequestLocale(locale);
|
||||
|
||||
const t = useTranslations("DoNotWriteOnThisPage");
|
||||
const indexT = useTranslations("Index");
|
||||
|
||||
return (
|
||||
<div className="flex flex-col min-h-screen overflow-hidden">
|
||||
{/* Header */}
|
||||
<Header />
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="flex-1 w-full">
|
||||
{/* Hero Section */}
|
||||
<section className=" text-white py-20 bg-gradient-to-r from-blue-500 to-purple-600">
|
||||
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 text-center">
|
||||
<h1 className="text-4xl font-bold mb-4">{t("heroTitle")}</h1>
|
||||
<p className="text-xl mb-8">{t("heroSubtitle")}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Tool Section */}
|
||||
<section
|
||||
className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 py-6"
|
||||
id="designTool"
|
||||
>
|
||||
<h2 className="text-3xl font-bold text-center mb-12">
|
||||
{t("toolTitle")}
|
||||
</h2>
|
||||
<DoNotWriteOnThisPage></DoNotWriteOnThisPage>
|
||||
</section>
|
||||
|
||||
{/* Features Section */}
|
||||
<section className="py-16 bg-gray-50">
|
||||
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||
<h2 className="text-3xl font-bold mb-4 text-center">
|
||||
{indexT("featuresTitle")}
|
||||
</h2>
|
||||
<div className="grid md:grid-cols-3 gap-8">
|
||||
<div className="text-center">
|
||||
<h3 className="text-xl font-bold mb-2">{t("feature1Title")}</h3>
|
||||
<p>{t("feature1Desc")}</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<h3 className="text-xl font-bold mb-2">{t("feature2Title")}</h3>
|
||||
<p>{t("feature2Desc")}</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<h3 className="text-xl font-bold mb-2">{t("feature3Title")}</h3>
|
||||
<p>{t("feature3Desc")}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* CTA Section */}
|
||||
<section className="py-16 text-center">
|
||||
<h2 className="text-3xl font-bold mb-4">{t("ctaTitle")}</h2>
|
||||
<p className="text-xl mb-8">{t("ctaSubtitle")}</p>
|
||||
<a
|
||||
className="bg-black text-white px-8 py-3 rounded-lg text-lg font-medium"
|
||||
href="#designTool"
|
||||
>
|
||||
{t("ctaButton")}
|
||||
</a>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
{/* Footer */}
|
||||
<Footer></Footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>;
|
||||
}): Promise<Metadata> {
|
||||
const { locale } = await params;
|
||||
|
||||
const t = await getTranslations({
|
||||
locale: locale,
|
||||
namespace: "DoNotWriteOnThisPage",
|
||||
});
|
||||
|
||||
const host = process.env.NEXT_PUBLIC_HOST;
|
||||
|
||||
const name = "do-not-write-on-this-page";
|
||||
|
||||
return {
|
||||
title: t("seoTitle"),
|
||||
description: t("seoDescription"),
|
||||
openGraph: {
|
||||
title: t("seoTitle"),
|
||||
description: t("seoDescription"),
|
||||
url: `${host}/${locale}/${name}`,
|
||||
images: [
|
||||
{
|
||||
url: `${process.env.NEXT_PUBLIC_HOST}/og-image-${name}.png`,
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: t("seoTitle"),
|
||||
},
|
||||
],
|
||||
locale: locale,
|
||||
type: "website",
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: t("seoTitle"),
|
||||
description: t("seoDescription"),
|
||||
images: [`${process.env.NEXT_PUBLIC_HOST}/og-image-${name}.png`],
|
||||
},
|
||||
alternates: {
|
||||
canonical: `${host}/${name}`,
|
||||
languages: {
|
||||
en: `${host}/en/${name}`,
|
||||
ar: `${host}/ar/${name}`,
|
||||
zh: `${host}/zh/${name}`,
|
||||
es: `${host}/es/${name}`,
|
||||
ja: `${host}/jp/${name}`,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
84
src/app/[locale]/layout.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import { NextIntlClientProvider, hasLocale } from "next-intl";
|
||||
import { notFound } from "next/navigation";
|
||||
import { routing } from "@/i18n/routing";
|
||||
import { getTranslations, setRequestLocale } from "next-intl/server";
|
||||
import { jsonLdScriptProps } from "react-schemaorg";
|
||||
import { WebSite } from "schema-dts";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import { Analytics } from "@vercel/analytics/react";
|
||||
import { SpeedInsights } from "@vercel/speed-insights/next";
|
||||
import "../globals.css";
|
||||
import { ThemeProvider } from "next-themes";
|
||||
import { Theme } from "@radix-ui/themes";
|
||||
const host = process.env.NEXT_PUBLIC_HOST;
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
export default async function RootLayout({
|
||||
children,
|
||||
params,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
params: Promise<{ locale: string }>;
|
||||
}) {
|
||||
// Ensure that the incoming `locale` is valid
|
||||
const { locale } = await params;
|
||||
if (!hasLocale(routing.locales, locale)) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
// Enable static rendering
|
||||
setRequestLocale(locale);
|
||||
|
||||
const isArabic = locale === "ar";
|
||||
const t = await getTranslations({ locale, namespace: "Metadata" });
|
||||
return (
|
||||
<html lang={locale} dir={isArabic ? "rtl" : "ltr"} suppressHydrationWarning>
|
||||
<head>
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link
|
||||
rel="preconnect"
|
||||
href="https://fonts.gstatic.com"
|
||||
crossOrigin="anonymous"
|
||||
/>
|
||||
{/* <meta name="keywords" content={t("keywords")} /> */}
|
||||
<meta name="author" content="ymk" />
|
||||
<meta name="robots" content="index, follow" />
|
||||
<script
|
||||
{...jsonLdScriptProps<WebSite>({
|
||||
"@context": "https://schema.org",
|
||||
"@type": "WebSite",
|
||||
name: t("title"),
|
||||
description: t("description"),
|
||||
url: host,
|
||||
inLanguage: locale,
|
||||
})}
|
||||
/>
|
||||
</head>
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
suppressHydrationWarning
|
||||
>
|
||||
|
||||
<ThemeProvider attribute="class" >
|
||||
<Theme accentColor="iris">
|
||||
<NextIntlClientProvider>{children}</NextIntlClientProvider>
|
||||
</Theme>
|
||||
</ThemeProvider>
|
||||
|
||||
<Analytics />
|
||||
<SpeedInsights />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
43
src/app/[locale]/not-found.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import Link from "next/link";
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<html>
|
||||
<body className="flex items-center justify-center h-screen bg-slate-500/20">
|
||||
<div className="bg-white p-8 rounded-lg shadow-2xl max-w-md">
|
||||
<div className="flex flex-col items-center justify-center mb-6">
|
||||
<svg
|
||||
className="w-16 h-16 text-yellow-500 mb-2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
||||
/>
|
||||
</svg>
|
||||
<h1 className="text-3xl font-bold text-gray-800">
|
||||
Oops! Something went wrong.
|
||||
</h1>
|
||||
</div>
|
||||
<p className="text-gray-600 text-lg mb-8 text-center">
|
||||
We're sorry, but we couldn't find the page you were
|
||||
looking for. Please check the URL or go back to the homepage.
|
||||
</p>
|
||||
<div className="flex justify-center">
|
||||
<Link
|
||||
href="/"
|
||||
className="bg-gradient-to-r from-purple-500 to-pink-500 hover:from-purple-600 hover:to-pink-600 text-white font-bold py-3 px-6 rounded-full transition duration-200 ease-in-out hover:scale-105"
|
||||
>
|
||||
Go Back Home
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
206
src/app/[locale]/page.tsx
Normal file
@@ -0,0 +1,206 @@
|
||||
import { use } from "react";
|
||||
import { getTranslations, setRequestLocale } from "next-intl/server";
|
||||
import Footer from "@/components/Footer";
|
||||
import Header from "@/components/Header";
|
||||
import Editor from "@/components/Editor";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Locales } from "@/i18n/config";
|
||||
import { Metadata } from "next";
|
||||
import { Box, Flex, Heading, Section, Text } from "@radix-ui/themes";
|
||||
const host = process.env.NEXT_PUBLIC_HOST;
|
||||
export default function HomePage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>;
|
||||
}) {
|
||||
const { locale } = use(params);
|
||||
// Enable static rendering
|
||||
setRequestLocale(locale);
|
||||
|
||||
const t = useTranslations("Index");
|
||||
|
||||
return (
|
||||
<div className="flex flex-col min-h-screen overflow-hidden">
|
||||
{/* Header */}
|
||||
<Header />
|
||||
{/* Main Content */}
|
||||
<Flex direction={"column"} justify={"center"} align={"center"}>
|
||||
{/* Hero Section */}
|
||||
<Section className="w-full bg-gradient-to-r from-blue-500 to-purple-600">
|
||||
<Flex justify={"center"} align={"center"} direction={"column"}>
|
||||
<Heading as="h1" size={"7"} >
|
||||
{t("heroTitle")}
|
||||
</Heading>
|
||||
<Box p={"4"} >
|
||||
<Text size={"6"}> {t("heroSubtitle")}</Text>
|
||||
</Box>
|
||||
<a
|
||||
className="bg-white text-blue-600 px-8 py-3 rounded-full font-bold text-lg hover:bg-gray-100 transition-colors"
|
||||
href="#designTool"
|
||||
>
|
||||
<Text size={"5"}> {t("getStarted")}</Text>
|
||||
</a>
|
||||
</Flex>
|
||||
</Section>
|
||||
|
||||
{/* 工具栏 */}
|
||||
<Section p="4" className="md:w-2/3 w-full">
|
||||
<Flex justify={"between"} align={"center"} direction={"column"} gap={"2"}>
|
||||
<Heading as="h2" size={"6"}>
|
||||
{t("toolTitle")}
|
||||
</Heading>
|
||||
<Editor></Editor>
|
||||
</Flex>
|
||||
|
||||
</Section>
|
||||
|
||||
{/* Features Section */}
|
||||
<Section className="w-full py-16 bg-gradient-to-br from-orange-50 to-amber-50">
|
||||
<div className="container mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||
<h2 className="text-3xl font-bold text-center mb-12">
|
||||
{t("featuresTitle")}
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||
<div className="bg-white p-6 rounded-lg shadow-sm border-l-4 border-orange-500">
|
||||
<h3 className="text-xl font-bold mb-3">{t("feature1Title")}</h3>
|
||||
<p className="text-gray-600">{t("feature1Desc")}</p>
|
||||
</div>
|
||||
<div className="bg-white p-6 rounded-lg shadow-sm border-l-4 border-amber-500">
|
||||
<h3 className="text-xl font-bold mb-3">{t("feature2Title")}</h3>
|
||||
<p className="text-gray-600">{t("feature2Desc")}</p>
|
||||
</div>
|
||||
<div className="bg-white p-6 rounded-lg shadow-sm border-l-4 border-yellow-500">
|
||||
<h3 className="text-xl font-bold mb-3">{t("feature3Title")}</h3>
|
||||
<p className="text-gray-600">{t("feature3Desc")}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* Testimonials Section */}
|
||||
<Section className="w-full py-16 bg-gradient-to-br from-blue-50 to-purple-50">
|
||||
<div className="container mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 space-y-8">
|
||||
<h2 className="text-3xl font-bold text-center mb-12">
|
||||
{t("testimonialsTitle")}
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||
<div className="bg-white p-6 rounded-lg shadow-sm border-l-4 border-blue-500">
|
||||
<p className="text-gray-600 mb-4">"{t("testimonial1Text")}"</p>
|
||||
<p className="font-semibold">- {t("testimonial1Author")}</p>
|
||||
</div>
|
||||
<div className="bg-white p-6 rounded-lg shadow-sm border-l-4 border-purple-500">
|
||||
<p className="text-gray-600 mb-4">"{t("testimonial2Text")}"</p>
|
||||
<p className="font-semibold">- {t("testimonial2Author")}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* CTA Section */}
|
||||
<section className="w-full py-16 bg-blue-600 text-white">
|
||||
<div className="container mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 text-center">
|
||||
<h2 className="text-3xl font-bold mb-6">{t("ctaTitle")}</h2>
|
||||
<p className="text-xl mb-8 max-w-3xl mx-auto">{t("ctaSubtitle")}</p>
|
||||
<a
|
||||
className="bg-white text-blue-600 px-8 py-3 rounded-full font-bold text-lg hover:bg-gray-100 transition-colors"
|
||||
href="#designTool"
|
||||
>
|
||||
{t("ctaButton")}
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* FAQ Section */}
|
||||
<section className="w-full py-16 bg-white">
|
||||
<div className="container mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||
<h2 className="text-3xl font-bold text-center mb-12">
|
||||
{t("faqTitle")}
|
||||
</h2>
|
||||
<div className="max-w-3xl mx-auto space-y-4">
|
||||
<div className="border-b pb-4">
|
||||
<h3 className="text-xl font-bold mb-2">{t("faqQuestion1")}</h3>
|
||||
<p className="text-gray-600">{t("faqAnswer1")}</p>
|
||||
</div>
|
||||
<div className="border-b pb-4">
|
||||
<h3 className="text-xl font-bold mb-2">{t("faqQuestion2")}</h3>
|
||||
<p className="text-gray-600">{t("faqAnswer2")}</p>
|
||||
</div>
|
||||
<div className="border-b pb-4">
|
||||
<h3 className="text-xl font-bold mb-2">{t("faqQuestion3")}</h3>
|
||||
<p className="text-gray-600">{t("faqAnswer3")}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</Flex>
|
||||
|
||||
{/* Footer */}
|
||||
<Footer></Footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const locales = Locales;
|
||||
|
||||
export function generateStaticParams() {
|
||||
return locales.map((locale) => ({ locale }));
|
||||
}
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>;
|
||||
}): Promise<Metadata> {
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations({ locale, namespace: "Metadata" });
|
||||
|
||||
return {
|
||||
title: t("title"),
|
||||
description: t("description"),
|
||||
// keywords: t("keywords"),
|
||||
// other: {
|
||||
// "google-site-verification": "sVYBYfSJfXdBca3QoqsZtD6lsWVH6sk02RCH4YAbcm8",
|
||||
// },
|
||||
openGraph: {
|
||||
title: t("title"),
|
||||
description: t("description"),
|
||||
url: host,
|
||||
siteName: "screen customization",
|
||||
images: [
|
||||
{
|
||||
url: `${host}/og-image.png`,
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: t("title"),
|
||||
},
|
||||
],
|
||||
locale: locale,
|
||||
type: "website",
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: t("title"),
|
||||
description: t("description"),
|
||||
images: [`${host}/og-image.png`],
|
||||
creator: "@s0ver5",
|
||||
},
|
||||
alternates: {
|
||||
canonical: `${host}`,
|
||||
languages: {
|
||||
en: `${host}/en`,
|
||||
zh: `${host}/zh`,
|
||||
},
|
||||
},
|
||||
robots: {
|
||||
index: true,
|
||||
follow: true,
|
||||
googleBot: {
|
||||
index: true,
|
||||
follow: true,
|
||||
"max-video-preview": -1,
|
||||
"max-image-preview": "large",
|
||||
"max-snippet": -1,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
47
src/app/error.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
"use client";
|
||||
|
||||
import { ServerCrash } from "lucide-react";
|
||||
import { use, useEffect } from "react";
|
||||
|
||||
import { Link } from "@/i18n/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { setRequestLocale } from "next-intl/server";
|
||||
import { Button } from "@radix-ui/themes";
|
||||
|
||||
export default function Error({
|
||||
params,
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>;
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
// Log the error to an error reporting service
|
||||
console.error(error);
|
||||
}, [error]);
|
||||
const { locale } = use(params);
|
||||
setRequestLocale(locale);
|
||||
const t = useTranslations("error");
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[70vh] px-4 text-center">
|
||||
<div className="flex items-center justify-center w-20 h-20 mb-6 rounded-full bg-red-100">
|
||||
<ServerCrash className="w-10 h-10 text-red-600" />
|
||||
</div>
|
||||
<h1 className="mb-2 text-3xl font-bold tracking-tight text-gray-900 sm:text-4xl">
|
||||
{t("title")}
|
||||
</h1>
|
||||
<p className="mb-8 text-lg text-gray-600">{t("sorry")}</p>
|
||||
<div className="flex flex-col gap-4 sm:flex-row">
|
||||
<Button onClick={() => reset()} variant="classic">
|
||||
{t("tryAgain")}
|
||||
</Button>
|
||||
<Button variant="outline" asChild>
|
||||
<Link href="/">{t("returnHome")}</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
BIN
src/app/favicon.ico
Normal file
|
After Width: | Height: | Size: 25 KiB |
123
src/app/globals.css
Normal file
@@ -0,0 +1,123 @@
|
||||
@import "tailwindcss";
|
||||
@import "@radix-ui/themes/styles.css";
|
||||
@plugin "tailwindcss-animate";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-ring: var(--ring);
|
||||
--color-input: var(--input);
|
||||
--color-border: var(--border);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-card: var(--card);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
}
|
||||
|
||||
:root {
|
||||
--radius: 0.625rem;
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.205 0 0);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.922 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
--chart-2: oklch(0.696 0.17 162.48);
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
--chart-4: oklch(0.627 0.265 303.9);
|
||||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.556 0 0);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
4
src/app/robots.txt
Normal file
@@ -0,0 +1,4 @@
|
||||
User-agent: *
|
||||
Allow: /
|
||||
|
||||
Sitemap: https://screen.mysoul.fun/sitemap.xml
|
||||
59
src/app/sitemap.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { Locales } from "@/i18n/config";
|
||||
import { MetadataRoute } from "next";
|
||||
|
||||
const host = process.env.NEXT_PUBLIC_HOST;
|
||||
export default function sitemap(): MetadataRoute.Sitemap {
|
||||
const baseUrl = host!;
|
||||
const locales = Locales;
|
||||
|
||||
return [
|
||||
{
|
||||
url: baseUrl,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: "yearly",
|
||||
priority: 1,
|
||||
},
|
||||
...locales.map((locale) => ({
|
||||
url: `${baseUrl}/${locale}`,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: "monthly" as const,
|
||||
priority: 0.8,
|
||||
})),
|
||||
{
|
||||
url: baseUrl + "/black-screen",
|
||||
lastModified: new Date(),
|
||||
changeFrequency: "yearly",
|
||||
priority: 1,
|
||||
},
|
||||
...locales.map((locale) => ({
|
||||
url: `${baseUrl}/${locale}/black-screen`,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: "monthly" as const,
|
||||
priority: 0.8,
|
||||
})),
|
||||
{
|
||||
url: baseUrl + "/white-screen",
|
||||
lastModified: new Date(),
|
||||
changeFrequency: "yearly",
|
||||
priority: 1,
|
||||
},
|
||||
...locales.map((locale) => ({
|
||||
url: `${baseUrl}/${locale}/white-screen`,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: "monthly" as const,
|
||||
priority: 0.8,
|
||||
})),
|
||||
{
|
||||
url: baseUrl + "/do-not-write-on-this-page",
|
||||
lastModified: new Date(),
|
||||
changeFrequency: "yearly",
|
||||
priority: 1,
|
||||
},
|
||||
...locales.map((locale) => ({
|
||||
url: `${baseUrl}/${locale}/do-not-write-on-this-page`,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: "monthly" as const,
|
||||
priority: 0.8,
|
||||
})),
|
||||
];
|
||||
}
|
||||
42
src/components/Editor.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
"use client";
|
||||
import { Flex, Box } from "@radix-ui/themes";
|
||||
import BackgroundSelector, {
|
||||
BackgroundProp,
|
||||
} from "./common/BackgroundSelector";
|
||||
import PreviewToolbar from "./common/PreviewToolbar";
|
||||
import TextSetting, { FontNames, FontWeights, TextProp } from "./common/TextSetting";
|
||||
import { useState } from "react";
|
||||
|
||||
/**
|
||||
* 全特性工具栏
|
||||
* @returns
|
||||
*/
|
||||
export default function Page() {
|
||||
const [background, setBackground] = useState<BackgroundProp>({
|
||||
type: "color",
|
||||
color: "#ffffff",
|
||||
image: null,
|
||||
});
|
||||
const [text, setText] = useState<TextProp>({
|
||||
text: "default",
|
||||
color: "black",
|
||||
font: FontNames[0],
|
||||
weight: FontWeights[0],
|
||||
});
|
||||
|
||||
return (
|
||||
<Flex gap={"2"}>
|
||||
<Flex gap={"2"} direction={"column"} className="w-1/3">
|
||||
<BackgroundSelector
|
||||
background={background}
|
||||
setBackground={setBackground}
|
||||
/>
|
||||
<TextSetting text={text} setText={setText} />
|
||||
</Flex>
|
||||
|
||||
<Box className="w-2/3" >
|
||||
<PreviewToolbar background={background} text={text} />
|
||||
</Box>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
36
src/components/Footer.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { Box, Container, Flex, Link, Section } from "@radix-ui/themes";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export default function Footer() {
|
||||
const f = useTranslations("Footer");
|
||||
|
||||
return (
|
||||
<footer className="w-full border-t backdrop-blur-sm bg-background/95 ">
|
||||
<Flex justify={"between"} align={"center"} direction={"column"} gap={"2"} p="2">
|
||||
<Flex justify={"center"} gap={"4"}>
|
||||
<Link
|
||||
href="/black-screen"
|
||||
className="text-sm text-muted-foreground hover:text-primary"
|
||||
>
|
||||
Black Screen
|
||||
</Link>
|
||||
<Link
|
||||
href="/white-screen"
|
||||
className="text-sm text-muted-foreground hover:text-primary"
|
||||
>
|
||||
White Screen
|
||||
</Link>
|
||||
<Link
|
||||
href="/do-not-write-on-this-page"
|
||||
className="text-sm text-muted-foreground hover:text-primary"
|
||||
>
|
||||
Do Not Write On This Page
|
||||
</Link>
|
||||
</Flex>
|
||||
<Box className="text-sm text-muted-foreground">
|
||||
{f("copyright", { year: new Date().getFullYear() })}
|
||||
</Box>
|
||||
</Flex>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
42
src/components/Header.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { useTranslations } from "next-intl";
|
||||
import LanguageSwitcher from "./LanguageSwitcher";
|
||||
import { ModeToggle } from "./ModeToggle";
|
||||
import { Box, Flex, Link, Strong, Text } from "@radix-ui/themes";
|
||||
|
||||
export default function Header() {
|
||||
const t = useTranslations("Index");
|
||||
return (
|
||||
<header className="w-full py-2">
|
||||
<Flex justify="center" gap="9" align="center">
|
||||
<Box >
|
||||
<a href="/" >
|
||||
<Text size="6" color="iris"><Strong>{t("appName")}</Strong></Text>
|
||||
</a>
|
||||
</Box >
|
||||
|
||||
<Flex gap={"4"} justify={"between"} align={"center"}>
|
||||
<Link
|
||||
href="/black-screen"
|
||||
>
|
||||
Black Screen
|
||||
</Link>
|
||||
<Link
|
||||
href="/white-screen"
|
||||
>
|
||||
White Screen
|
||||
</Link>
|
||||
<Link
|
||||
href="/do-not-write-on-this-page"
|
||||
>
|
||||
Do Not Write On This Page
|
||||
</Link>
|
||||
</Flex>
|
||||
|
||||
<Flex align="center" gap="4">
|
||||
<LanguageSwitcher />
|
||||
<ModeToggle />
|
||||
</Flex>
|
||||
</Flex>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
75
src/components/LanguageSwitcher.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { usePathname, useRouter } from "@/i18n/navigation";
|
||||
import { Locales } from "@/i18n/config";
|
||||
import { Button, DropdownMenu } from "@radix-ui/themes";
|
||||
|
||||
const LanguageSwitcher = () => {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const [currentLanguage, setCurrentLanguage] = useState("en");
|
||||
|
||||
useEffect(() => {
|
||||
const savedLanguage =
|
||||
document.cookie
|
||||
.split("; ")
|
||||
.find((row) => row.startsWith("NEXT_LOCALE="))
|
||||
?.split("=")[1] || "en";
|
||||
setCurrentLanguage(savedLanguage);
|
||||
|
||||
const urlLanguage = pathname.split("/")[1];
|
||||
if (Locales.includes(urlLanguage)) {
|
||||
setCurrentLanguage(urlLanguage);
|
||||
}
|
||||
}, [pathname]);
|
||||
|
||||
const changeLanguage = (newLanguage: string) => {
|
||||
setCurrentLanguage(newLanguage);
|
||||
document.cookie = `NEXT_LOCALE=${newLanguage}; path=/;`;
|
||||
|
||||
const segments = pathname.split("/");
|
||||
if (Locales.includes(segments[1])) {
|
||||
segments[1] = newLanguage;
|
||||
} else {
|
||||
segments.splice(1, 0, newLanguage);
|
||||
}
|
||||
|
||||
router.push(segments.join("/"));
|
||||
router.refresh();
|
||||
};
|
||||
|
||||
const languageLabels = {
|
||||
en: "English",
|
||||
zh: "中文",
|
||||
// ar: "العربية",
|
||||
// es: "Español",
|
||||
// jp: "日本語",
|
||||
};
|
||||
|
||||
const labels = [];
|
||||
for (let l in languageLabels) {
|
||||
const key = l as keyof typeof languageLabels;
|
||||
labels.push(
|
||||
<DropdownMenu.Item key={l} onClick={() => changeLanguage(l)}>
|
||||
{languageLabels[key]}
|
||||
</DropdownMenu.Item>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu.Root dir={currentLanguage === "ar" ? "rtl" : "ltr"}>
|
||||
<DropdownMenu.Trigger >
|
||||
<Button variant="outline" size={"2"}>
|
||||
{languageLabels[currentLanguage as keyof typeof languageLabels]}
|
||||
</Button>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content align="end">
|
||||
{labels}
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
);
|
||||
};
|
||||
|
||||
export default LanguageSwitcher;
|
||||
25
src/components/ModeToggle.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { Moon, Sun } from "lucide-react";
|
||||
import { useTheme } from "next-themes";
|
||||
import { Button } from "@radix-ui/themes";
|
||||
|
||||
export function ModeToggle() {
|
||||
const { setTheme } = useTheme();
|
||||
//get the current theme
|
||||
const { theme: themes } = useTheme();
|
||||
|
||||
//change theme from current to opposite
|
||||
const toggleTheme = () => {
|
||||
setTheme(themes === "light" ? "dark" : "light");
|
||||
};
|
||||
|
||||
return (
|
||||
<Button variant="ghost" size="1" onClick={() => toggleTheme()}>
|
||||
<Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
|
||||
<Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
|
||||
<span className="sr-only">Toggle theme</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
16
src/components/OmmitRlt.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import React from "react";
|
||||
|
||||
interface OmitRTLProps {
|
||||
children: React.ReactNode;
|
||||
omitRTL?: boolean;
|
||||
}
|
||||
|
||||
const OmitRTL: React.FC<OmitRTLProps> = ({ children, omitRTL = true }) => {
|
||||
const dir = omitRTL ? "ltr" : "inherit";
|
||||
|
||||
return (
|
||||
<div style={{ direction: dir, unicodeBidi: "isolate" }}>{children}</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default OmitRTL;
|
||||
124
src/components/common/BackgroundSelector.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
import { Box, Text, Flex, Heading, Section, Radio } from "@radix-ui/themes";
|
||||
import { useTranslations } from "next-intl";
|
||||
export type BackgroundType = "color" | "image";
|
||||
export interface BackgroundProp {
|
||||
type: BackgroundType;
|
||||
color: string;
|
||||
image: string | null;
|
||||
}
|
||||
export default function BackgroundSelector({
|
||||
background,
|
||||
setBackground,
|
||||
}: {
|
||||
background: BackgroundProp;
|
||||
setBackground: (bg: BackgroundProp) => void;
|
||||
}) {
|
||||
const t = useTranslations("BackgoundSetting");
|
||||
|
||||
const handleColorChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setBackground({
|
||||
type: "color",
|
||||
color: e.target.value,
|
||||
image: background.image,
|
||||
});
|
||||
};
|
||||
|
||||
const handleImageUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
const result = event.target?.result as string;
|
||||
setBackground({
|
||||
type: "image",
|
||||
image: result,
|
||||
color: background.color,
|
||||
});
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box className="space-y-4 p-4 border rounded-lg min-w-64">
|
||||
<Heading size={"3"} className="font-medium text-lg">{t("title")}</Heading>
|
||||
<Flex gap={"2"} p="2">
|
||||
<Flex gap={"1"} align={"center"}>
|
||||
<Radio name="background-type" value="1" checked={background.type === "color"} onChange={() =>
|
||||
setBackground({
|
||||
type: "color",
|
||||
color: background.color,
|
||||
image: background.image,
|
||||
})
|
||||
} />
|
||||
<Text size="2">{t("colorOption")}</Text>
|
||||
</Flex>
|
||||
|
||||
<Flex gap={"1"} align={"center"}>
|
||||
<Radio name="background-type" value="1" checked={background.type === "image"} onChange={() =>
|
||||
setBackground({
|
||||
type: "image",
|
||||
color: background.color,
|
||||
image: background.image,
|
||||
})
|
||||
} />
|
||||
<Text size="2">{t("imageOption")}</Text>
|
||||
</Flex>
|
||||
</Flex>
|
||||
|
||||
<Box className="w-full">
|
||||
{background.type === "color" && (
|
||||
<Text as="label" size="2">
|
||||
<input
|
||||
type="color"
|
||||
id="color-picker"
|
||||
value={background.color}
|
||||
onChange={handleColorChange}
|
||||
className="w-full h-10 rounded-md cursor-pointer"
|
||||
/>
|
||||
{t("selectColor")}
|
||||
</Text>
|
||||
|
||||
// <div className="flex flex-col gap-2">
|
||||
// <label
|
||||
// htmlFor="color-picker"
|
||||
// className="text-sm text-muted-foreground"
|
||||
// >
|
||||
// {t("selectColor")}
|
||||
// </label>
|
||||
// <input
|
||||
// type="color"
|
||||
// id="color-picker"
|
||||
// value={background.color}
|
||||
// onChange={handleColorChange}
|
||||
// className="w-full h-10 rounded-md cursor-pointer"
|
||||
// />
|
||||
// </div>
|
||||
)}
|
||||
|
||||
{background.type === "image" && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<label
|
||||
htmlFor="file-upload"
|
||||
className="text-sm text-muted-foreground"
|
||||
>
|
||||
{t("uploadImage")}
|
||||
</label>
|
||||
<input
|
||||
type="file"
|
||||
id="file-upload"
|
||||
accept="image/*"
|
||||
onChange={handleImageUpload}
|
||||
className="block w-full text-sm text-muted-foreground
|
||||
file:mr-4 file:py-2 file:px-4
|
||||
file:rounded-md file:border-0
|
||||
file:text-sm file:font-semibold
|
||||
file:bg-primary file:text-primary-foreground
|
||||
hover:file:bg-primary/90"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
175
src/components/common/PreviewToolbar.tsx
Normal file
@@ -0,0 +1,175 @@
|
||||
"use client";
|
||||
import { useState, useRef, useEffect, } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Eye, Download } from "lucide-react";
|
||||
import { BackgroundProp } from "./BackgroundSelector";
|
||||
import { TextProp } from "./TextSetting";
|
||||
import { Box, Flex } from "@radix-ui/themes";
|
||||
import { init as threeInit, updateBackground, updateTextProps } from "./ThreeTools";
|
||||
|
||||
const Sizes = [
|
||||
"1920x1080",
|
||||
"1024x768",
|
||||
"800x600",
|
||||
]
|
||||
|
||||
function gcd(a: number, b: number): number {
|
||||
return b === 0 ? a : gcd(b, a % b);
|
||||
}
|
||||
|
||||
const AspectRatio = Sizes.map(o => {
|
||||
const [w, h] = o.split("x").map(Number);
|
||||
const a = gcd(w, h);
|
||||
return `${w / a}/${h / a}`;
|
||||
})
|
||||
export default function PreviewToolbar({
|
||||
background,
|
||||
text,
|
||||
}: {
|
||||
background: BackgroundProp;
|
||||
text: TextProp;
|
||||
}) {
|
||||
const t = useTranslations("PreviewBar");
|
||||
const [aspectRadio, setAspectRadio] = useState<number>(0);
|
||||
const container = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
const updateSize = () => {
|
||||
const box = container.current!;
|
||||
const split = Sizes[aspectRadio].split("x").map(Number);
|
||||
box.width = split[0];
|
||||
box.height = split[1];
|
||||
}
|
||||
useEffect(() => {
|
||||
function init() {
|
||||
if (!container.current) {
|
||||
setTimeout(init, 100);
|
||||
} else {
|
||||
updateSize();
|
||||
const box = container.current;
|
||||
threeInit(box);
|
||||
console.log("three init");
|
||||
}
|
||||
}
|
||||
|
||||
init();
|
||||
|
||||
}, []);
|
||||
|
||||
useEffect(updateSize, [aspectRadio]);
|
||||
|
||||
useEffect(() => {
|
||||
updateBackground(background);
|
||||
console.log("background init");
|
||||
}, [background]);
|
||||
|
||||
useEffect(() => {
|
||||
updateTextProps(text);
|
||||
console.log("text init");
|
||||
}, [text]);
|
||||
|
||||
const handleDownload = () => {
|
||||
|
||||
if (!container.current) return;
|
||||
const canvas = container.current;
|
||||
|
||||
// 创建下载链接
|
||||
// const link = document.createElement("a");
|
||||
// link.download = `background-${downloadSize.width}x${downloadSize.height}.png`;
|
||||
// link.href = canvas.toDataURL("image/png");
|
||||
// link.click();
|
||||
|
||||
};
|
||||
|
||||
|
||||
const handleFullScreen = () => {
|
||||
if (!container.current) return;
|
||||
|
||||
const canvas = container.current;
|
||||
const width = window.screen.width;
|
||||
const height = window.screen.height;
|
||||
canvas.style.width = "100vw";
|
||||
canvas.style.height = "100vh";
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
|
||||
if (canvas.requestFullscreen) {
|
||||
canvas.requestFullscreen();
|
||||
} else if ((canvas as any).webkitRequestFullscreen) {
|
||||
(canvas as any).webkitRequestFullscreen();
|
||||
} else if ((canvas as any).msRequestFullscreen) {
|
||||
(canvas as any).msRequestFullscreen();
|
||||
}
|
||||
|
||||
// 退出全屏时隐藏canvas
|
||||
const onFullscreenChange = () => {
|
||||
if (!document.fullscreenElement) {
|
||||
|
||||
canvas.style.removeProperty("width");
|
||||
canvas.style.removeProperty("height");
|
||||
updateSize();
|
||||
document.removeEventListener("fullscreenchange", onFullscreenChange);
|
||||
}
|
||||
};
|
||||
document.addEventListener("fullscreenchange", onFullscreenChange);
|
||||
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "F11") {
|
||||
e.preventDefault();
|
||||
handleFullScreen();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [handleFullScreen]);
|
||||
|
||||
return (
|
||||
<Flex direction={"column"} justify={"center"} align={"center"} p="2" className="rounded-lg border w-full" gap={"2"}>
|
||||
<canvas ref={container} className="w-full border border-gray-300" style={{
|
||||
aspectRatio: AspectRatio[aspectRadio],
|
||||
// backgroundColor: background.type === "color" ? background.color : "none",
|
||||
backgroundImage: (background.type === "image" && background.image) ? `url(${background.image})` : "none",
|
||||
backgroundRepeat: "no-repeat",
|
||||
backgroundSize: "contain",
|
||||
backgroundPosition: "center",
|
||||
}} />
|
||||
|
||||
<Flex gap={"9"} className="justify-around">
|
||||
<button
|
||||
onClick={() => handleFullScreen()}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-primary text-primary-foreground rounded-md hover:bg-primary/90 transition-colors shadow hover:shadow-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
<Eye className="w-4 h-4" />
|
||||
{t("previewFullscreen")} (F11)
|
||||
</button>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-sm font-medium text-muted-foreground">
|
||||
{t("downloadSize")}
|
||||
</label>
|
||||
<select
|
||||
value={`${aspectRadio}`}
|
||||
onChange={(e) => setAspectRadio(parseInt(e.target.value))}
|
||||
className="h-9 rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
>
|
||||
{AspectRatio.map((_, i) => <option key={i} value={i}>{Sizes[i]}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 transition-colors shadow hover:shadow-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
{t("downloadBackground")}
|
||||
</button>
|
||||
</div>
|
||||
</Flex>
|
||||
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
88
src/components/common/TextSetting.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import { Flex, Heading } from "@radix-ui/themes";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useEffect } from "react";
|
||||
|
||||
export interface TextProp {
|
||||
text: string;
|
||||
color: string;
|
||||
font: string;
|
||||
weight: string;
|
||||
}
|
||||
|
||||
export function getFontPath(fontName: string, fontWeight: String) {
|
||||
return `/fonts/${fontName}_${fontWeight}.typeface.json`;
|
||||
}
|
||||
|
||||
export const FontWeights = ["regular", "bold"];
|
||||
export const FontNames = ["gentilis", "helvetiker", "optimer"];
|
||||
|
||||
export default function TextEditor({
|
||||
text,
|
||||
setText,
|
||||
}: {
|
||||
text: TextProp;
|
||||
setText: (text: TextProp) => void;
|
||||
}) {
|
||||
const t = useTranslations("TextEditor");
|
||||
|
||||
useEffect(() => {
|
||||
// 初始化默认文本
|
||||
let textStr = text.text == "default" ? t("defaultText") : text.text;
|
||||
setText({ ...text, text: textStr });
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Flex className="p-4 border rounded-lg " gap={"3"} direction={"column"}>
|
||||
<Heading size={"3"} className="font-medium text-lg" >{t("title")}</Heading>
|
||||
<textarea
|
||||
value={text.text}
|
||||
onChange={e => setText({ ...text, text: e.target.value })}
|
||||
className="w-full p-3 border rounded-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
rows={4}
|
||||
/>
|
||||
<div className="space-y-1">
|
||||
<label className="block text-sm text-muted-foreground">
|
||||
{t("textColor")}
|
||||
</label>
|
||||
<input
|
||||
type="color"
|
||||
value={text.color}
|
||||
onChange={e => setText({ ...text, text: e.target.value })}
|
||||
className="w-full h-10 rounded-md cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="block text-sm text-muted-foreground">
|
||||
{t("fontFamily")}
|
||||
</label>
|
||||
<select
|
||||
value={text.font}
|
||||
onChange={(e) => setText({ ...text, font: e.target.value })}
|
||||
className="w-full p-2 border rounded-md"
|
||||
>
|
||||
{FontNames.map((name) => (
|
||||
<option key={name} value={name}>
|
||||
{name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm text-muted-foreground">
|
||||
{t("fontWeight")}
|
||||
</label>
|
||||
<select
|
||||
value={text.weight}
|
||||
onChange={(e) => setText({ ...text, weight: e.target.value })}
|
||||
className="w-full p-2 border rounded-md"
|
||||
>
|
||||
{FontWeights.map((weight) => (
|
||||
<option key={weight} value={weight}>
|
||||
{weight}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
201
src/components/common/ThreeTools.ts
Normal file
@@ -0,0 +1,201 @@
|
||||
import * as THREE from "three";
|
||||
import { getFontPath, TextProp } from "./TextSetting";
|
||||
import { BackgroundProp } from "./BackgroundSelector";
|
||||
import { Font, FontLoader } from "three/addons/loaders/FontLoader.js";
|
||||
import { TextGeometry } from "three/addons/geometries/TextGeometry.js";
|
||||
|
||||
THREE.Cache.enabled = true;
|
||||
// import { OrbitControls } from "three/addons/controls/OrbitControls.js";
|
||||
|
||||
let camera: THREE.PerspectiveCamera,
|
||||
scene: THREE.Scene,
|
||||
renderer: THREE.WebGLRenderer,
|
||||
container: HTMLElement;
|
||||
|
||||
export function init(_container: HTMLCanvasElement) {
|
||||
container = _container;
|
||||
scene = new THREE.Scene();
|
||||
// scene.background = new THREE.Color(0xcccccc);
|
||||
// scene.fog = new THREE.FogExp2(0xcccccc, 0.002);
|
||||
|
||||
const width = container.clientWidth;
|
||||
const height = container.clientHeight;
|
||||
|
||||
renderer = new THREE.WebGLRenderer({
|
||||
antialias: true,
|
||||
alpha: true,
|
||||
canvas: container,
|
||||
});
|
||||
renderer.setPixelRatio(window.devicePixelRatio);
|
||||
// renderer.setSize(width, height);
|
||||
renderer.setAnimationLoop(animate);
|
||||
|
||||
camera = new THREE.PerspectiveCamera(60, width / height, 1, 1000);
|
||||
camera.position.set(400, 200, 0);
|
||||
|
||||
// controls
|
||||
|
||||
// controls = new OrbitControls(camera, renderer.domElement);
|
||||
|
||||
// controls.enable = false;
|
||||
// controls.listenToKeyEvents(window); // optional
|
||||
|
||||
// //controls.addEventListener( 'change', render ); // call this only in static scenes (i.e., if there is no animation loop)
|
||||
|
||||
// controls.enableDamping = true; // an animation loop is required when either damping or auto-rotation are enabled
|
||||
// controls.dampingFactor = 0.05;
|
||||
|
||||
// controls.screenSpacePanning = false;
|
||||
|
||||
// controls.minDistance = 100;
|
||||
// controls.maxDistance = 500;
|
||||
|
||||
// controls.maxPolarAngle = Math.PI / 2;
|
||||
|
||||
// lights
|
||||
|
||||
const dirLight1 = new THREE.DirectionalLight(0xffffff, 3);
|
||||
dirLight1.position.set(1, 1, 1);
|
||||
scene.add(dirLight1);
|
||||
|
||||
const dirLight2 = new THREE.DirectionalLight(0x002288, 3);
|
||||
dirLight2.position.set(-1, -1, -1);
|
||||
scene.add(dirLight2);
|
||||
|
||||
const ambientLight = new THREE.AmbientLight(0x555555);
|
||||
scene.add(ambientLight);
|
||||
|
||||
const observer = new ResizeObserver((entries: ResizeObserverEntry[]) => {
|
||||
console.log("resize");
|
||||
|
||||
const width = container.clientWidth;
|
||||
const height = container.clientHeight;
|
||||
|
||||
camera.aspect = width / height;
|
||||
camera.updateProjectionMatrix();
|
||||
});
|
||||
|
||||
observer.observe(container);
|
||||
}
|
||||
|
||||
function animate() {
|
||||
// controls.update(); // only required if controls.enableDamping = true, or if controls.autoRotate = true
|
||||
render();
|
||||
}
|
||||
|
||||
function render() {
|
||||
renderer.render(scene, camera);
|
||||
}
|
||||
|
||||
let textMesh: THREE.Mesh;
|
||||
let lastTextProps: TextProp | null = null;
|
||||
export async function updateTextProps(textProps: TextProp) {
|
||||
// const mirror = true;
|
||||
// const plane = new THREE.Mesh(
|
||||
// new THREE.PlaneGeometry(10000, 10000),
|
||||
// new THREE.MeshBasicMaterial({
|
||||
// color: 0xffffff,
|
||||
// opacity: 0.5,
|
||||
// transparent: true,
|
||||
// })
|
||||
// );
|
||||
// plane.position.y = 100;
|
||||
// plane.rotation.x = -Math.PI / 2;
|
||||
// scene.add(plane);
|
||||
|
||||
if (lastTextProps == null) {
|
||||
let mat = new THREE.MeshLambertMaterial({
|
||||
color: textProps.color,
|
||||
side: THREE.DoubleSide,
|
||||
});
|
||||
let geo = await getTextGeometry(textProps);
|
||||
|
||||
let textMesh1 = new THREE.Mesh(geo, mat);
|
||||
textMesh1.rotation.x = 0;
|
||||
textMesh1.rotation.y = Math.PI * 2;
|
||||
|
||||
scene.add(textMesh1);
|
||||
|
||||
textMesh = textMesh1;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const colorSame = textProps.color == lastTextProps.color;
|
||||
|
||||
if (colorSame) {
|
||||
let geo = await getTextGeometry(textProps);
|
||||
textMesh.geometry.dispose();
|
||||
textMesh.geometry = geo;
|
||||
} else {
|
||||
(textMesh.material as THREE.MeshLambertMaterial).color.set(textProps.color);
|
||||
}
|
||||
}
|
||||
|
||||
async function getTextGeometry(textProps: TextProp) {
|
||||
let text = textProps.text;
|
||||
let bevelEnabled = true;
|
||||
let font = await loadFont(textProps);
|
||||
const depth = 20,
|
||||
size = 70,
|
||||
// hover = 30,
|
||||
curveSegments = 4,
|
||||
bevelThickness = 2,
|
||||
bevelSize = 1.5;
|
||||
|
||||
let textGeo = new TextGeometry(text, {
|
||||
font,
|
||||
size: size,
|
||||
depth: depth,
|
||||
curveSegments: curveSegments,
|
||||
|
||||
bevelThickness: bevelThickness,
|
||||
bevelSize: bevelSize,
|
||||
bevelEnabled: bevelEnabled,
|
||||
});
|
||||
|
||||
textGeo.computeBoundingBox();
|
||||
textGeo.center();
|
||||
|
||||
return textGeo;
|
||||
|
||||
// if (mirror) {
|
||||
// textMesh2 = new THREE.Mesh(textGeo, materials);
|
||||
|
||||
// textMesh2.position.x = centerOffset;
|
||||
// textMesh2.position.y = -hover;
|
||||
// textMesh2.position.z = depth;
|
||||
|
||||
// textMesh2.rotation.x = Math.PI;
|
||||
// textMesh2.rotation.y = Math.PI * 2;
|
||||
|
||||
// group.add(textMesh2);
|
||||
// }
|
||||
}
|
||||
|
||||
async function loadFont(textProps: TextProp) {
|
||||
const loader = new FontLoader();
|
||||
let font = await loader.loadAsync(
|
||||
getFontPath(textProps.font, textProps.weight)
|
||||
);
|
||||
return font;
|
||||
}
|
||||
|
||||
function createText(textProps: TextProp, font: Font) {}
|
||||
|
||||
// function refreshText() {
|
||||
// group.remove(textMesh1);
|
||||
// if (mirror) group.remove(textMesh2);
|
||||
|
||||
// if (!text) return;
|
||||
|
||||
// createText();
|
||||
// }
|
||||
|
||||
export function updateBackground(bg: BackgroundProp) {
|
||||
if (bg.type === "color") {
|
||||
scene.background = new THREE.Color(bg.color);
|
||||
} else {
|
||||
scene.background = null;
|
||||
}
|
||||
}
|
||||
1
src/i18n/config.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const Locales = ["en", "zh"]; // "ar", "es", "jp"
|
||||
7
src/i18n/navigation.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { createNavigation } from "next-intl/navigation";
|
||||
import { routing } from "./routing";
|
||||
|
||||
// Lightweight wrappers around Next.js' navigation
|
||||
// APIs that consider the routing configuration
|
||||
export const { Link, redirect, usePathname, useRouter, getPathname } =
|
||||
createNavigation(routing);
|
||||
16
src/i18n/request.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { getRequestConfig } from "next-intl/server";
|
||||
import { hasLocale } from "next-intl";
|
||||
import { routing } from "./routing";
|
||||
|
||||
export default getRequestConfig(async ({ requestLocale }) => {
|
||||
// Typically corresponds to the `[locale]` segment
|
||||
const requested = await requestLocale;
|
||||
const locale = hasLocale(routing.locales, requested)
|
||||
? requested
|
||||
: routing.defaultLocale;
|
||||
|
||||
return {
|
||||
locale,
|
||||
messages: (await import(`../../dictionary/${locale}.json`)).default,
|
||||
};
|
||||
});
|
||||
13
src/i18n/routing.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { defineRouting } from "next-intl/routing";
|
||||
import { Locales } from "./config";
|
||||
|
||||
export const routing = defineRouting({
|
||||
// A list of all locales that are supported
|
||||
locales: Locales,
|
||||
|
||||
// Used when no locale matches
|
||||
defaultLocale: "en",
|
||||
localeDetection: true,
|
||||
//to remove the locale prefix from the url
|
||||
localePrefix: "never",
|
||||
});
|
||||
6
src/lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
11
src/middleware.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import createMiddleware from "next-intl/middleware";
|
||||
import { routing } from "./i18n/routing";
|
||||
|
||||
export default createMiddleware(routing);
|
||||
|
||||
export const config = {
|
||||
// Match all pathnames except for
|
||||
// - … if they start with `/api`, `/trpc`, `/_next` or `/_vercel`
|
||||
// - … the ones containing a dot (e.g. `favicon.ico`)
|
||||
matcher: "/((?!api|trpc|_next|_vercel|.*\\..*).*)",
|
||||
};
|
||||
41
tsconfig.json
Normal file
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"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/components/common/three_init.js"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||