Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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));
}