i18n 적용
CI / test (push) Failing after 11m12s
CI / e2e (push) Has been cancelled
CI / pr_and_merge (push) Has been cancelled

This commit is contained in:
2025-12-10 15:56:51 +09:00
parent 73e9bc6a1c
commit f71207aeb5
127 changed files with 7346 additions and 2079 deletions
+84 -67
View File
@@ -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)}