first commit

This commit is contained in:
ymk
2025-07-30 20:18:02 +08:00
commit 303339747f
56 changed files with 8157 additions and 0 deletions

View File

@@ -0,0 +1,5 @@
import { notFound } from "next/navigation";
export default function CatchAllPage() {
notFound();
}

View 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}`,
},
},
};
}

View 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>
);
}

View 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&apos;re sorry, but we couldn&apos;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
View 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
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

123
src/app/globals.css Normal file
View 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
View File

@@ -0,0 +1,4 @@
User-agent: *
Allow: /
Sitemap: https://screen.mysoul.fun/sitemap.xml

59
src/app/sitemap.ts Normal file
View 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
View 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
View 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
View 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>
);
}

View 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;

View 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>
);
}

View 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;

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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
View File

@@ -0,0 +1 @@
export const Locales = ["en", "zh"]; // "ar", "es", "jp"

7
src/i18n/navigation.ts Normal file
View 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
View 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
View 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
View 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
View 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|.*\\..*).*)",
};