Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions app/components/Configurator.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"use client"

import { useState } from 'react'
import copy from 'copy-to-clipboard'
import { useCopy } from '@/hooks/use-copy'

export const Configurator = ({ args, template, env }: {
args: string[],
Expand All @@ -15,6 +15,7 @@ export const Configurator = ({ args, template, env }: {
}));

const [values, setValues] = useState(envVariables.map(v => v.defaultVal || ''));
const { copyToClipboard } = useCopy();

const handleCopy = () => {
// 处理环境变量
Expand All @@ -34,7 +35,7 @@ export const Configurator = ({ args, template, env }: {
);
});

copy(result);
copyToClipboard(result, '配置已复制到剪贴板');
};

return (
Expand Down Expand Up @@ -65,4 +66,4 @@ export const Configurator = ({ args, template, env }: {
</button>
</div>
);
};
};
14 changes: 5 additions & 9 deletions app/components/EnvVariableConfig.tsx
Original file line number Diff line number Diff line change
@@ -1,23 +1,19 @@
"use client";
import { useState } from 'react';
import copy from 'copy-to-clipboard';
import { useCopy } from '@/hooks/use-copy';

export function EnvVariableConfig({ variableNames, format }: { variableNames: { key: string; name: string; defaultVal?: string }[]; format?: "yaml" | "env" }) {
const [values, setValues] = useState(variableNames.map((name) => name.defaultVal || ''));
const [copyButtonText, setCopyButtonText] = useState('复制'); // 新增状态用于控制按钮文本
const { copyToClipboard } = useCopy();

const handleCopy = () => {
if (format === 'yaml') {
const yamlContent = variableNames.map((name, index) => `- ${name.key}=${values[index]}`).join('\n');
copy(yamlContent);
copyToClipboard(yamlContent, '环境变量配置已复制');
} else {
const envContent = variableNames.map((name, index) => `${name.key}=${values[index]}`).join('\n');
copy(envContent);
copyToClipboard(envContent, '环境变量配置已复制');
}
setCopyButtonText('复制成功');
setTimeout(() => {
setCopyButtonText('复制');
}, 3000);
};

const handleChange = (index: number, value: string) => {
Expand Down Expand Up @@ -68,7 +64,7 @@ export function EnvVariableConfig({ variableNames, format }: { variableNames: {
className="border bg-black w-full text-white px-4 py-2 rounded-lg text-sm transform transition-all duration-300 focus:outline-none hover:bg-gray-700 dark:border-gray-700 dark:bg-gray-800"
onClick={handleCopy}
>
{copyButtonText}
复制配置
</button>
</div>
</div>
Expand Down
5 changes: 4 additions & 1 deletion app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Provider } from './components/provider';
import type { ReactNode } from 'react';
import type { Metadata } from 'next'
import { env } from 'std-env'
import { ToastProvider } from '@/contexts/toast-context';
const baseUrl = env.NEXT_PUBLIC_BASE_URL || 'https://mx-space.js.org'
const metaDescription = `Mix Space 是一个小型个人空间站点程序,采用前后端分离设计,适合喜欢写作的你。`
const metaTitle = 'Mix Space 文档 - 现代化的个人空间解决方案'
Expand Down Expand Up @@ -34,7 +35,9 @@ export default function RootLayout({
</head>
<body>
<Provider>
{children}
<ToastProvider>
{children}
</ToastProvider>
</Provider>
</body>
</html>
Expand Down
98 changes: 98 additions & 0 deletions components/ui/toast.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
'use client'

import { useEffect, useState } from 'react'
import { createPortal } from 'react-dom'
import { cn } from '@/utils/cn'

export interface ToastProps {
id: string
message: string
type?: 'success' | 'error' | 'warning' | 'info'
duration?: number
onClose: (id: string) => void
}

export function Toast({ id, message, type = 'success', duration = 3000, onClose }: ToastProps) {
const [isVisible, setIsVisible] = useState(false)
const [isLeaving, setIsLeaving] = useState(false)
const [mounted, setMounted] = useState(false)

useEffect(() => {
setMounted(true)
}, [])

useEffect(() => {
if (!mounted) return

// 进入动画
const timer = setTimeout(() => setIsVisible(true), 50)

// 自动关闭
const closeTimer = setTimeout(() => {
setIsLeaving(true)
setTimeout(() => onClose(id), 300)
}, duration)

return () => {
clearTimeout(timer)
clearTimeout(closeTimer)
}
}, [id, duration, onClose, mounted])

const getTypeStyles = () => {
switch (type) {
case 'success':
return 'bg-green-500 text-white border-green-600'
case 'error':
return 'bg-red-500 text-white border-red-600'
case 'warning':
return 'bg-yellow-500 text-white border-yellow-600'
case 'info':
return 'bg-blue-500 text-white border-blue-600'
default:
return 'bg-green-500 text-white border-green-600'
}
}

const getIcon = () => {
switch (type) {
case 'success':
return '✓'
case 'error':
return '✕'
case 'warning':
return '⚠'
case 'info':
return 'ℹ'
default:
return '✓'
}
}

if (!mounted) return null

return createPortal(
<div
className={cn(
'fixed top-4 right-4 z-50 flex items-center gap-3 px-4 py-3 rounded-lg shadow-lg border',
'transform transition-all duration-300 ease-in-out min-w-[280px] max-w-[400px]',
getTypeStyles(),
isVisible && !isLeaving ? 'translate-x-0 opacity-100' : 'translate-x-full opacity-0'
)}
>
<span className="text-lg font-semibold">{getIcon()}</span>
<span className="flex-1 font-medium text-sm">{message}</span>
<button
onClick={() => {
setIsLeaving(true)
setTimeout(() => onClose(id), 300)
}}
className="ml-2 text-white/80 hover:text-white transition-colors text-lg leading-none"
aria-label="关闭"
>
×
</button>
</div>,
document.body
)
}
57 changes: 53 additions & 4 deletions content/docs/core/extra.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,62 @@ icon: Ellipsis

## 反向代理

在这里提供双域名(前端和后端各用一个域名)与单域名(前后端共用一个域名)的配置步骤
在这里提供 **Mix-Space** 的反代配置步骤

当然不管使用哪种方法,都建议用控制面板(如宝塔、1Panel 等)的使用面板提供的反代功能单独粘贴对应的反代配置部分完成配置(需要删掉开头和结尾的 server 块),手写反代配置的大佬随意。
当然不管使用哪种方法,都建议用控制面板(如宝塔、1Panel 等)完成配置,手写反代配置的大佬随意。

另外,不管是前端还是后端的域名,都需要**配置好 HTTPS 证书**以保证网站能正常访问。

### 双域名
### 图形化界面

现代服务器面板(如 `1Panel` 和`宝塔面板`)自带的**反向代理**已足以满足 Mix-Space 所需的反代要求(包括 Websocket),因此我们更建议非高级用户使用图形化界面来操作和维护

#### 宝塔面板

进入`网站`,在`反向代理`栏目下点击`添加反代`

`域名`填入你将要使用的域名,`目标`填写`URL地址` + `http://127.0.0.1:2333`

#### 1Panel

进入`网站 > 网站`,并创建一个新网站,选择`反向代理`

`主域名`填入你将要使用的域名,并勾选`监听 IPV6`,代理类型选择 `http` ,地址填入 `127.0.0.1:2333`

### Cloudflare Tunnel
<Callout type="warn">
除非你在**非完整服务器环境**(如在 Sealos 或 Huggingface Space 上部署),否则我们不推荐在容器内使用该功能,而应在宿主机内配置 **Cloudflare Tunnel** 以避免后期出现管理不方便等问题
</Callout>

启动该功能需要两个环境变量
- `ENABLE_CLOUDFLARED` = **true**
- `CF_ZERO_TRUST_TOKEN` = **Tunnel 给的令牌(删掉 cloudflared.exe service install,只保留令牌部分)**

#### 详细步骤:
1.申请 Cloudflare Zero Trust,关于申请方式请自行查找

2.添加一条隧道,连接方式选择 Cloudflared,名称任意

3.添加一个 Public Hostname,回源选择 HTTP,端口选择 2333

一旦启动成功,你应当在日志中看到如下输出,并在 Cloudflare 后台看到客户端正常上线:
```
============================================
Starting Cloudflared Tunnel
============================================

============================================
2025-06-06T02:22:40Z INF Using SysV
2025-06-06T02:22:41Z INF Linux service for cloudflared installed successfully
```

### 手写配置

<Callout type="warn">
手写配置文件需要较高的**技术功底**,请量力而行
</Callout>

#### 双域名

这里假定前端域名为 `www.example.com`,后端为 `server.example.com`。

Expand Down Expand Up @@ -80,7 +129,7 @@ server{
- 本地后台为 `https://server.example.com/proxy/qaqdmin`
</Callout>

### 单域名
#### 单域名

以下配置文件以 Nginx 为例,请自行修改 SSL 证书路径以及自己的网站域名。

Expand Down
78 changes: 78 additions & 0 deletions contexts/toast-context.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
'use client'

import { createContext, useContext, useState, ReactNode, useCallback } from 'react'
import { Toast, ToastProps } from '@/components/ui/toast'

interface ToastContextType {
showToast: (message: string, type?: ToastProps['type'], duration?: number) => void
showSuccess: (message: string, duration?: number) => void
showError: (message: string, duration?: number) => void
showWarning: (message: string, duration?: number) => void
showInfo: (message: string, duration?: number) => void
}

const ToastContext = createContext<ToastContextType | null>(null)

interface ToastItem extends Omit<ToastProps, 'onClose'> {
id: string
}

export function ToastProvider({ children }: { children: ReactNode }) {
const [toasts, setToasts] = useState<ToastItem[]>([])

const removeToast = useCallback((id: string) => {
setToasts(prev => prev.filter(toast => toast.id !== id))
}, [])

const showToast = useCallback((
message: string,
type: ToastProps['type'] = 'success',
duration = 3000
) => {
const id = Date.now().toString() + Math.random().toString(36).substr(2, 9)
const newToast: ToastItem = {
id,
message,
type,
duration
}
setToasts(prev => [...prev, newToast])
}, [])

const showSuccess = useCallback((message: string, duration?: number) => {
showToast(message, 'success', duration)
}, [showToast])

const showError = useCallback((message: string, duration?: number) => {
showToast(message, 'error', duration)
}, [showToast])

const showWarning = useCallback((message: string, duration?: number) => {
showToast(message, 'warning', duration)
}, [showToast])

const showInfo = useCallback((message: string, duration?: number) => {
showToast(message, 'info', duration)
}, [showToast])

return (
<ToastContext.Provider value={{ showToast, showSuccess, showError, showWarning, showInfo }}>
{children}
{toasts.map(toast => (
<Toast
key={toast.id}
{...toast}
onClose={removeToast}
/>
))}
</ToastContext.Provider>
)
}

export function useToast() {
const context = useContext(ToastContext)
if (!context) {
throw new Error('useToast must be used within a ToastProvider')
}
return context
}
43 changes: 43 additions & 0 deletions hooks/use-copy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
'use client'

import { useCallback } from 'react'
import { useToast } from '@/contexts/toast-context'

export function useCopy() {
const { showSuccess, showError } = useToast()

const copyToClipboard = useCallback(async (text: string, successMessage?: string) => {
try {
if (navigator.clipboard && window.isSecureContext) {
// 使用现代 Clipboard API
await navigator.clipboard.writeText(text)
} else {
// 兼容旧版浏览器
const textArea = document.createElement('textarea')
textArea.value = text
textArea.style.position = 'fixed'
textArea.style.left = '-999999px'
textArea.style.top = '-999999px'
document.body.appendChild(textArea)
textArea.focus()
textArea.select()

const successful = document.execCommand('copy')
document.body.removeChild(textArea)

if (!successful) {
throw new Error('复制失败')
}
}

showSuccess(successMessage || '复制成功!')
return true
} catch (error) {
console.error('复制失败:', error)
showError('复制失败,请重试')
return false
}
}, [showSuccess, showError])

return { copyToClipboard }
}
7 changes: 6 additions & 1 deletion utils/cn.ts
Original file line number Diff line number Diff line change
@@ -1 +1,6 @@
export { twMerge as cn } from 'tailwind-merge';
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';

export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}