i18n 적용
This commit is contained in:
+84
-67
@@ -4,7 +4,19 @@ import type { CSSProperties, ReactNode } from "react";
|
||||
import { Fragment, Suspense, useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { FilePlus2, Trash2, Eye, Pencil, ListChecks, Undo2, Redo2 } from "lucide-react";
|
||||
import {
|
||||
FilePlus2,
|
||||
Trash2,
|
||||
Eye,
|
||||
Pencil,
|
||||
ListChecks,
|
||||
Undo2,
|
||||
Redo2,
|
||||
ArrowUp,
|
||||
ArrowDown,
|
||||
IndentIncrease,
|
||||
IndentDecrease,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
DndContext,
|
||||
PointerSensor,
|
||||
@@ -25,6 +37,8 @@ import {
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { useEditorStore } from "@/features/editor/state/editorStore";
|
||||
import { useAppLocale } from "@/features/i18n/LocaleProvider";
|
||||
import { getEditorMessages } from "@/features/i18n/messages/editor";
|
||||
import type {
|
||||
Block,
|
||||
TextBlockProps,
|
||||
@@ -81,6 +95,8 @@ export default function EditorPage() {
|
||||
function EditorPageInner() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const locale = useAppLocale();
|
||||
const m = getEditorMessages(locale);
|
||||
const blocks = useEditorStore((state) => state.blocks);
|
||||
const selectedBlockId = useEditorStore((state) => state.selectedBlockId);
|
||||
const selectedListItemId = useEditorStore((state) => (state as any).selectedListItemId as string | null | undefined);
|
||||
@@ -454,7 +470,7 @@ function EditorPageInner() {
|
||||
const handleSaveProject = async () => {
|
||||
const slug = (projectConfig.slug ?? "").trim();
|
||||
if (!slug) {
|
||||
setProjectMessage("프로젝트 주소를 입력해 주세요.");
|
||||
setProjectMessage(m.projectMessageSlugRequired);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -510,26 +526,26 @@ function EditorPageInner() {
|
||||
}
|
||||
}
|
||||
|
||||
setProjectMessage("프로젝트 저장에 실패했습니다.");
|
||||
setProjectMessage(m.projectMessageSaveFailed);
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
setProjectMessage(`프로젝트가 저장되었습니다: ${data.slug}`);
|
||||
setProjectMessage(`${m.projectMessageSaveSuccessPrefix}${data.slug}`);
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
window.location.href = "/projects";
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
setProjectMessage("프로젝트 저장 중 오류가 발생했습니다.");
|
||||
setProjectMessage(m.projectMessageSaveError);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLoadProject = async () => {
|
||||
const slug = (projectConfig.slug ?? "").trim();
|
||||
if (!slug) {
|
||||
setProjectMessage("불러올 주소를 입력해 주세요.");
|
||||
setProjectMessage(m.projectMessageLoadSlugRequired);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -552,7 +568,7 @@ function EditorPageInner() {
|
||||
// 다른 프로젝트(slug)를 불러왔으므로 이전 history/future 를 초기화한다.
|
||||
resetHistory();
|
||||
|
||||
setProjectMessage(`로컬에서 프로젝트를 불러왔습니다: ${slug}`);
|
||||
setProjectMessage(`${m.projectMessageLoadLocalSuccessPrefix}${slug}`);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
@@ -564,7 +580,7 @@ function EditorPageInner() {
|
||||
// 2) 로컬에 없으면 서버에서 조회
|
||||
const response = await fetch(`/api/projects/${slug}`);
|
||||
if (!response.ok) {
|
||||
setProjectMessage("프로젝트를 불러오지 못했습니다.");
|
||||
setProjectMessage(m.projectMessageLoadFailed);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -580,28 +596,26 @@ function EditorPageInner() {
|
||||
// 서버에서 다른 프로젝트를 불러온 뒤에는 이전 history/future 를 모두 비운다.
|
||||
resetHistory();
|
||||
|
||||
setProjectMessage(`프로젝트를 불러왔습니다: ${slug}`);
|
||||
setProjectMessage(`${m.projectMessageLoadServerSuccessPrefix}${slug}`);
|
||||
} else {
|
||||
setProjectMessage("프로젝트 데이터 형식이 올바르지 않습니다.");
|
||||
setProjectMessage(m.projectMessageInvalidFormat);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
setProjectMessage("프로젝트 불러오기 중 오류가 발생했습니다.");
|
||||
setProjectMessage(m.projectMessageLoadError);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteProject = async () => {
|
||||
const slug = (projectConfig.slug ?? "").trim();
|
||||
if (!slug) {
|
||||
setProjectMessage("삭제할 프로젝트 주소가 없습니다.");
|
||||
setProjectMessage(m.projectMessageDeleteSlugMissing);
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
const confirmed = window.confirm(
|
||||
"현재 프로젝트를 삭제할까요? 삭제 후에는 되돌릴 수 없습니다. (로컬 저장 및 자동저장 데이터도 함께 삭제됩니다.)",
|
||||
);
|
||||
const confirmed = window.confirm(m.confirmDeleteProject);
|
||||
if (!confirmed) return;
|
||||
|
||||
try {
|
||||
@@ -610,7 +624,7 @@ function EditorPageInner() {
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
setProjectMessage("프로젝트 삭제에 실패했습니다.");
|
||||
setProjectMessage(m.projectMessageDeleteFailed);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -618,12 +632,12 @@ function EditorPageInner() {
|
||||
window.localStorage.removeItem(`pb:project:${slug}`);
|
||||
window.localStorage.removeItem(`pb:autosave:${slug}`);
|
||||
|
||||
setProjectMessage("프로젝트가 삭제되었습니다.");
|
||||
setProjectMessage(m.projectMessageDeleteSuccess);
|
||||
|
||||
window.location.href = "/projects";
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
setProjectMessage("프로젝트 삭제 중 오류가 발생했습니다.");
|
||||
setProjectMessage(m.projectMessageDeleteError);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1063,7 +1077,11 @@ function EditorPageInner() {
|
||||
selectListItem={selectListItem}
|
||||
allBlocks={blocks}
|
||||
renderBlocks={renderBlocks}
|
||||
updateBlock={updateBlock}
|
||||
updateBlock={(id, partial) => updateBlock(id, partial as any)}
|
||||
listItemToolbarMoveUpLabel={m.listItemToolbarMoveUpLabel}
|
||||
listItemToolbarMoveDownLabel={m.listItemToolbarMoveDownLabel}
|
||||
listItemToolbarIndentLabel={m.listItemToolbarIndentLabel}
|
||||
listItemToolbarOutdentLabel={m.listItemToolbarOutdentLabel}
|
||||
/>
|
||||
</Fragment>
|
||||
);
|
||||
@@ -1077,8 +1095,8 @@ function EditorPageInner() {
|
||||
<Pencil className="w-4 h-4 text-sky-400" aria-hidden="true" />
|
||||
</span>
|
||||
<div className="flex flex-col">
|
||||
<h1 className="text-xl font-semibold">Page Editor</h1>
|
||||
<p className="text-xs text-slate-400">빌더 MVP용 에디터 페이지 골격</p>
|
||||
<h1 className="text-xl font-semibold">{m.headerTitle}</h1>
|
||||
<p className="text-xs text-slate-400">{m.headerDescription}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
@@ -1087,14 +1105,14 @@ function EditorPageInner() {
|
||||
className="inline-flex items-center gap-1 rounded border border-slate-700 bg-slate-900 px-3 py-1 text-slate-100 hover:bg-slate-800"
|
||||
>
|
||||
<ListChecks className="w-3 h-3" aria-hidden="true" />
|
||||
<span>프로젝트 목록</span>
|
||||
<span>{m.navProjects}</span>
|
||||
</Link>
|
||||
<Link
|
||||
href={previewHref}
|
||||
className="inline-flex items-center gap-1 rounded border border-slate-700 bg-slate-900 px-3 py-1 text-slate-100 hover:bg-slate-800"
|
||||
>
|
||||
<Eye className="w-3 h-3" aria-hidden="true" />
|
||||
<span>프리뷰 열기</span>
|
||||
<span>{m.navPreview}</span>
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
@@ -1103,7 +1121,7 @@ function EditorPageInner() {
|
||||
disabled={!canUndo}
|
||||
>
|
||||
<Undo2 className="w-3 h-3" aria-hidden="true" />
|
||||
<span>실행 취소</span>
|
||||
<span>{m.undoLabel}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -1112,7 +1130,7 @@ function EditorPageInner() {
|
||||
disabled={!canRedo}
|
||||
>
|
||||
<Redo2 className="w-3 h-3" aria-hidden="true" />
|
||||
<span>다시 실행</span>
|
||||
<span>{m.redoLabel}</span>
|
||||
</button>
|
||||
<div className="relative">
|
||||
<button
|
||||
@@ -1121,7 +1139,7 @@ function EditorPageInner() {
|
||||
onClick={() => setMenuOpen((prev) => !prev)}
|
||||
>
|
||||
<FilePlus2 className="w-3 h-3" aria-hidden="true" />
|
||||
<span>메뉴</span>
|
||||
<span>{m.menuLabel}</span>
|
||||
<span className="text-[9px]">▼</span>
|
||||
</button>
|
||||
{menuOpen && (
|
||||
@@ -1136,7 +1154,7 @@ function EditorPageInner() {
|
||||
>
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<FilePlus2 className="w-3 h-3" aria-hidden="true" />
|
||||
<span>프로젝트 저장/불러오기</span>
|
||||
<span>{m.menuProjectSaveLoad}</span>
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
@@ -1149,7 +1167,7 @@ function EditorPageInner() {
|
||||
>
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<FilePlus2 className="w-3 h-3" aria-hidden="true" />
|
||||
<span>JSON 내보내기/불러오기</span>
|
||||
<span>{m.menuJsonImportExport}</span>
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
@@ -1162,22 +1180,7 @@ function EditorPageInner() {
|
||||
>
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<FilePlus2 className="w-3 h-3" aria-hidden="true" />
|
||||
<span>페이지 파일로 내보내기 (ZIP)</span>
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="w-full px-3 py-2 text-left hover:bg-red-900/60 text-red-100 border-t border-slate-800"
|
||||
onClick={() => {
|
||||
setMenuOpen(false);
|
||||
if (window.confirm("캔버스를 모두 초기화할까요? 이 작업은 실행 취소로만 되돌릴 수 있습니다.")) {
|
||||
handleClearCanvas();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<Trash2 className="w-3 h-3" aria-hidden="true" />
|
||||
<span>캔버스 초기화</span>
|
||||
<span>{m.menuExportZip}</span>
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
@@ -1190,7 +1193,7 @@ function EditorPageInner() {
|
||||
>
|
||||
<span className="inline-flex items-center gap-2 text-red-500">
|
||||
<Trash2 className="w-3 h-3" aria-hidden="true" />
|
||||
<span>프로젝트 삭제</span>
|
||||
<span>{m.menuDeleteProject}</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -1235,7 +1238,7 @@ function EditorPageInner() {
|
||||
<div className="fixed inset-0 z-30 flex items-center justify-center bg-black/60">
|
||||
<div className="w-full max-w-md rounded-lg border border-slate-200 bg-white p-4 text-xs text-slate-900 shadow-xl dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-medium">프로젝트 저장 / 불러오기</h3>
|
||||
<h3 className="text-sm font-medium">{m.modalProjectTitle}</h3>
|
||||
<button
|
||||
type="button"
|
||||
className="text-slate-400 hover:text-slate-700 text-sm dark:hover:text-slate-100"
|
||||
@@ -1246,7 +1249,7 @@ function EditorPageInner() {
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-slate-600 dark:text-slate-400">프로젝트 제목</span>
|
||||
<span className="text-slate-600 dark:text-slate-400">{m.modalProjectTitleLabel}</span>
|
||||
<input
|
||||
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-xs text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
|
||||
value={projectConfig.title ?? ""}
|
||||
@@ -1254,7 +1257,7 @@ function EditorPageInner() {
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-slate-600 dark:text-slate-400">프로젝트 주소 (예: my-landing)</span>
|
||||
<span className="text-slate-600 dark:text-slate-400">{m.modalProjectSlugLabel}</span>
|
||||
<input
|
||||
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-xs text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
|
||||
value={projectConfig.slug ?? ""}
|
||||
@@ -1267,14 +1270,14 @@ function EditorPageInner() {
|
||||
className="flex-1 rounded border border-sky-500 bg-sky-50 px-2 py-1 text-sky-700 hover:bg-sky-100 dark:border-sky-700 dark:bg-sky-950 dark:text-sky-100 dark:hover:bg-sky-900"
|
||||
onClick={handleSaveProject}
|
||||
>
|
||||
저장 (로컬 + 서버)
|
||||
{m.modalSaveButton}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="flex-1 rounded border border-slate-300 bg-white px-2 py-1 text-slate-900 hover:bg-slate-100 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100 dark:hover:bg-slate-800"
|
||||
onClick={handleLoadProject}
|
||||
>
|
||||
불러오기
|
||||
{m.modalLoadButton}
|
||||
</button>
|
||||
</div>
|
||||
{projectMessage && (
|
||||
@@ -1292,10 +1295,8 @@ function EditorPageInner() {
|
||||
<div className="w-full max-w-2xl rounded-lg border border-slate-200 bg-white p-4 text-xs text-slate-900 shadow-xl dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-medium">
|
||||
JSON Export / Import
|
||||
<span className="text-xs text-slate-500 dark:text-slate-400">
|
||||
* 에디터 내용을 가져오거나 내보낼 수 있습니다.
|
||||
</span>
|
||||
{m.jsonModalTitle}
|
||||
<span className="text-xs text-slate-500 dark:text-slate-400">{m.jsonModalSubtitle}</span>
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
@@ -1312,19 +1313,19 @@ function EditorPageInner() {
|
||||
className="flex-1 rounded border border-slate-300 bg-white px-2 py-1 text-slate-900 hover:bg-slate-100 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100 dark:hover:bg-slate-800"
|
||||
onClick={handleExportJson}
|
||||
>
|
||||
JSON 내보내기
|
||||
{m.jsonExportButton}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded border border-red-300 bg-white px-2 py-1 text-red-700 hover:bg-red-50 dark:border-red-700 dark:bg-red-950 dark:text-red-100 dark:hover:bg-red-900"
|
||||
onClick={handleClearCanvas}
|
||||
>
|
||||
캔버스 초기화
|
||||
{m.jsonClearCanvasButton}
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-slate-600 dark:text-slate-400">에디터 상태 JSON</span>
|
||||
<span className="text-slate-600 dark:text-slate-400">{m.jsonEditorStateLabel}</span>
|
||||
<textarea
|
||||
className="w-full h-48 rounded border border-slate-300 bg-white px-2 py-1 text-[10px] font-mono text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
|
||||
value={exportJson}
|
||||
@@ -1332,7 +1333,7 @@ function EditorPageInner() {
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-slate-600 dark:text-slate-400">JSON에서 불러오기</span>
|
||||
<span className="text-slate-600 dark:text-slate-400">{m.jsonImportLabel}</span>
|
||||
<textarea
|
||||
className="w-full h-48 rounded border border-slate-300 bg-white px-2 py-1 text-[10px] font-mono text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
|
||||
value={importJson}
|
||||
@@ -1343,7 +1344,7 @@ function EditorPageInner() {
|
||||
className="mt-1 w-full rounded border border-slate-300 bg-white px-2 py-1 text-slate-900 hover:bg-slate-100 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100 dark:hover:bg-slate-800"
|
||||
onClick={handleApplyImportJson}
|
||||
>
|
||||
JSON 적용하기
|
||||
{m.jsonApplyButton}
|
||||
</button>
|
||||
</label>
|
||||
</div>
|
||||
@@ -1418,6 +1419,10 @@ interface SortableEditorBlockProps {
|
||||
allBlocks: Block[];
|
||||
renderBlocks: (targetBlocks: Block[]) => ReactNode;
|
||||
updateBlock: (id: string, partial: Partial<TextBlockProps & ButtonBlockProps & FormBlockProps>) => void;
|
||||
listItemToolbarMoveUpLabel: string;
|
||||
listItemToolbarMoveDownLabel: string;
|
||||
listItemToolbarIndentLabel: string;
|
||||
listItemToolbarOutdentLabel: string;
|
||||
}
|
||||
|
||||
function SortableEditorBlock({
|
||||
@@ -1438,6 +1443,10 @@ function SortableEditorBlock({
|
||||
allBlocks,
|
||||
renderBlocks,
|
||||
updateBlock,
|
||||
listItemToolbarMoveUpLabel,
|
||||
listItemToolbarMoveDownLabel,
|
||||
listItemToolbarIndentLabel,
|
||||
listItemToolbarOutdentLabel,
|
||||
}: SortableEditorBlockProps) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition } = useSortable({
|
||||
id: block.id,
|
||||
@@ -2172,7 +2181,9 @@ function SortableEditorBlock({
|
||||
<span className="ml-2 inline-flex items-center gap-1 text-[10px] text-slate-900 dark:text-slate-300 align-middle">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded border border-slate-700 px-1 py-0.5 hover:bg-slate-800"
|
||||
className="rounded border border-slate-700 px-1 py-0.5 hover:bg-slate-800 inline-flex items-center justify-center"
|
||||
aria-label={listItemToolbarMoveUpLabel}
|
||||
title={listItemToolbarMoveUpLabel}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
selectBlock(block.id);
|
||||
@@ -2180,11 +2191,13 @@ function SortableEditorBlock({
|
||||
(useEditorStore.getState() as any).moveSelectedListItemUp(block.id);
|
||||
}}
|
||||
>
|
||||
위
|
||||
<ArrowUp className="w-3 h-3" aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded border border-slate-700 px-1 py-0.5 hover:bg-slate-800"
|
||||
className="rounded border border-slate-700 px-1 py-0.5 hover:bg-slate-800 inline-flex items-center justify-center"
|
||||
aria-label={listItemToolbarMoveDownLabel}
|
||||
title={listItemToolbarMoveDownLabel}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
selectBlock(block.id);
|
||||
@@ -2192,11 +2205,13 @@ function SortableEditorBlock({
|
||||
(useEditorStore.getState() as any).moveSelectedListItemDown(block.id);
|
||||
}}
|
||||
>
|
||||
아래
|
||||
<ArrowDown className="w-3 h-3" aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded border border-slate-700 px-1 py-0.5 hover:bg-slate-800"
|
||||
className="rounded border border-slate-700 px-1 py-0.5 hover:bg-slate-800 inline-flex items-center justify-center"
|
||||
aria-label={listItemToolbarIndentLabel}
|
||||
title={listItemToolbarIndentLabel}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
selectBlock(block.id);
|
||||
@@ -2204,11 +2219,13 @@ function SortableEditorBlock({
|
||||
(useEditorStore.getState() as any).indentSelectedListItem(block.id);
|
||||
}}
|
||||
>
|
||||
들여쓰기
|
||||
<IndentIncrease className="w-3 h-3" aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded border border-slate-700 px-1 py-0.5 hover:bg-slate-800"
|
||||
className="rounded border border-slate-700 px-1 py-0.5 hover:bg-slate-800 inline-flex items-center justify-center"
|
||||
aria-label={listItemToolbarOutdentLabel}
|
||||
title={listItemToolbarOutdentLabel}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
selectBlock(block.id);
|
||||
@@ -2216,7 +2233,7 @@ function SortableEditorBlock({
|
||||
(useEditorStore.getState() as any).outdentSelectedListItem(block.id);
|
||||
}}
|
||||
>
|
||||
내어쓰기
|
||||
<IndentDecrease className="w-3 h-3" aria-hidden="true" />
|
||||
</button>
|
||||
</span>
|
||||
{node.children && node.children.length > 0 && renderListNodes(node.children, level + 1)}
|
||||
|
||||
Reference in New Issue
Block a user