mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-11 09:46:37 +08:00
refactor(error-pages): preset templates with OpenFlare branding
This commit is contained in:
@@ -0,0 +1,70 @@
|
||||
'use client';
|
||||
|
||||
import type { ReactNode } from 'react';
|
||||
import { FileWarning } from 'lucide-react';
|
||||
import type { UseQueryResult } from '@tanstack/react-query';
|
||||
|
||||
import { useAuth } from '@/components/providers/auth-provider';
|
||||
import { EmptyStateWithBorder } from '@/components/layout/empty';
|
||||
import { ErrorInline } from '@/components/layout/error';
|
||||
import { LoadingStateWithBorder } from '@/components/layout/loading';
|
||||
|
||||
type ErrorPageGateProps = {
|
||||
optionsQuery: UseQueryResult<Array<{ key: string; value: string }>, Error>;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export function ErrorPageGate({ optionsQuery, children }: ErrorPageGateProps) {
|
||||
const { user, loading: authLoading } = useAuth();
|
||||
|
||||
if (authLoading) {
|
||||
return (
|
||||
<div className='py-6 px-1'>
|
||||
<LoadingStateWithBorder
|
||||
icon={FileWarning}
|
||||
description='加载权限信息...'
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user?.is_admin) {
|
||||
return (
|
||||
<div className='py-6 px-1'>
|
||||
<EmptyStateWithBorder
|
||||
icon={FileWarning}
|
||||
title='权限不足'
|
||||
description='只有管理员可以访问源站错误页设置。'
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (optionsQuery.isLoading) {
|
||||
return (
|
||||
<div className='py-6 px-1'>
|
||||
<LoadingStateWithBorder
|
||||
icon={FileWarning}
|
||||
description='加载错误页配置...'
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (optionsQuery.isError) {
|
||||
return (
|
||||
<div className='py-6 px-1'>
|
||||
<ErrorInline
|
||||
message={
|
||||
optionsQuery.error instanceof Error
|
||||
? optionsQuery.error.message
|
||||
: '加载失败'
|
||||
}
|
||||
onRetry={() => void optionsQuery.refetch()}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
'use client';
|
||||
|
||||
import { useRef, useState } from 'react';
|
||||
import { useTheme } from 'next-themes';
|
||||
import CodeMirror from '@uiw/react-codemirror';
|
||||
import { html } from '@codemirror/lang-html';
|
||||
import { Expand, Terminal } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
ORIGIN_ERROR_PAGE_HTML_MAX_BYTES,
|
||||
previewOriginErrorPageHTML,
|
||||
} from '@/lib/openflare/default-origin-error-page-html';
|
||||
|
||||
type HtmlEditorWorkspaceProps = {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
toolbarRight?: React.ReactNode;
|
||||
};
|
||||
|
||||
/**
|
||||
* SQL 查询终端同款工作区:整体边框 + 工具栏 + 可拖拽分隔;
|
||||
* 本页为左代码 / 右实时预览横向布局。
|
||||
*/
|
||||
export function HtmlEditorWorkspace({
|
||||
value,
|
||||
onChange,
|
||||
toolbarRight,
|
||||
}: HtmlEditorWorkspaceProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [editorWidthPercent, setEditorWidthPercent] = useState(48);
|
||||
const { resolvedTheme } = useTheme();
|
||||
const cmTheme = resolvedTheme === 'dark' ? 'dark' : 'light';
|
||||
const previewSrcDoc = previewOriginErrorPageHTML(value);
|
||||
const htmlBytes = new TextEncoder().encode(value).length;
|
||||
|
||||
const handleMouseDown = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
const rect = container.getBoundingClientRect();
|
||||
|
||||
const handleMouseMove = (moveEvent: MouseEvent) => {
|
||||
const x = moveEvent.clientX - rect.left;
|
||||
const pct = (x / rect.width) * 100;
|
||||
if (pct > 25 && pct < 75) {
|
||||
setEditorWidthPercent(pct);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className='w-full border border-border/40 bg-card/60 backdrop-blur-md rounded-lg overflow-hidden flex flex-col shadow-sm h-[calc(100vh-160px)] min-h-[520px]'
|
||||
>
|
||||
<div className='flex items-center justify-between px-4 py-2 border-b bg-muted/40 shrink-0 gap-4 flex-wrap'>
|
||||
<div className='flex items-center gap-2 min-w-0'>
|
||||
<Terminal className='size-4 text-primary shrink-0' />
|
||||
<span className='text-xs font-semibold'>HTML 编辑器</span>
|
||||
<span className='text-[11px] text-muted-foreground font-mono truncate'>
|
||||
{htmlBytes} / {ORIGIN_ERROR_PAGE_HTML_MAX_BYTES} 字节
|
||||
{value.trim() === '' ? ' · 空则使用内置默认' : ''}
|
||||
</span>
|
||||
</div>
|
||||
<div className='flex items-center gap-1.5 flex-wrap'>{toolbarRight}</div>
|
||||
</div>
|
||||
|
||||
<div className='flex flex-1 min-h-0 overflow-hidden'>
|
||||
<div
|
||||
style={{ width: `${editorWidthPercent}%` }}
|
||||
className='h-full overflow-hidden relative min-w-[200px] bg-background border-r border-border/40'
|
||||
>
|
||||
<CodeMirror
|
||||
value={value}
|
||||
height='100%'
|
||||
extensions={[html()]}
|
||||
theme={cmTheme}
|
||||
onChange={onChange}
|
||||
className='h-full text-xs font-mono'
|
||||
basicSetup={{
|
||||
lineNumbers: true,
|
||||
foldGutter: true,
|
||||
dropCursor: true,
|
||||
allowMultipleSelections: false,
|
||||
indentOnInput: true,
|
||||
}}
|
||||
placeholder='留空使用内置默认模板…'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
onMouseDown={handleMouseDown}
|
||||
className='w-1.5 bg-border/60 hover:bg-primary/50 cursor-col-resize transition-colors flex items-center justify-center shrink-0 select-none z-10'
|
||||
title='拖动调整宽度'
|
||||
>
|
||||
<div className='h-8 w-1 rounded bg-muted-foreground/30' />
|
||||
</div>
|
||||
|
||||
<div className='flex-1 min-w-[200px] flex flex-col overflow-hidden bg-muted/10'>
|
||||
<div className='flex items-center justify-between px-4 py-1.5 border-b bg-muted/20 shrink-0 text-[11px] text-muted-foreground font-mono gap-2'>
|
||||
<span className='font-semibold'>实时预览</span>
|
||||
<div className='flex items-center gap-2'>
|
||||
<span className='hidden sm:inline'>
|
||||
{'{{status}}'}→502 · {'{{host}}'}→example.com
|
||||
</span>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
className='h-6 px-2 text-[11px]'
|
||||
asChild
|
||||
>
|
||||
<Link href='/error-pages/preview'>
|
||||
<Expand className='size-3' />
|
||||
真实预览
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex-1 min-h-0 overflow-hidden bg-background'>
|
||||
<iframe
|
||||
title='源站错误页实时预览'
|
||||
sandbox=''
|
||||
srcDoc={previewSrcDoc}
|
||||
className='h-full w-full border-0 bg-background'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { ORIGIN_ERROR_PAGE_HTML_MAX_BYTES } from '@/lib/openflare/default-origin-error-page-html';
|
||||
import {
|
||||
DEFAULT_ORIGIN_ERROR_PAGE_STATUS_TAGS,
|
||||
parseStatusCodeTagsJSON,
|
||||
validateStatusCodeTags,
|
||||
} from '@/lib/openflare/status-code-tags';
|
||||
|
||||
export const OPTIONS_QUERY_KEY = ['openflare', 'options'] as const;
|
||||
|
||||
export const KEY_ENABLED = 'origin_error_page_enabled';
|
||||
export const KEY_STATUS_CODES = 'origin_error_page_status_codes';
|
||||
export const KEY_HTML = 'origin_error_page_html';
|
||||
|
||||
export type ErrorPageFields = {
|
||||
enabled: boolean;
|
||||
statusCodes: string[];
|
||||
html: string;
|
||||
};
|
||||
|
||||
export const defaultErrorPageFields: ErrorPageFields = {
|
||||
enabled: true,
|
||||
statusCodes: [...DEFAULT_ORIGIN_ERROR_PAGE_STATUS_TAGS],
|
||||
html: '',
|
||||
};
|
||||
|
||||
export function optionsToMap(options: Array<{ key: string; value: string }>) {
|
||||
return options.reduce<Record<string, string>>((acc, option) => {
|
||||
acc[option.key] = option.value;
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
|
||||
export function mapOptionsToFields(
|
||||
optionMap: Record<string, string>,
|
||||
): ErrorPageFields {
|
||||
const enabledRaw = optionMap[KEY_ENABLED];
|
||||
return {
|
||||
enabled: enabledRaw === undefined ? true : enabledRaw === 'true',
|
||||
statusCodes: parseStatusCodeTagsJSON(optionMap[KEY_STATUS_CODES]),
|
||||
html: optionMap[KEY_HTML] ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
export function validateErrorPageFields(fields: ErrorPageFields) {
|
||||
validateStatusCodeTags(fields.statusCodes);
|
||||
validateErrorPageHTML(fields.html);
|
||||
}
|
||||
|
||||
export function validateErrorPageHTML(html: string) {
|
||||
const htmlBytes = new TextEncoder().encode(html).length;
|
||||
if (htmlBytes > ORIGIN_ERROR_PAGE_HTML_MAX_BYTES) {
|
||||
throw new Error(
|
||||
`HTML 超过最大长度限制(${ORIGIN_ERROR_PAGE_HTML_MAX_BYTES} 字节)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function invalidateErrorPageQueries(queryClient: {
|
||||
invalidateQueries: (opts: {
|
||||
queryKey: readonly unknown[];
|
||||
}) => Promise<unknown>;
|
||||
}) {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: OPTIONS_QUERY_KEY }),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['openflare', 'config-preview'],
|
||||
}),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['openflare', 'config-versions'],
|
||||
}),
|
||||
]);
|
||||
}
|
||||
Reference in New Issue
Block a user