first commit
This commit is contained in:
5
src/app/[locale]/[...rest]/page.tsx
Normal file
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
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
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
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
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
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
BIN
src/app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
123
src/app/globals.css
Normal file
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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|.*\\..*).*)",
|
||||
};
|
||||
Reference in New Issue
Block a user