refactor: migrate application from next.js app to tanstack

This commit is contained in:
JOYCEQL
2026-02-15 21:41:30 +08:00
parent 153cc01409
commit ba3012a0f8
69 changed files with 2800 additions and 6342 deletions
+4 -3
View File
@@ -1,9 +1,10 @@
name: Deploy to Cloudflare Worker
name: Deploy to Cloudflare
on:
push:
branches:
- main
workflow_dispatch:
jobs:
deploy:
@@ -24,9 +25,9 @@ jobs:
run: pnpm install --frozen-lockfile
- name: Build
run: pnpm pages:build
run: pnpm run build
- name: Deploy to Cloudflare Worker
- name: Deploy to Cloudflare
uses: cloudflare/wrangler-action@v3
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
+12 -4
View File
@@ -3,14 +3,14 @@
# ✨ Magic Resume ✨
[![License](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](https://opensource.org/licenses/Apache-2.0)
![Next.js](https://img.shields.io/badge/Next.js-16.0-black)
![TanStack Start](https://img.shields.io/badge/TanStack_Start-1.x-black)
![Framer Motion](https://img.shields.io/badge/Framer_Motion-10.0-purple)
[简体中文](./README.md) | English
</div>
Magic Resume is a modern online resume editor that makes creating professional resumes simple and enjoyable. Built with Next.js and Framer Motion, it supports real-time preview and custom themes.
Magic Resume is a modern online resume editor that makes creating professional resumes simple and enjoyable. Built with TanStack Start and Framer Motion, it supports real-time preview and custom themes.
## 📸 Screenshots
@@ -18,7 +18,7 @@ Magic Resume is a modern online resume editor that makes creating professional r
## ✨ Features
- 🚀 Built with Next.js 16+
- 🚀 Built with TanStack Start
- 💫 Smooth animations (Framer Motion)
- 🎨 Custom theme support
- 📱 Responsive design
@@ -30,7 +30,9 @@ Magic Resume is a modern online resume editor that makes creating professional r
## 🛠️ Tech Stack
- Next.js 16+
- TanStack Start
- TanStack Router
- Vite
- TypeScript
- Motion
- Tiptap
@@ -68,6 +70,12 @@ pnpm dev
pnpm build
```
## ☁️ Cloudflare Deploy
```bash
pnpm run cf:deploy
```
## 🐳 Docker Deployment
+12 -4
View File
@@ -3,7 +3,7 @@
# ✨ Magic Resume ✨
[![License](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](https://opensource.org/licenses/Apache-2.0)
![Next.js](https://img.shields.io/badge/Next.js-16.0-black)
![TanStack Start](https://img.shields.io/badge/TanStack_Start-1.x-black)
![Framer Motion](https://img.shields.io/badge/Framer_Motion-10.0-purple)
<a href="https://trendshift.io/repositories/13077" target="_blank"><img src="https://trendshift.io/api/badge/repositories/13077" alt="Magic Resume | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
@@ -13,7 +13,7 @@
</div>
Magic Resume 是一个现代化的在线简历编辑器,让创建专业简历变得简单有趣。基于 Next.js 和 Motion 构建,支持实时预览和自定义主题。
Magic Resume 是一个现代化的在线简历编辑器,让创建专业简历变得简单有趣。基于 TanStack Start 和 Motion 构建,支持实时预览和自定义主题。
## 📸 项目截图
@@ -21,7 +21,7 @@ Magic Resume 是一个现代化的在线简历编辑器,让创建专业简历
## ✨ 特性
- 🚀 基于 Next.js 16+ 构建
- 🚀 基于 TanStack Start 构建
- 💫 流畅的动画效果 (Motion)
- 🎨 自定义主题支持
- 🌙 深色模式
@@ -32,7 +32,9 @@ Magic Resume 是一个现代化的在线简历编辑器,让创建专业简历
## 🛠️ 技术栈
- Next.js 16+
- TanStack Start
- TanStack Router
- Vite
- TypeScript
- Motion
- Tiptap
@@ -70,6 +72,12 @@ pnpm dev
pnpm build
```
## ☁️ Cloudflare 部署
```bash
pnpm run cf:deploy
```
## 🐳 Docker 部署
### Docker Compose
+5 -7
View File
@@ -1,21 +1,19 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
export default defineConfig([
...nextVitals,
{
rules: {
"react-hooks/immutability": "off",
"react-hooks/preserve-manual-memoization": "off",
"react-hooks/purity": "off",
"react-hooks/set-state-in-effect": "off",
},
"react-hooks/set-state-in-effect": "off"
}
},
globalIgnores([
".next/**",
".open-next/**",
".wrangler/**",
"dist/**",
"node_modules/**",
"next-env.d.ts",
]),
"node_modules/**"
])
]);
-12
View File
@@ -1,12 +0,0 @@
import createNextIntlPlugin from "next-intl/plugin";
const withNextIntl = createNextIntlPlugin();
/** @type {import('next').NextConfig} */
const config = {
typescript: {
ignoreBuildErrors: true,
},
...(process.env.NEXT_STANDALONE === "1" && { output: "standalone" }),
};
export default withNextIntl(config);
-3
View File
@@ -1,3 +0,0 @@
import { defineCloudflareConfig } from "@opennextjs/cloudflare/config";
export default defineCloudflareConfig({});
+13 -10
View File
@@ -4,15 +4,16 @@
"private": true,
"packageManager": "pnpm@10.3.0",
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"dev": "vite dev --port 3000",
"build": "vite build",
"start": "vite preview",
"preview": "vite preview",
"lint": "eslint .",
"pages:build": "node scripts/opennext-build.mjs",
"preview": "pnpm pages:build && wrangler dev",
"deploy": "pnpm pages:build && opennextjs-cloudflare deploy"
"cf:deploy": "pnpm build && wrangler deploy",
"cf:dev": "wrangler dev"
},
"dependencies": {
"@cloudflare/vite-plugin": "^1.13.8",
"@heroui/checkbox": "^2.3.30",
"@heroui/date-input": "^2.3.30",
"@heroui/react": "^2.8.8",
@@ -35,6 +36,8 @@
"@radix-ui/react-tabs": "^1.0.4",
"@radix-ui/react-tooltip": "^1.1.3",
"@sparticuz/chromium": "^131.0.0",
"@tanstack/react-router": "^1.159.14",
"@tanstack/react-start": "^1.159.14",
"@tiptap/core": "^2.4.0",
"@tiptap/extension-bullet-list": "^2.10.2",
"@tiptap/extension-color": "^2.4.0",
@@ -59,8 +62,6 @@
"lodash": "^4.17.21",
"lucide-react": "^0.379.0",
"mark.js": "^8.11.1",
"next": "16.1.6",
"next-intl": "^4.8.2",
"next-themes": "^0.4.3",
"puppeteer": "^23.9.0",
"puppeteer-core": "^23.9.0",
@@ -78,18 +79,20 @@
"zustand": "^4.5.4"
},
"devDependencies": {
"@opennextjs/cloudflare": "1.16.3",
"@tanstack/router-plugin": "^1.159.14",
"@types/lodash": "^4.17.13",
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
"@vitejs/plugin-react": "^5.0.4",
"eslint": "^9.39.2",
"eslint-config-next": "16.1.6",
"postcss": "^8",
"postcss-normalize": "^13.0.1",
"sass": "^1.77.4",
"tailwindcss": "^3.4.1",
"typescript": "^5",
"vite": "^7.1.7",
"vite-tsconfig-paths": "^5.1.4",
"wrangler": "^4.65.0"
}
}
+1143 -5532
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,6 +1,6 @@
User-agent: *
Allow: /
Disallow: /app/workbench
Disallow: /app
Disallow: /api
# Sitemap
+13
View File
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://magicv.art/zh</loc>
<changefreq>daily</changefreq>
<priority>1.0</priority>
</url>
<url>
<loc>https://magicv.art/en</loc>
<changefreq>daily</changefreq>
<priority>1.0</priority>
</url>
</urlset>
-85
View File
@@ -1,85 +0,0 @@
import { spawn } from "node:child_process";
import { promises as fs } from "node:fs";
import path from "node:path";
const root = process.cwd();
const proxyPath = path.join(root, "src/proxy.ts");
const middlewarePath = path.join(root, "src/middleware.ts");
const backupPath = path.join(root, "src/proxy.ts.__opennext_backup__");
const middlewareContent = [
'import handler from "./intl-proxy";',
"",
"export default handler;",
// 'export const runtime = "experimental-edge";',
"export const config = {",
' matcher: ["/", "/(zh|en)/:path*"],',
"};",
"",
].join("\n");
async function fileExists(filePath) {
try {
await fs.access(filePath);
return true;
} catch {
return false;
}
}
function run(command, args) {
return new Promise((resolve, reject) => {
const child = spawn(command, args, { stdio: "inherit" });
child.on("exit", (code) => {
if (code === 0) {
resolve();
} else {
reject(new Error(`Command failed: ${command} ${args.join(" ")}`));
}
});
child.on("error", reject);
});
}
async function restoreProxy() {
if (await fileExists(middlewarePath)) {
await fs.unlink(middlewarePath);
}
if (await fileExists(backupPath)) {
await fs.rename(backupPath, proxyPath);
}
}
async function main() {
if (!(await fileExists(proxyPath))) {
throw new Error("Expected src/proxy.ts to exist before OpenNext build.");
}
if (await fileExists(middlewarePath)) {
throw new Error("Expected src/middleware.ts to not exist before OpenNext build.");
}
if (await fileExists(backupPath)) {
throw new Error("Found stale backup file: src/proxy.ts.__opennext_backup__");
}
await fs.rename(proxyPath, backupPath);
await fs.writeFile(middlewarePath, middlewareContent, "utf8");
try {
await run("pnpm", ["opennextjs-cloudflare", "build"]);
} finally {
await restoreProxy();
}
}
main().catch(async (error) => {
try {
await restoreProxy();
} catch (restoreError) {
console.error("Failed to restore proxy file after OpenNext build.", restoreError);
}
console.error(error);
process.exit(1);
});
-13
View File
@@ -1,13 +0,0 @@
"use server";
import { redirect } from "next/navigation";
export async function GoDashboardAction() {
redirect("/app/dashboard");
}
export async function GoResumesAction() {
redirect("/app/dashboard/resumes");
}
export async function GoTemplatesAction() {
redirect("/app/dashboard/templates");
}
-74
View File
@@ -1,74 +0,0 @@
import { ReactNode } from "react";
import { Metadata } from "next";
import { notFound } from "next/navigation";
import { NextIntlClientProvider } from "next-intl";
import {
getMessages,
getTranslations,
setRequestLocale
} from "next-intl/server";
import Document from "@/components/Document";
import { locales } from "@/i18n/config";
import { Providers } from "@/app/providers";
// export const runtime = "edge";
type Props = {
children: ReactNode;
params: Promise<{ locale: string }>;
};
export async function generateMetadata({
params
}: Props): Promise<Metadata> {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: "common" });
const baseUrl = "https://magicv.art";
return {
title: t("title") + " - " + t("subtitle"),
description: t("description"),
robots: {
index: true,
follow: true,
googleBot: {
index: true,
follow: true,
"max-video-preview": -1,
"max-image-preview": "large",
"max-snippet": -1
}
},
alternates: {
canonical: `${baseUrl}/${locale}`
},
openGraph: {
title: t("title"),
description: t("description"),
locale: locale,
alternateLocale: locale === "en" ? ["zh"] : ["en"]
}
};
}
export default async function LocaleLayout({
children,
params
}: Props) {
const { locale } = await params;
setRequestLocale(locale);
if (!locales.includes(locale as any)) {
notFound();
}
const messages = await getMessages();
return (
<Document locale={locale}>
<NextIntlClientProvider messages={messages}>
<Providers>{children}</Providers>
</NextIntlClientProvider>
</Document>
);
}
+1 -1
View File
@@ -8,7 +8,7 @@ import FAQSection from "@/components/home/FAQSection";
export default function LandingPage() {
return (
<div className="relative bg-gradient-to-b from-[#f8f9fb] to-white dark:from-gray-900 dark:to-gray-800">
<div className="relative bg-gradient-to-b from-[#f8f9fb] to-white dark:from-gray-900 ">
<LandingHeader />
<HeroSection />
<FeaturesSection />
-105
View File
@@ -1,105 +0,0 @@
import { NextResponse } from "next/server";
import { AIModelType } from "@/config/ai";
import { AI_MODEL_CONFIGS } from "@/config/ai";
export async function POST(req: Request) {
try {
const body = await req.json();
const { apiKey, model, content, modelType, apiEndpoint } = body;
const modelConfig = AI_MODEL_CONFIGS[modelType as AIModelType];
if (!modelConfig) {
throw new Error("Invalid model type");
}
const response = await fetch(modelConfig.url(apiEndpoint), {
method: "POST",
headers: modelConfig.headers(apiKey),
body: JSON.stringify({
model: modelConfig.requiresModelId ? model : modelConfig.defaultModel,
messages: [
{
role: "system",
content: `你是一个专业的简历优化助手。请帮助优化以下文本,使其更加专业和有吸引力。
优化原则:
1. 使用更专业的词汇和表达方式
2. 突出关键成就和技能
3. 保持简洁清晰
4. 使用主动语气
5. 保持原有信息的完整性
6. 保留我输入的格式
请直接返回优化后的文本,不要包含任何解释或其他内容。`,
},
{
role: "user",
content,
},
],
stream: true,
}),
});
const encoder = new TextEncoder();
const stream = new ReadableStream({
async start(controller) {
if (!response.body) {
controller.close();
return;
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
try {
while (true) {
const { done, value } = await reader.read();
if (done) {
controller.close();
break;
}
const chunk = decoder.decode(value);
const lines = chunk
.split("\n")
.filter((line) => line.trim() !== "");
for (const line of lines) {
if (line.includes("[DONE]")) continue;
if (!line.startsWith("data:")) continue;
try {
const data = JSON.parse(line.slice(5));
const content = data.choices[0]?.delta?.content;
if (content) {
controller.enqueue(encoder.encode(content));
}
} catch (e) {
console.error("Error parsing JSON:", e);
}
}
}
} catch (error) {
console.error("Stream reading error:", error);
controller.error(error);
}
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
},
});
} catch (error) {
console.error("Polish error:", error);
return NextResponse.json(
{ error: "Failed to polish content" },
{ status: 500 }
);
}
}
-101
View File
@@ -1,101 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
// resolve image proxy
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const imageUrl = searchParams.get("url");
if (!imageUrl) {
console.error("缺少图片URL参数");
return NextResponse.json({ error: "缺少图片URL参数" }, { status: 400 });
}
let parsedUrl;
try {
parsedUrl = new URL(imageUrl);
} catch (e) {
console.error(`图片URL格式不正确: ${imageUrl}`);
return NextResponse.json({ error: "图片URL格式不正确" }, { status: 400 });
}
if (parsedUrl.protocol !== "http:" && parsedUrl.protocol !== "https:") {
console.error(`不支持的URL协议: ${parsedUrl.protocol}`);
return NextResponse.json(
{ error: "只支持HTTP和HTTPS协议" },
{ status: 400 }
);
}
let response;
try {
response = await fetch(imageUrl, {
headers: {
// 模拟浏览器请求
"User-Agent":
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36",
Accept:
"image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8",
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
Referer: parsedUrl.origin,
},
});
} catch (error: any) {
console.error(`获取图片失败: ${error.message || "未知错误"}`);
return NextResponse.json(
{ error: `获取图片失败: ${error.message || "未知错误"}` },
{ status: 500 }
);
}
// 检查响应状态
if (!response.ok) {
console.error(
`图片服务器返回错误: ${response.status} ${response.statusText}`
);
return NextResponse.json(
{ error: `获取图片失败: ${response.status} ${response.statusText}` },
{ status: response.status }
);
}
let imageBuffer;
try {
imageBuffer = await response.arrayBuffer();
console.log(`成功获取图片,大小: ${imageBuffer.byteLength} 字节`);
} catch (error: any) {
console.error(`读取图片内容失败: ${error.message || "未知错误"}`);
return NextResponse.json(
{ error: `读取图片内容失败: ${error.message || "未知错误"}` },
{ status: 500 }
);
}
if (imageBuffer.byteLength === 0) {
console.error("图片内容为空");
return NextResponse.json({ error: "图片内容为空" }, { status: 400 });
}
const contentType = response.headers.get("content-type") || "image/jpeg";
return new NextResponse(imageBuffer, {
headers: {
"Content-Type": contentType,
"Cache-Control":
"no-store, no-cache, must-revalidate, proxy-revalidate",
Pragma: "no-cache",
Expires: "0",
"Surrogate-Control": "no-store",
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type",
},
});
} catch (error: any) {
console.error("图片代理未处理的错误:", error);
return NextResponse.json(
{ error: `处理图片请求时出错: ${error.message || "未知错误"}` },
{ status: 500 }
);
}
}
+3 -2
View File
@@ -22,7 +22,7 @@ import {
TooltipTrigger
} from "@/components/ui/tooltip";
import Logo from "@/components/shared/Logo";
import { useTranslations } from "next-intl";
import { useLocale, useTranslations } from "next-intl";
interface MenuItem {
title: string;
@@ -34,6 +34,7 @@ interface MenuItem {
const DashboardLayout = ({ children }: { children: React.ReactNode }) => {
const t = useTranslations("dashboard");
const locale = useLocale();
const sidebarItems: MenuItem[] = [
{
title: t("sidebar.resumes"),
@@ -98,7 +99,7 @@ const DashboardLayout = ({ children }: { children: React.ReactNode }) => {
<Logo
className="cursor-pointer hover:opacity-80 transition-opacity"
size={48}
onClick={() => router.push("/")}
onClick={() => router.push(`/${locale}`)}
/>
{open && (
<span className="font-bold text-lg tracking-tight">
-31
View File
@@ -1,31 +0,0 @@
import { ReactNode } from "react";
import { Metadata } from "next";
import { NextIntlClientProvider } from "next-intl";
import { getLocale, getMessages, getTranslations } from "next-intl/server";
import Document from "@/components/Document";
import { Providers } from "@/app/providers";
import Client from "./client";
type Props = {
children: ReactNode;
};
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations({ namespace: "common" });
return {
title: t("title") + " - " + t("dashboard"),
};
}
export default async function LocaleLayout({ children }: Props) {
const locale = await getLocale();
const messages = await getMessages();
return (
<Document locale={locale}>
<NextIntlClientProvider messages={messages}>
<Providers>
<Client>{children}</Client>
</Providers>
</NextIntlClientProvider>
</Document>
);
}
-6
View File
@@ -1,6 +0,0 @@
import { redirect } from "next/navigation";
export default function Dashboard() {
redirect("/app/dashboard/resumes");
}
-30
View File
@@ -1,30 +0,0 @@
import { Metadata } from "next";
import { NextIntlClientProvider } from "next-intl";
import { ReactNode } from "react";
import { getLocale, getMessages, getTranslations } from "next-intl/server";
import Document from "@/components/Document";
type Props = {
children: ReactNode;
};
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations({ namespace: "common" });
return {
title: t("title"),
};
}
export default async function LocaleLayout({ children }: Props) {
const locale = await getLocale();
const messages = await getMessages();
return (
<Document locale={locale}>
<NextIntlClientProvider messages={messages}>
{children}
</NextIntlClientProvider>
</Document>
);
}
-47
View File
@@ -1,47 +0,0 @@
import { ReactNode } from "react";
import { Metadata } from "next";
import { NextIntlClientProvider } from "next-intl";
import { getLocale, getMessages, getTranslations } from "next-intl/server";
import Document from "@/components/Document";
import { Providers } from "@/app/providers";
import { Toaster } from "@/components/ui/sonner";
type Props = {
children: ReactNode;
};
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations({ namespace: "common" });
return {
title: t("title") + " - " + t("dashboard"),
robots: {
index: false,
follow: false,
googleBot: {
index: false,
follow: false,
"max-video-preview": -1,
"max-image-preview": "large",
"max-snippet": -1
}
}
};
}
export default async function LocaleLayout({ children }: Props) {
const locale = await getLocale();
const messages = await getMessages();
return (
<Document
locale={locale}
bodyClassName="overflow-y-hidden w-full"
>
<NextIntlClientProvider messages={messages}>
<Providers>{children}</Providers>
<Toaster position="top-center" richColors />
</NextIntlClientProvider>
</Document>
);
}
-32
View File
@@ -1,32 +0,0 @@
import { ReactNode } from "react";
import { Metadata } from "next";
import "./globals.css";
import "./font.css";
type Props = {
children: ReactNode;
};
export const metadata: Metadata = {
metadataBase: new URL("https://magicv.art"),
icons: {
icon: "/icon.png",
shortcut: "/icon.png",
apple: "/icon.png"
},
robots: {
index: true,
follow: true,
googleBot: {
index: true,
follow: true,
"max-video-preview": -1,
"max-image-preview": "large",
"max-snippet": -1
}
}
};
export default function RootLayout({ children }: Props) {
return children;
}
-21
View File
@@ -1,21 +0,0 @@
import type { MetadataRoute } from "next";
export default function manifest(): MetadataRoute.Manifest {
return {
name: "Magic Resume",
short_name: "Magic Resume",
description: "A Progressive Web App built with Next.js",
start_url: "/",
display: "standalone",
background_color: "#ffffff",
theme_color: "#000000",
icons: [
{
src: "/icon.png",
sizes: "512x512",
type: "image/png"
}
]
};
}
-17
View File
@@ -1,17 +0,0 @@
import { MetadataRoute } from "next";
export default function sitemap(): MetadataRoute.Sitemap {
const baseUrl = "https://magicv.art/";
const routes = ["zh", "en"];
const sitemap: MetadataRoute.Sitemap = routes.map((route) => ({
url: `${baseUrl}${route}`,
lastModified: new Date(),
changeFrequency: "daily",
priority: 1.0
}));
return sitemap;
}
+3
View File
@@ -0,0 +1,3 @@
export async function revalidatePath() {
return;
}
+17
View File
@@ -0,0 +1,17 @@
type FontResult = {
className: string;
style: { fontFamily: string };
};
function createFont(family: string): FontResult {
return {
className: "",
style: {
fontFamily: family
}
};
}
export function Inter() {
return createFont("Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif");
}
+33
View File
@@ -0,0 +1,33 @@
type CookieValue = {
value: string;
};
export async function cookies() {
return {
get: (name: string): CookieValue | undefined => {
if (typeof document === "undefined") {
return undefined;
}
const cookieItem = document.cookie
.split(";")
.map((item) => item.trim())
.find((item) => item.startsWith(`${name}=`));
if (!cookieItem) {
return undefined;
}
return {
value: decodeURIComponent(cookieItem.slice(name.length + 1))
};
},
set: (name: string, value: string) => {
if (typeof document === "undefined") {
return;
}
document.cookie = `${name}=${encodeURIComponent(value)}; Path=/; Max-Age=31536000; SameSite=Lax`;
}
};
}
+76
View File
@@ -0,0 +1,76 @@
import type { CSSProperties, ImgHTMLAttributes } from "react";
export type StaticImageData = {
src: string;
width: number;
height: number;
blurDataURL?: string;
};
type ImageSource = string | StaticImageData;
type NextImageProps = Omit<ImgHTMLAttributes<HTMLImageElement>, "src"> & {
src: ImageSource;
alt: string;
width?: number;
height?: number;
fill?: boolean;
priority?: boolean;
sizes?: string;
quality?: number;
placeholder?: "empty" | "blur";
blurDataURL?: string;
unoptimized?: boolean;
loader?: (params: { src: string; width: number; quality?: number }) => string;
};
function resolveSrc(src: ImageSource): string {
return typeof src === "string" ? src : src.src;
}
export default function Image({
src,
alt,
width,
height,
fill,
priority,
quality,
placeholder,
blurDataURL,
unoptimized,
loader,
style,
...rest
}: NextImageProps) {
const resolvedSrc = resolveSrc(src);
const fillStyle: CSSProperties = fill
? {
position: "absolute",
inset: 0,
width: "100%",
height: "100%",
...style
}
: {
...style
};
const priorityAttributes = priority
? ({ fetchpriority: "high" } as Record<string, string>)
: {};
return (
<img
src={resolvedSrc}
alt={alt}
width={fill ? undefined : width}
height={fill ? undefined : height}
loading={priority ? "eager" : rest.loading}
style={fillStyle}
{...rest}
{...priorityAttributes}
/>
);
}
+27
View File
@@ -0,0 +1,27 @@
import type { AnchorHTMLAttributes, ReactNode } from "react";
import { Link as RouterLink } from "@tanstack/react-router";
type LinkProps = Omit<AnchorHTMLAttributes<HTMLAnchorElement>, "href"> & {
href: string;
children: ReactNode;
};
function isExternal(href: string): boolean {
return /^(https?:)?\/\//.test(href) || href.startsWith("mailto:") || href.startsWith("tel:");
}
export default function Link({ href, children, ...props }: LinkProps) {
if (isExternal(href)) {
return (
<a href={href} {...props}>
{children}
</a>
);
}
return (
<RouterLink to={href} {...props}>
{children}
</RouterLink>
);
}
+63
View File
@@ -0,0 +1,63 @@
import { useMemo } from "react";
import {
useNavigate,
useRouter as useTanStackRouter,
useRouterState
} from "@tanstack/react-router";
type UrlLike = string | URL;
function normalizeUrl(url: UrlLike): string {
if (typeof url === "string") {
return url;
}
return url.pathname + url.search + url.hash;
}
export function useRouter() {
const navigate = useNavigate();
const router = useTanStackRouter();
return useMemo(
() => ({
push: (href: UrlLike) => {
void navigate({ to: normalizeUrl(href) });
},
replace: (href: UrlLike) => {
void navigate({ to: normalizeUrl(href), replace: true });
},
back: () => {
if (typeof window !== "undefined") {
window.history.back();
}
},
forward: () => {
if (typeof window !== "undefined") {
window.history.forward();
}
},
refresh: () => {
void router.invalidate();
}
}),
[navigate, router]
);
}
export function usePathname() {
return useRouterState({ select: (state) => state.location.pathname });
}
export function redirect(href: UrlLike): never {
const target = normalizeUrl(href);
if (typeof window !== "undefined") {
window.location.assign(target);
}
throw new Error(`NEXT_REDIRECT:${target}`);
}
export function notFound(): never {
throw new Error("NEXT_NOT_FOUND");
}
-23
View File
@@ -1,23 +0,0 @@
import { Inter } from "next/font/google";
import { ReactNode } from "react";
const inter = Inter({
subsets: ["latin"],
});
type Props = {
children: ReactNode;
locale: string;
bodyClassName?: string;
};
export default function Document({ children, locale, bodyClassName }: Props) {
return (
<html className={inter.className} lang={locale} suppressHydrationWarning>
<head>
<link rel="icon" href="/favicon.ico?v=2" />
</head>
<body className={bodyClassName}>{children}</body>
</html>
);
}
+1 -1
View File
@@ -8,7 +8,7 @@ export default function CTASection() {
const t = useTranslations("home");
return (
<section className="py-24 md:py-44 bg-secondary/30 relative overflow-hidden">
<section className="py-24 md:py-44 relative overflow-hidden">
{/* Decorative background elements */}
<div className="absolute top-0 right-0 w-1/3 h-full bg-primary/5 blur-[120px] -z-10 translate-x-1/2" />
<div className="absolute bottom-0 left-0 w-1/3 h-full bg-primary/5 blur-[120px] -z-10 -translate-x-1/2" />
+18 -11
View File
@@ -1,23 +1,30 @@
"use client";
import {
GoDashboardAction,
GoTemplatesAction,
GoResumesAction,
} from "@/actions/navigation";
import { useRouter } from "next/navigation";
export default function GoDashboard({
children,
type = "dashboard",
type = "dashboard"
}: {
children: React.ReactNode;
type?: "dashboard" | "templates" | "resumes";
}) {
const actionMap = {
dashboard: GoDashboardAction,
resumes: GoResumesAction,
templates: GoTemplatesAction,
const router = useRouter();
const pathMap: Record<typeof type, string> = {
dashboard: "/app/dashboard",
resumes: "/app/dashboard/resumes",
templates: "/app/dashboard/templates"
};
return <form action={actionMap[type]}>{children}</form>;
return (
<div
className="contents cursor-pointer"
onClick={() => {
router.push(pathMap[type]);
}}
>
{children}
</div>
);
}
+1 -1
View File
@@ -8,7 +8,7 @@ const EditButton = (
RefAttributes<HTMLButtonElement>
) => {
return (
<Link href={"/dashboard"}>
<Link href={"/app/dashboard"}>
<Button {...props}>{props.children}</Button>
</Link>
);
+1 -1
View File
@@ -57,7 +57,7 @@ export function GitHubStars() {
/>
<motion.div
className="relative z-10"
className="relative z-10 flex"
animate={isHovered ? { rotate: 180 } : { rotate: 0 }}
transition={{ duration: 0.3 }}
>
+32 -10
View File
@@ -1,4 +1,5 @@
"use client";
import { useState } from "react";
import { useLocale } from "next-intl";
import { useRouter } from "next/navigation";
import { Languages } from "lucide-react";
@@ -10,14 +11,33 @@ import {
} from "@/components/ui/dropdown-menu";
import { Button } from "@/components/ui/button";
import { locales, localeNames } from "@/i18n/config";
import { Link, usePathname } from "@/i18n/routing.public";
import { usePathname } from "@/i18n/routing.public";
import { persistLocale, withLocale } from "@/i18n/runtime";
export default function LanguageSwitch() {
const locale = useLocale();
const pathname = usePathname();
const router = useRouter();
const [open, setOpen] = useState(false);
const handleSelectLocale = (nextLocale: string) => {
if (nextLocale === locale) {
setOpen(false);
return;
}
persistLocale(nextLocale);
setOpen(false);
const nextPath = withLocale(pathname, nextLocale);
window.setTimeout(() => {
router.push(nextPath);
}, 0);
};
return (
<DropdownMenu>
<DropdownMenu modal={false} open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
@@ -33,15 +53,17 @@ export default function LanguageSwitch() {
<DropdownMenuItem
key={loc}
className={locale === loc ? "bg-accent" : ""}
onSelect={(event) => {
event.preventDefault();
handleSelectLocale(loc);
}}
>
<Link className="w-full" href={pathname} locale={loc}>
<span className="flex items-center gap-2">
{localeNames[loc]}
{locale === loc && (
<span className="text-xs text-muted-foreground"></span>
)}
</span>
</Link>
<span className="flex w-full items-center gap-2">
{localeNames[loc]}
{locale === loc && (
<span className="text-xs text-muted-foreground"></span>
)}
</span>
</DropdownMenuItem>
);
})}
-7
View File
@@ -1,7 +0,0 @@
"use server";
import { setUserLocale } from "@/i18n/db";
import { revalidatePath } from "next/cache";
export default async function updateLocale(locale: string) {
setUserLocale(locale);
revalidatePath("/");
}
+114
View File
@@ -0,0 +1,114 @@
import {
createContext,
useContext,
useEffect,
useMemo,
type ReactNode
} from "react";
import { defaultLocale, type Locale } from "@/i18n/config";
import {
getMessagesByLocale,
normalizeLocale,
type LocaleMessages
} from "@/i18n/messages";
import { persistLocale } from "@/i18n/runtime";
type TranslationValues = Record<string, string | number | boolean | null | undefined>;
type TranslateFn = ((key: string, values?: TranslationValues) => string) & {
raw: (key: string) => unknown;
};
type I18nContextValue = {
locale: Locale;
messages: LocaleMessages;
};
const I18nContext = createContext<I18nContextValue>({
locale: defaultLocale,
messages: getMessagesByLocale(defaultLocale)
});
function getNestedValue(value: unknown, key: string): unknown {
if (!key) {
return value;
}
return key.split(".").reduce<unknown>((acc, segment) => {
if (acc === null || typeof acc !== "object") {
return undefined;
}
return (acc as Record<string, unknown>)[segment];
}, value);
}
function interpolate(template: string, values?: TranslationValues): string {
if (!values) {
return template;
}
return template.replace(/\{(\w+)\}/g, (_, key) => {
const value = values[key];
return value === undefined || value === null ? "" : String(value);
});
}
function createTranslator(messages: LocaleMessages, namespace?: string): TranslateFn {
const base = namespace ? getNestedValue(messages, namespace) : messages;
const translate = ((key: string, values?: TranslationValues) => {
const value = getNestedValue(base, key);
if (typeof value === "string") {
return interpolate(value, values);
}
if (typeof value === "number" || typeof value === "boolean") {
return String(value);
}
return key;
}) as TranslateFn;
translate.raw = (key: string) => getNestedValue(base, key);
return translate;
}
export function NextIntlClientProvider({
children,
locale,
messages
}: {
children: ReactNode;
locale?: string;
messages?: LocaleMessages;
}) {
const normalizedLocale = normalizeLocale(locale);
const resolvedMessages = messages ?? getMessagesByLocale(normalizedLocale);
useEffect(() => {
persistLocale(normalizedLocale);
}, [normalizedLocale]);
const value = useMemo<I18nContextValue>(
() => ({
locale: normalizedLocale,
messages: resolvedMessages
}),
[normalizedLocale, resolvedMessages]
);
return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>;
}
export function useLocale(): Locale {
return useContext(I18nContext).locale;
}
export function useTranslations(namespace?: string): TranslateFn {
const { messages } = useContext(I18nContext);
return useMemo(() => createTranslator(messages, namespace), [messages, namespace]);
}
-14
View File
@@ -1,14 +0,0 @@
import { cookies } from "next/headers";
import { defaultLocale } from "./config";
const COOKIE_NAME = "NEXT_LOCALE";
export async function getUserLocale() {
const cookieStore = await cookies();
return cookieStore.get(COOKIE_NAME)?.value || defaultLocale;
}
export async function setUserLocale(locale: string) {
const cookieStore = await cookies();
cookieStore.set(COOKIE_NAME, locale);
}
+1 -1
View File
@@ -62,7 +62,7 @@
"content": " 全新的 AI 简历优化功能已上线"
},
"cta": {
"title": "开启你的新职业篇章",
"title": "开启你的新职业篇章",
"description": "立即使用魔方简历,创建一份令人印象深刻的简历",
"button": "免费开始使用"
},
+29
View File
@@ -0,0 +1,29 @@
import type { Locale } from "@/i18n/config";
import { defaultLocale, locales } from "@/i18n/config";
import en from "@/i18n/locales/en.json";
import zh from "@/i18n/locales/zh.json";
const localeMessages = {
en,
zh
} as const;
export type LocaleMessages = (typeof localeMessages)[Locale];
export function isSupportedLocale(locale: string): locale is Locale {
return locales.includes(locale as Locale);
}
export function getMessagesByLocale(locale: string): LocaleMessages {
if (isSupportedLocale(locale)) {
return localeMessages[locale];
}
return localeMessages[defaultLocale];
}
export function normalizeLocale(locale?: string | null): Locale {
if (locale && isSupportedLocale(locale)) {
return locale;
}
return defaultLocale;
}
-23
View File
@@ -1,23 +0,0 @@
import { getRequestConfig } from "next-intl/server";
import { defaultLocale, locales } from "./config";
import { getUserLocale } from "./db";
export default getRequestConfig(async ({ requestLocale }) => {
// Read from potential `[locale]` segment
let locale = await requestLocale;
if (!locale) {
// The user is logged in
locale = await getUserLocale();
}
// Ensure that the incoming locale is valid
if (!locales.includes(locale as any)) {
locale = defaultLocale;
}
return {
locale,
messages: (await import(`./locales/${locale}.json`)).default,
};
});
-10
View File
@@ -1,10 +0,0 @@
import { createNavigation } from "next-intl/navigation";
import { defineRouting } from "next-intl/routing";
import { defaultLocale, locales } from "./config";
export const routing = defineRouting({
locales,
defaultLocale,
});
export const { Link, usePathname } = createNavigation(routing);
+29
View File
@@ -0,0 +1,29 @@
import { Link as RouterLink, useRouterState } from "@tanstack/react-router";
import { defaultLocale, locales } from "./config";
import { useLocale } from "next-intl";
import { withLocale } from "./runtime";
export const routing = {
locales,
defaultLocale
};
export function usePathname() {
return useRouterState({ select: (state) => state.location.pathname });
}
export function Link({
href,
locale,
...props
}: {
href: string;
locale?: string;
className?: string;
children: React.ReactNode;
}) {
const currentLocale = useLocale();
const nextLocale = locale ?? currentLocale;
return <RouterLink to={withLocale(href, nextLocale)} {...props} />;
}
+92
View File
@@ -0,0 +1,92 @@
import { defaultLocale, type Locale } from "@/i18n/config";
import { isSupportedLocale, normalizeLocale } from "@/i18n/messages";
export const LOCALE_COOKIE_NAME = "NEXT_LOCALE";
export const LOCALE_STORAGE_KEY = "magic-resume-locale";
function safelyReadCookie(name: string): string | undefined {
if (typeof document === "undefined") {
return undefined;
}
const cookieItem = document.cookie
.split(";")
.map((item) => item.trim())
.find((item) => item.startsWith(`${name}=`));
if (!cookieItem) {
return undefined;
}
return decodeURIComponent(cookieItem.slice(name.length + 1));
}
function safelyReadStorage(name: string): string | undefined {
if (typeof window === "undefined") {
return undefined;
}
try {
return window.localStorage.getItem(name) ?? undefined;
} catch {
return undefined;
}
}
export function persistLocale(locale: string) {
if (!isSupportedLocale(locale)) {
return;
}
if (typeof document !== "undefined") {
document.cookie = `${LOCALE_COOKIE_NAME}=${encodeURIComponent(locale)}; Path=/; Max-Age=31536000; SameSite=Lax`;
}
if (typeof window !== "undefined") {
try {
window.localStorage.setItem(LOCALE_STORAGE_KEY, locale);
} catch {
// ignore storage errors
}
}
}
export function getLocaleFromPath(pathname: string): Locale | undefined {
const segment = pathname.split("/").filter(Boolean)[0];
if (segment && isSupportedLocale(segment)) {
return segment;
}
return undefined;
}
export function resolveLocale(pathname: string): Locale {
return (
getLocaleFromPath(pathname) ??
normalizeLocale(safelyReadCookie(LOCALE_COOKIE_NAME)) ??
normalizeLocale(safelyReadStorage(LOCALE_STORAGE_KEY)) ??
defaultLocale
);
}
export function withLocale(pathname: string, locale: string): string {
const normalizedLocale = normalizeLocale(locale);
const normalizedPathname = pathname.startsWith("/") ? pathname : `/${pathname}`;
if (normalizedPathname === "/") {
return `/${normalizedLocale}`;
}
const segments = normalizedPathname.split("/").filter(Boolean);
if (segments.length > 0 && isSupportedLocale(segments[0])) {
segments[0] = normalizedLocale;
return `/${segments.join("/")}`;
}
// 保持应用内路由无 locale 前缀,切换语言仅更新偏好
if (normalizedPathname.startsWith("/app") || normalizedPathname.startsWith("/api")) {
return normalizedPathname;
}
return `/${normalizedLocale}${normalizedPathname}`;
}
-6
View File
@@ -1,6 +0,0 @@
import createMiddleware from "next-intl/middleware";
import { routing } from "./i18n/routing.public";
const handler = createMiddleware(routing);
export default handler;
+27
View File
@@ -0,0 +1,27 @@
import { useEffect, useMemo } from "react";
import { useRouterState } from "@tanstack/react-router";
import { NextIntlClientProvider } from "next-intl";
import { Providers } from "@/app/providers";
import { resolveLocale } from "@/i18n/runtime";
import { getMessagesByLocale } from "@/i18n/messages";
import { Toaster } from "@/components/ui/sonner";
export function RouteProviders({ children }: { children: React.ReactNode }) {
const pathname = useRouterState({ select: (state) => state.location.pathname });
const locale = useMemo(() => resolveLocale(pathname), [pathname]);
const messages = useMemo(() => getMessagesByLocale(locale), [locale]);
useEffect(() => {
document.documentElement.lang = locale;
}, [locale]);
return (
<NextIntlClientProvider locale={locale} messages={messages}>
<Providers>
{children}
<Toaster position="top-center" richColors />
</Providers>
</NextIntlClientProvider>
);
}
-7
View File
@@ -1,7 +0,0 @@
import handler from "./intl-proxy";
export default handler;
export const config = {
matcher: ["/", "/(zh|en)/:path*"],
};
+359
View File
@@ -0,0 +1,359 @@
/* eslint-disable */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// This file was automatically generated by TanStack Router.
// You should NOT make any changes in this file as it will be overwritten.
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
import { Route as rootRouteImport } from './routes/__root'
import { Route as AppRouteImport } from './routes/app'
import { Route as LocaleRouteImport } from './routes/$locale'
import { Route as IndexRouteImport } from './routes/index'
import { Route as AppIndexRouteImport } from './routes/app.index'
import { Route as AppDashboardRouteImport } from './routes/app.dashboard'
import { Route as ApiPolishRouteImport } from './routes/api.polish'
import { Route as ApiGrammarRouteImport } from './routes/api.grammar'
import { Route as AppDashboardIndexRouteImport } from './routes/app.dashboard.index'
import { Route as AppWorkbenchIdRouteImport } from './routes/app.workbench.$id'
import { Route as AppDashboardTemplatesRouteImport } from './routes/app.dashboard.templates'
import { Route as AppDashboardSettingsRouteImport } from './routes/app.dashboard.settings'
import { Route as AppDashboardResumesRouteImport } from './routes/app.dashboard.resumes'
import { Route as AppDashboardAiRouteImport } from './routes/app.dashboard.ai'
import { Route as ApiProxyImageRouteImport } from './routes/api.proxy.image'
const AppRoute = AppRouteImport.update({
id: '/app',
path: '/app',
getParentRoute: () => rootRouteImport,
} as any)
const LocaleRoute = LocaleRouteImport.update({
id: '/$locale',
path: '/$locale',
getParentRoute: () => rootRouteImport,
} as any)
const IndexRoute = IndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => rootRouteImport,
} as any)
const AppIndexRoute = AppIndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => AppRoute,
} as any)
const AppDashboardRoute = AppDashboardRouteImport.update({
id: '/dashboard',
path: '/dashboard',
getParentRoute: () => AppRoute,
} as any)
const ApiPolishRoute = ApiPolishRouteImport.update({
id: '/api/polish',
path: '/api/polish',
getParentRoute: () => rootRouteImport,
} as any)
const ApiGrammarRoute = ApiGrammarRouteImport.update({
id: '/api/grammar',
path: '/api/grammar',
getParentRoute: () => rootRouteImport,
} as any)
const AppDashboardIndexRoute = AppDashboardIndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => AppDashboardRoute,
} as any)
const AppWorkbenchIdRoute = AppWorkbenchIdRouteImport.update({
id: '/workbench/$id',
path: '/workbench/$id',
getParentRoute: () => AppRoute,
} as any)
const AppDashboardTemplatesRoute = AppDashboardTemplatesRouteImport.update({
id: '/templates',
path: '/templates',
getParentRoute: () => AppDashboardRoute,
} as any)
const AppDashboardSettingsRoute = AppDashboardSettingsRouteImport.update({
id: '/settings',
path: '/settings',
getParentRoute: () => AppDashboardRoute,
} as any)
const AppDashboardResumesRoute = AppDashboardResumesRouteImport.update({
id: '/resumes',
path: '/resumes',
getParentRoute: () => AppDashboardRoute,
} as any)
const AppDashboardAiRoute = AppDashboardAiRouteImport.update({
id: '/ai',
path: '/ai',
getParentRoute: () => AppDashboardRoute,
} as any)
const ApiProxyImageRoute = ApiProxyImageRouteImport.update({
id: '/api/proxy/image',
path: '/api/proxy/image',
getParentRoute: () => rootRouteImport,
} as any)
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/$locale': typeof LocaleRoute
'/app': typeof AppRouteWithChildren
'/api/grammar': typeof ApiGrammarRoute
'/api/polish': typeof ApiPolishRoute
'/app/dashboard': typeof AppDashboardRouteWithChildren
'/app/': typeof AppIndexRoute
'/api/proxy/image': typeof ApiProxyImageRoute
'/app/dashboard/ai': typeof AppDashboardAiRoute
'/app/dashboard/resumes': typeof AppDashboardResumesRoute
'/app/dashboard/settings': typeof AppDashboardSettingsRoute
'/app/dashboard/templates': typeof AppDashboardTemplatesRoute
'/app/workbench/$id': typeof AppWorkbenchIdRoute
'/app/dashboard/': typeof AppDashboardIndexRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/$locale': typeof LocaleRoute
'/api/grammar': typeof ApiGrammarRoute
'/api/polish': typeof ApiPolishRoute
'/app': typeof AppIndexRoute
'/api/proxy/image': typeof ApiProxyImageRoute
'/app/dashboard/ai': typeof AppDashboardAiRoute
'/app/dashboard/resumes': typeof AppDashboardResumesRoute
'/app/dashboard/settings': typeof AppDashboardSettingsRoute
'/app/dashboard/templates': typeof AppDashboardTemplatesRoute
'/app/workbench/$id': typeof AppWorkbenchIdRoute
'/app/dashboard': typeof AppDashboardIndexRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/$locale': typeof LocaleRoute
'/app': typeof AppRouteWithChildren
'/api/grammar': typeof ApiGrammarRoute
'/api/polish': typeof ApiPolishRoute
'/app/dashboard': typeof AppDashboardRouteWithChildren
'/app/': typeof AppIndexRoute
'/api/proxy/image': typeof ApiProxyImageRoute
'/app/dashboard/ai': typeof AppDashboardAiRoute
'/app/dashboard/resumes': typeof AppDashboardResumesRoute
'/app/dashboard/settings': typeof AppDashboardSettingsRoute
'/app/dashboard/templates': typeof AppDashboardTemplatesRoute
'/app/workbench/$id': typeof AppWorkbenchIdRoute
'/app/dashboard/': typeof AppDashboardIndexRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths:
| '/'
| '/$locale'
| '/app'
| '/api/grammar'
| '/api/polish'
| '/app/dashboard'
| '/app/'
| '/api/proxy/image'
| '/app/dashboard/ai'
| '/app/dashboard/resumes'
| '/app/dashboard/settings'
| '/app/dashboard/templates'
| '/app/workbench/$id'
| '/app/dashboard/'
fileRoutesByTo: FileRoutesByTo
to:
| '/'
| '/$locale'
| '/api/grammar'
| '/api/polish'
| '/app'
| '/api/proxy/image'
| '/app/dashboard/ai'
| '/app/dashboard/resumes'
| '/app/dashboard/settings'
| '/app/dashboard/templates'
| '/app/workbench/$id'
| '/app/dashboard'
id:
| '__root__'
| '/'
| '/$locale'
| '/app'
| '/api/grammar'
| '/api/polish'
| '/app/dashboard'
| '/app/'
| '/api/proxy/image'
| '/app/dashboard/ai'
| '/app/dashboard/resumes'
| '/app/dashboard/settings'
| '/app/dashboard/templates'
| '/app/workbench/$id'
| '/app/dashboard/'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
LocaleRoute: typeof LocaleRoute
AppRoute: typeof AppRouteWithChildren
ApiGrammarRoute: typeof ApiGrammarRoute
ApiPolishRoute: typeof ApiPolishRoute
ApiProxyImageRoute: typeof ApiProxyImageRoute
}
declare module '@tanstack/react-router' {
interface FileRoutesByPath {
'/app': {
id: '/app'
path: '/app'
fullPath: '/app'
preLoaderRoute: typeof AppRouteImport
parentRoute: typeof rootRouteImport
}
'/$locale': {
id: '/$locale'
path: '/$locale'
fullPath: '/$locale'
preLoaderRoute: typeof LocaleRouteImport
parentRoute: typeof rootRouteImport
}
'/': {
id: '/'
path: '/'
fullPath: '/'
preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport
}
'/app/': {
id: '/app/'
path: '/'
fullPath: '/app/'
preLoaderRoute: typeof AppIndexRouteImport
parentRoute: typeof AppRoute
}
'/app/dashboard': {
id: '/app/dashboard'
path: '/dashboard'
fullPath: '/app/dashboard'
preLoaderRoute: typeof AppDashboardRouteImport
parentRoute: typeof AppRoute
}
'/api/polish': {
id: '/api/polish'
path: '/api/polish'
fullPath: '/api/polish'
preLoaderRoute: typeof ApiPolishRouteImport
parentRoute: typeof rootRouteImport
}
'/api/grammar': {
id: '/api/grammar'
path: '/api/grammar'
fullPath: '/api/grammar'
preLoaderRoute: typeof ApiGrammarRouteImport
parentRoute: typeof rootRouteImport
}
'/app/dashboard/': {
id: '/app/dashboard/'
path: '/'
fullPath: '/app/dashboard/'
preLoaderRoute: typeof AppDashboardIndexRouteImport
parentRoute: typeof AppDashboardRoute
}
'/app/workbench/$id': {
id: '/app/workbench/$id'
path: '/workbench/$id'
fullPath: '/app/workbench/$id'
preLoaderRoute: typeof AppWorkbenchIdRouteImport
parentRoute: typeof AppRoute
}
'/app/dashboard/templates': {
id: '/app/dashboard/templates'
path: '/templates'
fullPath: '/app/dashboard/templates'
preLoaderRoute: typeof AppDashboardTemplatesRouteImport
parentRoute: typeof AppDashboardRoute
}
'/app/dashboard/settings': {
id: '/app/dashboard/settings'
path: '/settings'
fullPath: '/app/dashboard/settings'
preLoaderRoute: typeof AppDashboardSettingsRouteImport
parentRoute: typeof AppDashboardRoute
}
'/app/dashboard/resumes': {
id: '/app/dashboard/resumes'
path: '/resumes'
fullPath: '/app/dashboard/resumes'
preLoaderRoute: typeof AppDashboardResumesRouteImport
parentRoute: typeof AppDashboardRoute
}
'/app/dashboard/ai': {
id: '/app/dashboard/ai'
path: '/ai'
fullPath: '/app/dashboard/ai'
preLoaderRoute: typeof AppDashboardAiRouteImport
parentRoute: typeof AppDashboardRoute
}
'/api/proxy/image': {
id: '/api/proxy/image'
path: '/api/proxy/image'
fullPath: '/api/proxy/image'
preLoaderRoute: typeof ApiProxyImageRouteImport
parentRoute: typeof rootRouteImport
}
}
}
interface AppDashboardRouteChildren {
AppDashboardAiRoute: typeof AppDashboardAiRoute
AppDashboardResumesRoute: typeof AppDashboardResumesRoute
AppDashboardSettingsRoute: typeof AppDashboardSettingsRoute
AppDashboardTemplatesRoute: typeof AppDashboardTemplatesRoute
AppDashboardIndexRoute: typeof AppDashboardIndexRoute
}
const AppDashboardRouteChildren: AppDashboardRouteChildren = {
AppDashboardAiRoute: AppDashboardAiRoute,
AppDashboardResumesRoute: AppDashboardResumesRoute,
AppDashboardSettingsRoute: AppDashboardSettingsRoute,
AppDashboardTemplatesRoute: AppDashboardTemplatesRoute,
AppDashboardIndexRoute: AppDashboardIndexRoute,
}
const AppDashboardRouteWithChildren = AppDashboardRoute._addFileChildren(
AppDashboardRouteChildren,
)
interface AppRouteChildren {
AppDashboardRoute: typeof AppDashboardRouteWithChildren
AppIndexRoute: typeof AppIndexRoute
AppWorkbenchIdRoute: typeof AppWorkbenchIdRoute
}
const AppRouteChildren: AppRouteChildren = {
AppDashboardRoute: AppDashboardRouteWithChildren,
AppIndexRoute: AppIndexRoute,
AppWorkbenchIdRoute: AppWorkbenchIdRoute,
}
const AppRouteWithChildren = AppRoute._addFileChildren(AppRouteChildren)
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
LocaleRoute: LocaleRoute,
AppRoute: AppRouteWithChildren,
ApiGrammarRoute: ApiGrammarRoute,
ApiPolishRoute: ApiPolishRoute,
ApiProxyImageRoute: ApiProxyImageRoute,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
._addFileTypes<FileRouteTypes>()
import type { getRouter } from './router.tsx'
import type { createStart } from '@tanstack/react-start'
declare module '@tanstack/react-start' {
interface Register {
ssr: true
router: Awaited<ReturnType<typeof getRouter>>
}
}
+19
View File
@@ -0,0 +1,19 @@
import { createRouter as createTanStackRouter } from "@tanstack/react-router";
import { routeTree } from "./routeTree.gen";
export function getRouter() {
const router = createTanStackRouter({
routeTree,
scrollRestoration: true,
defaultPreload: "intent",
defaultPreloadStaleTime: 0
});
return router;
}
declare module "@tanstack/react-router" {
interface Register {
router: ReturnType<typeof getRouter>;
}
}
+112
View File
@@ -0,0 +1,112 @@
import { createFileRoute, redirect } from "@tanstack/react-router";
import { useEffect } from "react";
import LandingPage from "@/app/(public)/[locale]/page";
import { defaultLocale } from "@/i18n/config";
import { getMessagesByLocale, isSupportedLocale, normalizeLocale } from "@/i18n/messages";
import { persistLocale } from "@/i18n/runtime";
export const Route = createFileRoute("/$locale")({
beforeLoad: ({ params }) => {
if (!isSupportedLocale(params.locale)) {
throw redirect({
to: "/$locale",
params: {
locale: defaultLocale
}
});
}
},
head: ({ params }) => {
const locale = normalizeLocale(params.locale);
const messages = getMessagesByLocale(locale);
const common = messages.common;
const baseUrl = "https://magicv.art";
const canonical = `${baseUrl}/${locale}`;
const alternateLocale = locale === "en" ? "zh" : "en";
const ogLocale = locale === "en" ? "en_US" : "zh_CN";
return {
meta: [
{
title: `${common.title} - ${common.subtitle}`
},
{
name: "description",
content: common.description
},
{
name: "robots",
content: "index,follow"
},
{
property: "og:type",
content: "website"
},
{
property: "og:site_name",
content: common.title
},
{
property: "og:title",
content: `${common.title} - ${common.subtitle}`
},
{
property: "og:description",
content: common.description
},
{
property: "og:url",
content: canonical
},
{
property: "og:locale",
content: ogLocale
},
{
name: "twitter:card",
content: "summary_large_image"
},
{
name: "twitter:title",
content: `${common.title} - ${common.subtitle}`
},
{
name: "twitter:description",
content: common.description
}
],
links: [
{
rel: "canonical",
href: canonical
},
{
rel: "alternate",
hrefLang: "zh",
href: `${baseUrl}/zh`
},
{
rel: "alternate",
hrefLang: "en",
href: `${baseUrl}/en`
},
{
rel: "alternate",
hrefLang: "x-default",
href: `${baseUrl}/${defaultLocale}`
}
]
};
},
component: LocalePage
});
function LocalePage() {
const { locale } = Route.useParams();
useEffect(() => {
persistLocale(locale);
}, [locale]);
return <LandingPage />;
}
+56
View File
@@ -0,0 +1,56 @@
import {
ErrorComponent,
HeadContent,
Scripts,
createRootRoute
} from "@tanstack/react-router";
import { RouteProviders } from "@/providers/RouteProviders";
import appCss from "@/styles.css?url";
export const Route = createRootRoute({
head: () => ({
meta: [
{
charSet: "utf-8"
},
{
name: "viewport",
content: "width=device-width, initial-scale=1"
},
{
name: "theme-color",
content: "#1B1B18"
}
],
links: [
{
rel: "stylesheet",
href: appCss
},
{
rel: "icon",
href: "/icon.png"
}
]
}),
errorComponent: ({ error }) => (
<RootDocument>
<ErrorComponent error={error} />
</RootDocument>
),
shellComponent: RootDocument
});
function RootDocument({ children }: { children: React.ReactNode }) {
return (
<html lang="zh" suppressHydrationWarning>
<head>
<HeadContent />
</head>
<body>
<RouteProviders>{children}</RouteProviders>
<Scripts />
</body>
</html>
);
}
@@ -1,31 +1,38 @@
import { NextRequest, NextResponse } from "next/server";
import { AIModelType } from "@/config/ai";
import { AI_MODEL_CONFIGS } from "@/config/ai";
import { createFileRoute } from "@tanstack/react-router";
import { AIModelType, AI_MODEL_CONFIGS } from "@/config/ai";
export async function POST(req: NextRequest) {
try {
const body = await req.json();
const { apiKey, model, content, modelType, apiEndpoint } = body;
export const Route = createFileRoute("/api/grammar")({
server: {
handlers: {
POST: async ({ request }) => {
try {
const body = await request.json();
const { apiKey, model, content, modelType, apiEndpoint } = body as {
apiKey: string;
model: string;
content: string;
modelType: AIModelType;
apiEndpoint?: string;
};
const modelConfig = AI_MODEL_CONFIGS[modelType as AIModelType];
if (!modelConfig) {
throw new Error("Invalid model type");
}
const modelConfig = AI_MODEL_CONFIGS[modelType as AIModelType];
if (!modelConfig) {
return Response.json({ error: "Invalid model type" }, { status: 400 });
}
const response = await fetch(modelConfig.url(apiEndpoint), {
method: "POST",
headers: modelConfig.headers(apiKey),
const response = await fetch(modelConfig.url(apiEndpoint), {
method: "POST",
headers: modelConfig.headers(apiKey),
body: JSON.stringify({
model: modelConfig.requiresModelId ? model : modelConfig.defaultModel,
response_format: {
type: "json_object"
},
messages: [
{
role: "system",
content: `你是一个专业的中文简历校对助手。你的任务是**仅**找出简历中的**错别字**和**标点符号错误。
body: JSON.stringify({
model: modelConfig.requiresModelId ? model : modelConfig.defaultModel,
response_format: {
type: "json_object",
},
messages: [
{
role: "system",
content: `你是一个专业的中文简历校对助手。你的任务是**仅**找出简历中的**错别字**和**标点符号错误**。
****
1. ****使****
2. ****"errors"
@@ -34,7 +41,7 @@ export async function POST(req: NextRequest) {
****
1. ****
2. ****
****
- ****使使, 使. ********
- **使**
@@ -51,24 +58,24 @@ export async function POST(req: NextRequest) {
}
]
}
****`,
},
{
role: "user",
content: content,
},
],
}),
});
const data = await response.json();
return NextResponse.json(data);
} catch (error) {
console.error("Error in grammar check:", error);
return NextResponse.json(
{ error: "Failed to check grammar" },
{ status: 500 }
);
****`
},
{
role: "user",
content
}
]
})
});
const data = await response.json();
return Response.json(data);
} catch (error) {
console.error("Error in grammar check:", error);
return Response.json({ error: "Failed to check grammar" }, { status: 500 });
}
}
}
}
}
});
+112
View File
@@ -0,0 +1,112 @@
import { createFileRoute } from "@tanstack/react-router";
import { AIModelType, AI_MODEL_CONFIGS } from "@/config/ai";
export const Route = createFileRoute("/api/polish")({
server: {
handlers: {
POST: async ({ request }) => {
try {
const body = await request.json();
const { apiKey, model, content, modelType, apiEndpoint } = body as {
apiKey: string;
model: string;
content: string;
modelType: AIModelType;
apiEndpoint?: string;
};
const modelConfig = AI_MODEL_CONFIGS[modelType as AIModelType];
if (!modelConfig) {
return Response.json({ error: "Invalid model type" }, { status: 400 });
}
const response = await fetch(modelConfig.url(apiEndpoint), {
method: "POST",
headers: modelConfig.headers(apiKey),
body: JSON.stringify({
model: modelConfig.requiresModelId ? model : modelConfig.defaultModel,
messages: [
{
role: "system",
content: `你是一个专业的简历优化助手。请帮助优化以下文本,使其更加专业和有吸引力。
1. 使
2.
3.
4. 使
5.
6.
`
},
{
role: "user",
content
}
],
stream: true
})
});
const encoder = new TextEncoder();
const stream = new ReadableStream({
async start(controller) {
if (!response.body) {
controller.close();
return;
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
try {
while (true) {
const { done, value } = await reader.read();
if (done) {
controller.close();
break;
}
const chunk = decoder.decode(value);
const lines = chunk
.split("\n")
.filter((line) => line.trim() !== "");
for (const line of lines) {
if (line.includes("[DONE]")) continue;
if (!line.startsWith("data:")) continue;
try {
const data = JSON.parse(line.slice(5));
const contentDelta = data.choices[0]?.delta?.content;
if (contentDelta) {
controller.enqueue(encoder.encode(contentDelta));
}
} catch (e) {
console.error("Error parsing JSON:", e);
}
}
}
} catch (error) {
console.error("Stream reading error:", error);
controller.error(error);
}
}
});
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive"
}
});
} catch (error) {
console.error("Polish error:", error);
return Response.json({ error: "Failed to polish content" }, { status: 500 });
}
}
}
}
});
+152
View File
@@ -0,0 +1,152 @@
import { createFileRoute } from "@tanstack/react-router";
function validateImageUrl(imageUrl: string | null) {
if (!imageUrl) {
return {
ok: false,
status: 400,
error: "缺少图片URL参数"
} as const;
}
let parsedUrl: URL;
try {
parsedUrl = new URL(imageUrl);
} catch {
return {
ok: false,
status: 400,
error: "图片URL格式不正确"
} as const;
}
if (parsedUrl.protocol !== "http:" && parsedUrl.protocol !== "https:") {
return {
ok: false,
status: 400,
error: "只支持HTTP和HTTPS协议"
} as const;
}
return {
ok: true,
parsedUrl
} as const;
}
function buildHeaders(parsedUrl: URL) {
return {
"User-Agent":
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36",
Accept: "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8",
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
Referer: parsedUrl.origin
};
}
export const Route = createFileRoute("/api/proxy/image")({
server: {
handlers: {
GET: async ({ request }) => {
try {
const { searchParams } = new URL(request.url);
const imageUrl = searchParams.get("url");
const validation = validateImageUrl(imageUrl);
if (!validation.ok) {
return Response.json({ error: validation.error }, { status: validation.status });
}
const response = await fetch(imageUrl!, {
headers: buildHeaders(validation.parsedUrl)
});
if (!response.ok) {
return Response.json(
{ error: `获取图片失败: ${response.status} ${response.statusText}` },
{ status: response.status }
);
}
const imageBuffer = await response.arrayBuffer();
if (imageBuffer.byteLength === 0) {
return Response.json({ error: "图片内容为空" }, { status: 400 });
}
const contentType = response.headers.get("content-type") || "image/jpeg";
return new Response(imageBuffer, {
headers: {
"Content-Type": contentType,
"Cache-Control": "no-store, no-cache, must-revalidate, proxy-revalidate",
Pragma: "no-cache",
Expires: "0",
"Surrogate-Control": "no-store",
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, HEAD, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type"
}
});
} catch (error: any) {
console.error("图片代理未处理的错误:", error);
return Response.json(
{ error: `处理图片请求时出错: ${error?.message || "未知错误"}` },
{ status: 500 }
);
}
},
HEAD: async ({ request }) => {
try {
const { searchParams } = new URL(request.url);
const imageUrl = searchParams.get("url");
const validation = validateImageUrl(imageUrl);
if (!validation.ok) {
return new Response(null, { status: validation.status });
}
const response = await fetch(imageUrl!, {
method: "HEAD",
headers: buildHeaders(validation.parsedUrl)
});
if (!response.ok) {
return new Response(null, { status: response.status });
}
const headers = new Headers();
const contentType = response.headers.get("content-type");
const contentLength = response.headers.get("content-length");
if (contentType) {
headers.set("content-type", contentType);
}
if (contentLength) {
headers.set("content-length", contentLength);
}
headers.set("Access-Control-Allow-Origin", "*");
headers.set("Access-Control-Allow-Methods", "GET, HEAD, OPTIONS");
headers.set("Access-Control-Allow-Headers", "Content-Type");
return new Response(null, {
status: 200,
headers
});
} catch (error) {
return new Response(null, { status: 500 });
}
},
OPTIONS: async () => {
return new Response(null, {
headers: {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, HEAD, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type"
}
});
}
}
}
});
+6
View File
@@ -0,0 +1,6 @@
import { createFileRoute } from "@tanstack/react-router";
import AISettingsPage from "@/app/app/dashboard/ai/page";
export const Route = createFileRoute("/app/dashboard/ai")({
component: AISettingsPage
});
+9
View File
@@ -0,0 +1,9 @@
import { createFileRoute, redirect } from "@tanstack/react-router";
export const Route = createFileRoute("/app/dashboard/")({
beforeLoad: () => {
throw redirect({
to: "/app/dashboard/resumes"
});
}
});
+6
View File
@@ -0,0 +1,6 @@
import { createFileRoute } from "@tanstack/react-router";
import ResumesPage from "@/app/app/dashboard/resumes/page";
export const Route = createFileRoute("/app/dashboard/resumes")({
component: ResumesPage
});
+6
View File
@@ -0,0 +1,6 @@
import { createFileRoute } from "@tanstack/react-router";
import SettingsPage from "@/app/app/dashboard/settings/page";
export const Route = createFileRoute("/app/dashboard/settings")({
component: SettingsPage
});
+6
View File
@@ -0,0 +1,6 @@
import { createFileRoute } from "@tanstack/react-router";
import TemplatesPage from "@/app/app/dashboard/templates/page";
export const Route = createFileRoute("/app/dashboard/templates")({
component: TemplatesPage
});
+14
View File
@@ -0,0 +1,14 @@
import { Outlet, createFileRoute } from "@tanstack/react-router";
import DashboardLayout from "@/app/app/dashboard/client";
export const Route = createFileRoute("/app/dashboard")({
component: DashboardRoute
});
function DashboardRoute() {
return (
<DashboardLayout>
<Outlet />
</DashboardLayout>
);
}
+9
View File
@@ -0,0 +1,9 @@
import { createFileRoute, redirect } from "@tanstack/react-router";
export const Route = createFileRoute("/app/")({
beforeLoad: () => {
throw redirect({
to: "/app/dashboard"
});
}
});
+17
View File
@@ -0,0 +1,17 @@
import { Outlet, createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute("/app")({
head: () => ({
meta: [
{
name: "robots",
content: "noindex,nofollow"
}
]
}),
component: AppRoute
});
function AppRoute() {
return <Outlet />;
}
+19
View File
@@ -0,0 +1,19 @@
import { useEffect } from "react";
import { createFileRoute } from "@tanstack/react-router";
import WorkbenchPage from "@/app/app/workbench/[id]/page";
export const Route = createFileRoute("/app/workbench/$id")({
component: WorkbenchRoute
});
function WorkbenchRoute() {
useEffect(() => {
document.body.classList.add("overflow-y-hidden", "w-full");
return () => {
document.body.classList.remove("overflow-y-hidden", "w-full");
};
}, []);
return <WorkbenchPage />;
}
+15
View File
@@ -0,0 +1,15 @@
import { createFileRoute, redirect } from "@tanstack/react-router";
import { resolveLocale } from "@/i18n/runtime";
export const Route = createFileRoute("/")({
beforeLoad: ({ location }) => {
const locale = resolveLocale(location.pathname);
throw redirect({
to: "/$locale",
params: {
locale
}
});
}
});
+3
View File
@@ -0,0 +1,3 @@
@import "./app/globals.css";
@import "./app/font.css";
@import "./styles/tiptap.scss";
+10 -28
View File
@@ -1,41 +1,23 @@
{
"compilerOptions": {
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"target": "ES2022",
"jsx": "react-jsx",
"module": "ESNext",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"types": ["vite/client"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"baseUrl": ".",
"paths": {
"@/*": [
"./src/*"
]
},
"target": "ES2017"
"@/*": ["./src/*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules"
]
"include": ["**/*.ts", "**/*.tsx"],
"exclude": ["node_modules", ".next", ".open-next"]
}
+45
View File
@@ -0,0 +1,45 @@
import { defineConfig } from "vite";
import { tanstackStart } from "@tanstack/react-start/plugin/vite";
import viteReact from "@vitejs/plugin-react";
import viteTsConfigPaths from "vite-tsconfig-paths";
import { fileURLToPath, URL } from "node:url";
import { cloudflare } from "@cloudflare/vite-plugin";
const config = defineConfig({
resolve: {
alias: {
"@": fileURLToPath(new URL("./src", import.meta.url)),
"next/navigation": fileURLToPath(
new URL("./src/compat/next/navigation.ts", import.meta.url)
),
"next/link": fileURLToPath(
new URL("./src/compat/next/link.tsx", import.meta.url)
),
"next/image": fileURLToPath(
new URL("./src/compat/next/image.tsx", import.meta.url)
),
"next/font/google": fileURLToPath(
new URL("./src/compat/next/font/google.ts", import.meta.url)
),
"next/cache": fileURLToPath(
new URL("./src/compat/next/cache.ts", import.meta.url)
),
"next/headers": fileURLToPath(
new URL("./src/compat/next/headers.ts", import.meta.url)
),
"next-intl": fileURLToPath(
new URL("./src/i18n/client.tsx", import.meta.url)
)
}
},
plugins: [
cloudflare({ viteEnvironment: { name: "ssr" } }),
viteTsConfigPaths({
projects: ["./tsconfig.json"]
}),
tanstackStart(),
viteReact()
]
});
export default config;
+3 -14
View File
@@ -1,15 +1,4 @@
name = "sorafm"
main = ".open-next/worker.js"
name = "magic-resume"
main = "@tanstack/react-start/server-entry"
compatibility_date = "2025-12-01"
compatibility_flags = ["nodejs_compat", "global_fetch_strictly_public"]
[assets]
directory = ".open-next/assets"
binding = "ASSETS"
[[services]]
binding = "WORKER_SELF_REFERENCE"
service = "sorafm"
[images]
binding = "IMAGES"
compatibility_flags = ["nodejs_compat"]