refactor: repartition server-side and client-side code

This commit is contained in:
LIlGG
2025-10-11 18:26:07 +08:00
parent 7acc4949fb
commit e9b573a276
309 changed files with 631 additions and 962 deletions

View File

@@ -0,0 +1,453 @@
import { useStore } from '@nanostores/react';
import * as ContextMenu from '@radix-ui/react-context-menu';
import classNames from 'classnames';
import { type Change, diffLines } from 'diff';
import { memo, type ReactNode, useCallback, useEffect, useMemo, useState } from 'react';
import { toast } from 'sonner';
import { Button } from '~/.client/components/ui/Button';
import { ConfirmationDialog, Dialog, DialogDescription, DialogRoot, DialogTitle } from '~/.client/components/ui/Dialog';
import { webBuilderStore } from '~/.client/stores/web-builder';
import type { PageMap } from '~/types/pages';
import { createScopedLogger, renderLogger } from '~/utils/logger';
const logger = createScopedLogger('PageTree');
interface PageNode {
id: string;
name: string;
title: string;
}
interface Props {
pages?: PageMap;
selectedPage?: string;
onPageSelect?: (pageName: string) => void;
unsavedPages?: Set<string>;
className?: string;
}
export const PageTree = memo(({ pages = {}, onPageSelect, selectedPage, className, unsavedPages }: Props) => {
renderLogger.trace('PageTree');
const pageList = useMemo(() => {
return buildPageList(pages);
}, [pages]);
return (
<div
className={classNames(
'text-sm rounded-md border border-upage-elements-borderColor',
className,
'overflow-y-auto modern-scrollbar',
)}
>
<div className="p-2 border-b border-upage-elements-borderColor bg-upage-elements-background-depth-1">
<h3 className="font-medium text-upage-elements-textPrimary"></h3>
</div>
<div className="p-1">
{pageList.map((page) => (
<Page
key={page.id}
selected={selectedPage === page.name}
page={page}
unsavedChanges={unsavedPages instanceof Set && unsavedPages.has(page.name)}
onClick={() => {
onPageSelect?.(page.name);
}}
/>
))}
</div>
</div>
);
});
export default PageTree;
function ContextMenuItem({ onSelect, children }: { onSelect?: () => void; children: ReactNode }) {
return (
<ContextMenu.Item
onSelect={onSelect}
className="flex items-center w-full px-3 py-2 outline-0 text-sm cursor-pointer rounded-md transition-colors duration-200 hover:bg-upage-elements-item-backgroundActive hover:text-upage-elements-item-contentActive"
>
{children}
</ContextMenu.Item>
);
}
function PageContextMenu({ pageName, children }: { pageName: string; children: ReactNode }) {
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [isDragging, setIsDragging] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(true);
}, []);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
}, []);
const handleDrop = useCallback(async (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
}, []);
const handleCreatePage = async (pageName: string, pageTitle: string) => {
setIsLoading(true);
try {
const success = await webBuilderStore.createPage(pageName, pageTitle);
if (success) {
toast.success('页面创建成功');
} else {
toast.error('页面创建失败');
}
} catch (error) {
toast.error('页面创建失败');
logger.error(error);
} finally {
setIsLoading(false);
}
};
const handleDelete = async () => {
setIsLoading(true);
try {
const success = await webBuilderStore.deletePage(pageName);
if (success) {
toast.success(`页面删除成功`);
setIsDeleteDialogOpen(false);
} else {
toast.error(`页面删除失败`);
}
} catch (error) {
toast.error(`页面删除失败`);
logger.error(error);
} finally {
setIsLoading(false);
}
};
return (
<>
<ContextMenu.Root>
<ContextMenu.Trigger>
<div
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
className={classNames('relative', {
'bg-upage-elements-background-depth-2 border border-dashed border-upage-elements-item-contentAccent rounded-md':
isDragging,
})}
>
{children}
</div>
</ContextMenu.Trigger>
<ContextMenu.Portal>
<ContextMenu.Content
style={{ zIndex: 998 }}
className="min-w-56 p-1 border border-upage-elements-borderColor rounded-md z-context-menu bg-upage-elements-background-depth-1 dark:bg-upage-elements-background-depth-2 data-[state=open]:animate-in animate-duration-100 data-[state=open]:fade-in-0 data-[state=open]:zoom-in-98 shadow-lg"
>
<ContextMenu.Group className="mb-1">
<ContextMenuItem onSelect={() => setIsCreateDialogOpen(true)}>
<div className="flex items-center gap-2 text-upage-elements-textPrimary">
<div className="i-ph:file-plus text-green-500" />
</div>
</ContextMenuItem>
</ContextMenu.Group>
<ContextMenu.Separator className="h-px bg-upage-elements-borderColor my-1" />
<ContextMenu.Group className="mt-1">
<ContextMenuItem onSelect={() => setIsDeleteDialogOpen(true)}>
<div className="flex items-center gap-2 text-red-500">
<div className="i-ph:trash" />
</div>
</ContextMenuItem>
</ContextMenu.Group>
</ContextMenu.Content>
</ContextMenu.Portal>
</ContextMenu.Root>
<CreatePageDialog
isOpen={isCreateDialogOpen}
onClose={() => setIsCreateDialogOpen(false)}
onConfirm={handleCreatePage}
/>
<ConfirmationDialog
isOpen={isDeleteDialogOpen}
onClose={() => setIsDeleteDialogOpen(false)}
onConfirm={handleDelete}
title="删除页面"
description={`确定要删除页面 "${pageName}" 吗?此操作不可撤销。`}
confirmLabel="删除"
cancelLabel="取消"
variant="destructive"
isLoading={isLoading}
/>
</>
);
}
interface PageProps {
page: PageNode;
selected: boolean;
unsavedChanges?: boolean;
onClick: () => void;
}
function formatSaveTime(timestamp: number): string {
const saveDate = new Date(timestamp);
return `${saveDate.getHours().toString().padStart(2, '0')}:${saveDate.getMinutes().toString().padStart(2, '0')}`;
}
function Page({ page, onClick, selected, unsavedChanges = false }: PageProps) {
const pageHistory = useStore(webBuilderStore.pagesStore.pageHistory);
const { name, title } = page;
const pageModifications = pageHistory[name];
const lastSavedTimes = useStore(webBuilderStore.editorStore.documentLastSaved);
const lastSavedTime = lastSavedTimes[name];
const { additions, deletions } = useMemo(() => {
if (!pageModifications?.originalContent) {
return { additions: 0, deletions: 0 };
}
const normalizedOriginal = pageModifications.originalContent.replace(/\r\n/g, '\n');
const normalizedCurrent =
pageModifications.versions[pageModifications.versions.length - 1]?.content.replace(/\r\n/g, '\n') || '';
if (normalizedOriginal === normalizedCurrent) {
return { additions: 0, deletions: 0 };
}
const changes = diffLines(normalizedOriginal, normalizedCurrent, {
newlineIsToken: false,
ignoreWhitespace: true,
});
return changes.reduce(
(acc: { additions: number; deletions: number }, change: Change) => {
if (change.added) {
acc.additions += change.value.split('\n').length;
}
if (change.removed) {
acc.deletions += change.value.split('\n').length;
}
return acc;
},
{ additions: 0, deletions: 0 },
);
}, [pageModifications]);
const showStats = additions > 0 || deletions > 0;
return (
<PageContextMenu pageName={name}>
<div
className={classNames('rounded-md transition-colors duration-200 my-1 overflow-hidden', {
'bg-upage-elements-background-depth-1 hover:bg-upage-elements-item-backgroundActive': !selected,
'bg-upage-elements-item-backgroundAccent': selected,
})}
>
<NodeButton
className={classNames('group', {
'text-upage-elements-item-contentDefault dark:bg-gray-800': !selected,
'text-upage-elements-item-contentAccent dark:bg-gray-800': selected,
})}
iconClasses={classNames('i-ph:file-duotone scale-98', {
'group-hover:text-upage-elements-item-contentActive': !selected,
})}
onClick={onClick}
>
<div className="flex flex-col w-full">
<div
className={classNames('flex items-center', {
'group-hover:text-upage-elements-item-contentActive': !selected,
})}
>
<div className="flex-1 font-medium truncate pr-2">{title || name}</div>
<div className="flex items-center gap-1">
{showStats && (
<div className="flex items-center gap-1 text-xs">
{additions > 0 && <span className="text-green-500">+{additions}</span>}
{deletions > 0 && <span className="text-red-500">-{deletions}</span>}
</div>
)}
{unsavedChanges && <span className="i-ph:circle-fill scale-68 shrink-0 text-orange-500" />}
</div>
</div>
<div className="flex justify-between items-center text-xs text-upage-elements-textTertiary mt-0.5">
<span className="truncate opacity-80">{name}</span>
{lastSavedTime && !unsavedChanges && (
<span className="flex items-center text-xs whitespace-nowrap">
<span className="i-ph:clock-clockwise size-4 mr-1 scale-90 inline-block" />
{formatSaveTime(lastSavedTime)}
</span>
)}
</div>
</div>
</NodeButton>
</div>
</PageContextMenu>
);
}
interface ButtonProps {
iconClasses: string;
children: ReactNode;
className?: string;
onClick?: () => void;
}
function NodeButton({ iconClasses, onClick, className, children }: ButtonProps) {
return (
<button
className={classNames('flex items-start gap-1.5 w-full px-3 py-2 border-2 border-transparent', className)}
onClick={() => onClick?.()}
>
<div className={classNames('scale-120 shrink-0 mt-0.5', iconClasses)}></div>
<div className="w-full text-left">{children}</div>
</button>
);
}
function buildPageList(pages: PageMap): PageNode[] {
const nodeList: PageNode[] = [];
const pageList = Object.values(pages);
for (const page of pageList) {
if (!page) {
continue;
}
nodeList.push({
id: page.name,
name: page.name,
title: page.title ?? '未命名页面',
});
}
return nodeList.sort((a, b) => compareNodes(a, b));
}
function compareNodes(a: PageNode, b: PageNode): number {
return a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' });
}
interface CreatePageDialogProps {
isOpen: boolean;
onClose: () => void;
onConfirm: (pageName: string, pageTitle: string) => void;
}
function CreatePageDialog({ isOpen, onClose, onConfirm }: CreatePageDialogProps) {
const [pageName, setPageName] = useState('');
const [pageTitle, setPageTitle] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (isOpen) {
setPageName('');
setPageTitle('');
setError(null);
}
}, [isOpen]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!pageName.trim()) {
setError('页面名称不能为空');
return;
}
if (!/^[a-zA-Z0-9_-]+$/.test(pageName)) {
setError('页面名称只能包含字母、数字、连字符和下划线');
return;
}
setIsLoading(true);
try {
await onConfirm(pageName.trim(), pageTitle.trim() || '未命名页面');
onClose();
} finally {
setIsLoading(false);
}
};
return (
<DialogRoot open={isOpen} onOpenChange={onClose}>
<Dialog showCloseButton={true} onClose={onClose}>
<div className="p-6 bg-white dark:bg-gray-950 relative z-10">
<DialogTitle></DialogTitle>
<DialogDescription className="mb-4"></DialogDescription>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<label htmlFor="pageName" className="block text-sm font-medium text-upage-elements-textPrimary">
<span className="text-red-500">*</span>
</label>
<input
id="pageName"
type="text"
value={pageName}
onChange={(e) => setPageName(e.target.value)}
className="w-full px-3 py-2 border border-upage-elements-borderColor rounded-md bg-upage-elements-background-depth-1 text-upage-elements-textPrimary focus:outline-none focus:ring-2 focus:ring-upage-elements-item-contentAccent"
placeholder="例如about"
autoFocus
/>
<p className="text-xs text-upage-elements-textTertiary">线</p>
</div>
<div className="space-y-2">
<label htmlFor="pageTitle" className="block text-sm font-medium text-upage-elements-textPrimary">
</label>
<input
id="pageTitle"
type="text"
value={pageTitle}
onChange={(e) => setPageTitle(e.target.value)}
className="w-full px-3 py-2 border border-upage-elements-borderColor rounded-md bg-upage-elements-background-depth-1 text-upage-elements-textPrimary focus:outline-none focus:ring-2 focus:ring-upage-elements-item-contentAccent"
placeholder="例如:关于我们"
/>
<p className="text-xs text-upage-elements-textTertiary">使"未命名页面"</p>
</div>
{error && <div className="text-sm text-red-500 font-medium">{error}</div>}
<div className="flex justify-end space-x-2 pt-2">
<Button variant="outline" onClick={onClose} type="button" disabled={isLoading}>
</Button>
<Button type="submit" disabled={isLoading || !pageName.trim()}>
{isLoading ? (
<>
<div className="i-ph-spinner-gap-bold animate-spin size-4 mr-2" />
...
</>
) : (
'创建页面'
)}
</Button>
</div>
</form>
</div>
</Dialog>
</DialogRoot>
);
}