Files
upage-git/app/components/webbuilder/PageTree.tsx
2025-09-29 12:50:15 +08:00

454 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 '~/components/ui/Button';
import { ConfirmationDialog, Dialog, DialogDescription, DialogRoot, DialogTitle } from '~/components/ui/Dialog';
import type { PageMap } from '~/lib/stores/pages';
import { webBuilderStore } from '~/lib/stores/web-builder';
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>
);
}