diff --git a/app/components/Configurator.tsx b/app/components/Configurator.tsx index c6992a4c..5ab66da7 100644 --- a/app/components/Configurator.tsx +++ b/app/components/Configurator.tsx @@ -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[], @@ -15,6 +15,7 @@ export const Configurator = ({ args, template, env }: { })); const [values, setValues] = useState(envVariables.map(v => v.defaultVal || '')); + const { copyToClipboard } = useCopy(); const handleCopy = () => { // 处理环境变量 @@ -34,7 +35,7 @@ export const Configurator = ({ args, template, env }: { ); }); - copy(result); + copyToClipboard(result, '配置已复制到剪贴板'); }; return ( @@ -65,4 +66,4 @@ export const Configurator = ({ args, template, env }: { ); -}; \ No newline at end of file +}; \ No newline at end of file diff --git a/app/components/EnvVariableConfig.tsx b/app/components/EnvVariableConfig.tsx index ed3a0ff9..48d8a43b 100644 --- a/app/components/EnvVariableConfig.tsx +++ b/app/components/EnvVariableConfig.tsx @@ -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) => { @@ -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} + 复制配置 diff --git a/app/layout.tsx b/app/layout.tsx index 320ed9e6..fc887234 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -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 文档 - 现代化的个人空间解决方案' @@ -34,7 +35,9 @@ export default function RootLayout({ - {children} + + {children} + diff --git a/components/ui/toast.tsx b/components/ui/toast.tsx new file mode 100644 index 00000000..1ad97d16 --- /dev/null +++ b/components/ui/toast.tsx @@ -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( +
+ {getIcon()} + {message} + +
, + document.body + ) +} diff --git a/content/docs/core/extra.mdx b/content/docs/core/extra.mdx index 2feffab3..0c420fee 100644 --- a/content/docs/core/extra.mdx +++ b/content/docs/core/extra.mdx @@ -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 + +除非你在**非完整服务器环境**(如在 Sealos 或 Huggingface Space 上部署),否则我们不推荐在容器内使用该功能,而应在宿主机内配置 **Cloudflare Tunnel** 以避免后期出现管理不方便等问题 + + +启动该功能需要两个环境变量 + - `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 +``` + +### 手写配置 + + +手写配置文件需要较高的**技术功底**,请量力而行 + + +#### 双域名 这里假定前端域名为 `www.example.com`,后端为 `server.example.com`。 @@ -80,7 +129,7 @@ server{ - 本地后台为 `https://server.example.com/proxy/qaqdmin` -### 单域名 +#### 单域名 以下配置文件以 Nginx 为例,请自行修改 SSL 证书路径以及自己的网站域名。 diff --git a/contexts/toast-context.tsx b/contexts/toast-context.tsx new file mode 100644 index 00000000..6175acff --- /dev/null +++ b/contexts/toast-context.tsx @@ -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(null) + +interface ToastItem extends Omit { + id: string +} + +export function ToastProvider({ children }: { children: ReactNode }) { + const [toasts, setToasts] = useState([]) + + 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 ( + + {children} + {toasts.map(toast => ( + + ))} + + ) +} + +export function useToast() { + const context = useContext(ToastContext) + if (!context) { + throw new Error('useToast must be used within a ToastProvider') + } + return context +} diff --git a/hooks/use-copy.ts b/hooks/use-copy.ts new file mode 100644 index 00000000..a1ac1254 --- /dev/null +++ b/hooks/use-copy.ts @@ -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 } +} diff --git a/utils/cn.ts b/utils/cn.ts index 52090748..77f4d80e 100644 --- a/utils/cn.ts +++ b/utils/cn.ts @@ -1 +1,6 @@ -export { twMerge as cn } from 'tailwind-merge'; \ No newline at end of file +import { clsx, type ClassValue } from 'clsx'; +import { twMerge } from 'tailwind-merge'; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} \ No newline at end of file