CI: feature/i18n-browser-locale #34

Merged
jaybe merged 3 commits from feature/i18n-browser-locale into main 2025-12-10 08:11:49 +00:00
153 changed files with 7511 additions and 2296 deletions
+44 -28
View File
@@ -4,6 +4,8 @@ import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { FolderKanban, LayoutDashboard, ListChecks, SunMoon } from "lucide-react";
import { useAppLocale } from "@/features/i18n/LocaleProvider";
import { getDashboardMessages } from "@/features/i18n/messages/dashboard";
// 대시보드 페이지 (/dashboard)
// - /api/dashboard/overview 에서 요약/프로젝트별 통계를 가져와 카드 형태로 렌더링한다.
@@ -35,6 +37,8 @@ type PageStatus = "idle" | "loading" | "error";
export default function DashboardPage() {
const router = useRouter();
const locale = useAppLocale();
const t = getDashboardMessages(locale);
const [summary, setSummary] = useState<SummaryStats | null>(null);
const [projects, setProjects] = useState<ProjectStatsItem[]>([]);
@@ -59,13 +63,13 @@ export default function DashboardPage() {
if (res.status === 401) {
setStatus("error");
setErrorMessage("대시보드를 보려면 로그인이 필요합니다. 다시 로그인해 주세요.");
setErrorMessage(t.errorUnauthorized);
router.push("/login");
return;
}
setStatus("error");
setErrorMessage("대시보드 데이터를 불러오는 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.");
setErrorMessage(t.errorGeneric);
return;
}
@@ -87,9 +91,7 @@ export default function DashboardPage() {
} catch {
if (!cancelled) {
setStatus("error");
setErrorMessage(
"대시보드 데이터를 불러오는 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.",
);
setErrorMessage(t.errorGeneric);
}
}
};
@@ -141,8 +143,8 @@ export default function DashboardPage() {
<main className="min-h-screen flex flex-col bg-slate-100 text-slate-900 dark:bg-slate-950 dark:text-slate-50">
<header className="border-b border-slate-200 px-6 py-4 flex items-center justify-between bg-white/80 backdrop-blur dark:border-slate-800 dark:bg-slate-950/80">
<div>
<h1 className="text-2xl font-bold tracking-tight"></h1>
<p className="text-sm text-slate-500 mt-1 dark:text-slate-400"> .</p>
<h1 className="text-2xl font-bold tracking-tight">{t.title}</h1>
<p className="text-sm text-slate-500 mt-1 dark:text-slate-400">{t.description}</p>
</div>
<div className="flex items-center gap-3 text-sm">
<nav className="inline-flex items-center gap-1 rounded-full border border-slate-200 bg-white/80 px-1.5 py-1 shadow-sm dark:border-slate-700 dark:bg-slate-900/80">
@@ -152,21 +154,21 @@ export default function DashboardPage() {
className="inline-flex items-center gap-1 px-3 py-1.5 rounded-full text-sm font-semibold bg-sky-600 text-white shadow-sm hover:bg-sky-700"
>
<LayoutDashboard className="w-4 h-4" aria-hidden="true" />
<span></span>
<span>{t.navDashboard}</span>
</Link>
<Link
href="/projects"
className="inline-flex items-center gap-1 px-3 py-1.5 rounded-full text-sm font-medium text-slate-700 hover:bg-slate-100 hover:text-sky-700 dark:text-slate-200 dark:hover:bg-slate-800 dark:hover:text-sky-200"
>
<FolderKanban className="w-4 h-4" aria-hidden="true" />
<span> </span>
<span>{t.navProjects}</span>
</Link>
<Link
href="/projects/submissions"
className="inline-flex items-center gap-1 px-3 py-1.5 rounded-full text-sm font-medium text-emerald-700 hover:bg-emerald-50 hover:text-emerald-800 dark:text-emerald-200 dark:hover:bg-emerald-900/60 dark:hover:text-emerald-100"
>
<ListChecks className="w-4 h-4" aria-hidden="true" />
<span> </span>
<span>{t.navSubmissions}</span>
</Link>
</nav>
<button
@@ -175,7 +177,7 @@ export default function DashboardPage() {
onClick={handleToggleTheme}
>
<SunMoon className="w-4 h-4" aria-hidden="true" />
<span> </span>
<span>{t.themeToggleLabel}</span>
</button>
<div className="relative">
<button
@@ -185,7 +187,7 @@ export default function DashboardPage() {
setIsMenuOpen((prev) => !prev);
}}
>
{t.menuLabel}
</button>
{isMenuOpen && (
<div className="absolute right-0 mt-1 w-32 rounded border border-slate-200 bg-white shadow-lg z-20 dark:border-slate-700 dark:bg-slate-900/95">
@@ -197,7 +199,7 @@ export default function DashboardPage() {
void handleLogout();
}}
>
{t.logoutLabel}
</button>
</div>
)}
@@ -211,7 +213,7 @@ export default function DashboardPage() {
)}
{status === "loading" && (
<p className="text-xs text-slate-400"> ...</p>
<p className="text-xs text-slate-400">{t.loadingText}</p>
)}
{/* 상단 요약 위젯 영역 */}
@@ -220,7 +222,9 @@ export default function DashboardPage() {
data-testid="dashboard-summary-total-projects"
className="rounded-xl border border-slate-200 bg-white/80 px-4 py-3 flex flex-col gap-1 shadow-sm dark:border-slate-800 dark:bg-slate-900/70"
>
<span className="text-[11px] font-medium text-slate-500 dark:text-slate-400"> </span>
<span className="text-[11px] font-medium text-slate-500 dark:text-slate-400">
{t.summaryTotalProjectsLabel}
</span>
<span className="text-2xl font-bold tracking-tight">{safeSummary.totalProjects}</span>
</div>
@@ -228,7 +232,9 @@ export default function DashboardPage() {
data-testid="dashboard-summary-total-submissions"
className="rounded-xl border border-slate-200 bg-white/80 px-4 py-3 flex flex-col gap-1 shadow-sm dark:border-slate-800 dark:bg-slate-900/70"
>
<span className="text-[11px] font-medium text-slate-500 dark:text-slate-400"> </span>
<span className="text-[11px] font-medium text-slate-500 dark:text-slate-400">
{t.summaryTotalSubmissionsLabel}
</span>
<span className="text-2xl font-bold tracking-tight">{safeSummary.totalSubmissions}</span>
</div>
@@ -236,7 +242,9 @@ export default function DashboardPage() {
data-testid="dashboard-summary-today-submissions"
className="rounded-xl border border-slate-200 bg-white/80 px-4 py-3 flex flex-col gap-1 shadow-sm dark:border-slate-800 dark:bg-slate-900/70"
>
<span className="text-[11px] font-medium text-slate-500 dark:text-slate-400"> </span>
<span className="text-[11px] font-medium text-slate-500 dark:text-slate-400">
{t.summaryTodaySubmissionsLabel}
</span>
<span className="text-2xl font-bold tracking-tight">{safeSummary.todaySubmissions}</span>
</div>
@@ -244,7 +252,9 @@ export default function DashboardPage() {
data-testid="dashboard-summary-last7days-submissions"
className="rounded-xl border border-slate-200 bg-white/80 px-4 py-3 flex flex-col gap-1 shadow-sm dark:border-slate-800 dark:bg-slate-900/70"
>
<span className="text-[11px] font-medium text-slate-500 dark:text-slate-400"> 7 </span>
<span className="text-[11px] font-medium text-slate-500 dark:text-slate-400">
{t.summaryLast7DaysSubmissionsLabel}
</span>
<span className="text-2xl font-bold tracking-tight">{safeSummary.last7DaysSubmissions}</span>
</div>
</div>
@@ -255,12 +265,12 @@ export default function DashboardPage() {
className="mt-2 rounded-xl border border-slate-200 bg-white/80 px-4 py-3 shadow-sm dark:border-slate-800 dark:bg-slate-900/70"
>
<div className="flex items-baseline justify-between mb-2">
<h2 className="text-sm font-semibold text-slate-800 dark:text-slate-200"> ()</h2>
<span className="text-[10px] text-slate-500 dark:text-slate-500"> .</span>
<h2 className="text-sm font-semibold text-slate-800 dark:text-slate-200">{t.chartTitle}</h2>
<span className="text-[10px] text-slate-500 dark:text-slate-500">{t.chartSubtitle}</span>
</div>
{dailySubmissions.length === 0 ? (
<p className="text-[11px] text-slate-500 dark:text-slate-400"> .</p>
<p className="text-[11px] text-slate-500 dark:text-slate-400">{t.chartEmptyLabel}</p>
) : (
<div className="flex items-end gap-2 text-[10px]">
{dailySubmissions.map((item) => (
@@ -283,10 +293,12 @@ export default function DashboardPage() {
{/* 프로젝트 카드 리스트 영역 */}
<div className="mt-2">
<h2 className="text-base font-semibold mb-2 text-slate-800 dark:text-slate-100"> </h2>
<h2 className="text-base font-semibold mb-2 text-slate-800 dark:text-slate-100">
{t.sectionProjectsTitle}
</h2>
{projects.length === 0 && status === "idle" && !errorMessage && (
<p className="text-xs text-slate-500 dark:text-slate-400"> .</p>
<p className="text-xs text-slate-500 dark:text-slate-400">{t.sectionProjectsEmpty}</p>
)}
{projects.length > 0 && (
@@ -294,7 +306,7 @@ export default function DashboardPage() {
{projects.map((project) => {
const lastSubmitted = project.lastSubmissionAt
? new Date(project.lastSubmissionAt).toLocaleString()
: "제출 내역 없음";
: t.projectLatestSubmissionEmpty;
return (
<article
@@ -314,9 +326,13 @@ export default function DashboardPage() {
<div className="mt-1 flex items-center justify-between text-[11px] text-slate-700 dark:text-slate-300">
<span>
<span className="font-semibold">{project.totalSubmissions}</span>
{t.projectTotalSubmissionsPrefix}
<span className="font-semibold">{project.totalSubmissions}</span>
{t.projectTotalSubmissionsSuffix}
</span>
<span className="text-slate-500 dark:text-slate-400">
{t.projectLatestSubmissionPrefix} {lastSubmitted}
</span>
<span className="text-slate-500 dark:text-slate-400"> : {lastSubmitted}</span>
</div>
<div className="mt-2 flex items-center gap-2 text-[11px]">
@@ -324,13 +340,13 @@ export default function DashboardPage() {
href={`/projects/${encodeURIComponent(project.slug)}/submissions`}
className="text-emerald-700 hover:text-emerald-900 underline-offset-2 hover:underline dark:text-emerald-300 dark:hover:text-emerald-100"
>
{t.projectViewSubmissionsLink}
</Link>
<Link
href={`/p/${encodeURIComponent(project.slug)}`}
className="text-sky-700 hover:text-sky-900 underline-offset-2 hover:underline dark:text-sky-300 dark:hover:text-sky-200"
>
{t.projectOpenPublicPageLink}
</Link>
</div>
</article>
+15 -7
View File
@@ -11,6 +11,8 @@ import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable"
import { rectIntersection } from "@dnd-kit/core";
import type { Block, ButtonBlockProps, ImageBlockProps, ProjectConfig, TextBlockProps } from "@/features/editor/state/editorStore";
import { useAppLocale } from "@/features/i18n/LocaleProvider";
import { getEditorCanvasMessages } from "@/features/i18n/messages/editorCanvas";
interface EditorCanvasProps {
blocks: Block[];
@@ -42,6 +44,9 @@ export function EditorCanvas(props: EditorCanvasProps) {
projectConfig,
} = props;
const locale = useAppLocale();
const m = getEditorCanvasMessages(locale);
const canvasOuterStyle: CSSProperties = {};
const canvasInnerStyle: CSSProperties = {};
const preset = projectConfig.canvasPreset ?? "full";
@@ -86,7 +91,7 @@ export function EditorCanvas(props: EditorCanvasProps) {
>
{blocks.length === 0 ? (
<div className="flex-1 flex items-center justify-center text-slate-500 text-xs">
"텍스트" .
{m.emptyStateHint}
</div>
) : (
<DndContext
@@ -121,6 +126,9 @@ interface DragPreviewProps {
export function EditorCanvasDragPreview({ block }: DragPreviewProps) {
if (!block) return null;
const locale = useAppLocale();
const m = getEditorCanvasMessages(locale);
return (
<div className="pointer-events-none rounded border border-sky-500 bg-white px-3 py-2 text-xs text-slate-900 shadow-lg opacity-90 min-w-[160px] max-w-[260px] dark:bg-slate-900 dark:text-slate-100">
<div className="text-[10px] uppercase tracking-wide text-sky-300 mb-1">
@@ -138,16 +146,16 @@ export function EditorCanvasDragPreview({ block }: DragPreviewProps) {
</div>
<div className="truncate">
{block.type === "text"
? (block.props as TextBlockProps).text || "텍스트 블록"
? (block.props as TextBlockProps).text || m.previewFallbackTextBlock
: block.type === "button"
? (block.props as ButtonBlockProps).label || "버튼 블록"
? (block.props as ButtonBlockProps).label || m.previewFallbackButtonBlock
: block.type === "image"
? (block.props as ImageBlockProps).alt || "이미지 블록"
? (block.props as ImageBlockProps).alt || m.previewFallbackImageBlock
: block.type === "list"
? "리스트 블록"
? m.previewFallbackListBlock
: block.type === "divider"
? "구분선 블록"
: "섹션 블록"}
? m.previewFallbackDividerBlock
: m.previewFallbackSectionBlock}
</div>
</div>
);
@@ -3,6 +3,8 @@
import type { Block, FormCheckboxBlockProps } from "@/features/editor/state/editorStore";
import { ColorPickerField, TEXT_COLOR_PALETTE } from "@/features/editor/components/ColorPickerField";
import { NumericPropertyControl } from "@/features/editor/components/NumericPropertyControl";
import { useAppLocale } from "@/features/i18n/LocaleProvider";
import { getEditorFormCheckboxPanelMessages } from "@/features/i18n/messages/editorFormCheckboxPanel";
interface FormCheckboxPropertiesPanelProps {
block: Block;
@@ -11,6 +13,8 @@ interface FormCheckboxPropertiesPanelProps {
}
export function FormCheckboxPropertiesPanel({ block, selectedBlockId, updateBlock }: FormCheckboxPropertiesPanelProps) {
const locale = useAppLocale();
const m = getEditorFormCheckboxPanelMessages(locale);
if (!selectedBlockId || block.id !== selectedBlockId) return null;
const checkboxProps = block.props as FormCheckboxBlockProps;
@@ -18,10 +22,10 @@ export function FormCheckboxPropertiesPanel({ block, selectedBlockId, updateBloc
return (
<div className="space-y-3 text-xs">
<h3 className="text-[11px] font-semibold text-slate-800 dark:text-slate-200"> </h3>
<h3 className="text-[11px] font-semibold text-slate-800 dark:text-slate-200">{m.sectionTitle}</h3>
<label className="flex flex-col gap-1">
<span className="text-slate-500 dark:text-slate-400"> </span>
<span className="text-slate-500 dark:text-slate-400">{m.groupTitleTypeLabel}</span>
<select
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-[11px] text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
value={checkboxProps.groupLabelMode ?? "text"}
@@ -31,13 +35,13 @@ export function FormCheckboxPropertiesPanel({ block, selectedBlockId, updateBloc
} as any)
}
>
<option value="text"></option>
<option value="image"></option>
<option value="text">{m.groupTitleTypeOptionText}</option>
<option value="image">{m.groupTitleTypeOptionImage}</option>
</select>
</label>
<label className="flex flex-col gap-1">
<span className="text-slate-500 dark:text-slate-400"> </span>
<span className="text-slate-500 dark:text-slate-400">{m.groupTitleDisplayLabel}</span>
<select
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-[11px] text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
value={groupLabelDisplay}
@@ -47,14 +51,14 @@ export function FormCheckboxPropertiesPanel({ block, selectedBlockId, updateBloc
} as any)
}
>
<option value="visible"> ()</option>
<option value="hidden"></option>
<option value="visible">{m.groupTitleDisplayOptionVisible}</option>
<option value="hidden">{m.groupTitleDisplayOptionHidden}</option>
</select>
</label>
{groupLabelDisplay === "visible" && (
<label className="flex flex-col gap-1 text-[11px] text-slate-500 dark:text-slate-400">
<span></span>
<span>{m.layoutLabel}</span>
<select
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-[11px] text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
value={checkboxProps.labelLayout ?? "stacked"}
@@ -64,14 +68,14 @@ export function FormCheckboxPropertiesPanel({ block, selectedBlockId, updateBloc
} as any)
}
>
<option value="stacked"> ()</option>
<option value="inline"> ()</option>
<option value="stacked">{m.layoutOptionStacked}</option>
<option value="inline">{m.layoutOptionInline}</option>
</select>
</label>
)}
<label className="flex flex-col gap-1 text-[11px] text-slate-500 dark:text-slate-400">
<span> </span>
<span>{m.optionLayoutLabel}</span>
<select
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-[11px] text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
value={checkboxProps.optionLayout ?? "stacked"}
@@ -81,14 +85,14 @@ export function FormCheckboxPropertiesPanel({ block, selectedBlockId, updateBloc
} as any)
}
>
<option value="stacked"> ()</option>
<option value="inline"> ()</option>
<option value="stacked">{m.layoutOptionStacked}</option>
<option value="inline">{m.layoutOptionInline}</option>
</select>
</label>
{groupLabelDisplay === "visible" && (checkboxProps.labelLayout ?? "stacked") === "inline" && (
<NumericPropertyControl
label="라벨/필드 간격 (px)"
label={m.labelGapLabel}
unitLabel="(px)"
value={typeof checkboxProps.labelGapPx === "number" ? checkboxProps.labelGapPx : 8}
min={0}
@@ -109,7 +113,7 @@ export function FormCheckboxPropertiesPanel({ block, selectedBlockId, updateBloc
)}
<label className="flex flex-col gap-1">
<span className="text-slate-500 dark:text-slate-400"> </span>
<span className="text-slate-500 dark:text-slate-400">{m.groupTitleLabel}</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={checkboxProps.groupLabel ?? ""}
@@ -131,7 +135,7 @@ export function FormCheckboxPropertiesPanel({ block, selectedBlockId, updateBloc
return (
<div className="space-y-1">
<label className="flex flex-col gap-1">
<span className="text-slate-400"> </span>
<span className="text-slate-400">{m.groupTitleImageSourceLabel}</span>
<select
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-[11px] text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
value={source}
@@ -141,14 +145,14 @@ export function FormCheckboxPropertiesPanel({ block, selectedBlockId, updateBloc
} as any)
}
>
<option value="url">URL</option>
<option value="upload"> </option>
<option value="url">{m.groupTitleImageSourceOptionUrl}</option>
<option value="upload">{m.groupTitleImageSourceOptionUpload}</option>
</select>
</label>
{source === "url" && (
<label className="flex flex-col gap-1">
<span className="text-slate-400"> URL</span>
<span className="text-slate-400">{m.groupTitleImageUrlLabel}</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={checkboxProps.groupLabelImageUrl ?? ""}
@@ -163,12 +167,12 @@ export function FormCheckboxPropertiesPanel({ block, selectedBlockId, updateBloc
{source === "upload" && (
<label className="flex flex-col gap-1">
<span className="text-slate-400"> </span>
<span className="text-slate-400">{m.groupTitleImageUploadLabel}</span>
<input
type="file"
accept="image/*"
className="w-full text-[11px] text-slate-300 file:mr-2 file:rounded file:border file:border-slate-700 file:bg-slate-800 file:px-2 file:py-1 file:text-[11px] file:text-slate-100 hover:file:bg-slate-700"
aria-label="그룹 타이틀 이미지 파일 업로드"
aria-label={m.groupTitleImageUploadAria}
onChange={async (event) => {
const file = event.target.files?.[0];
if (!file) return;
@@ -208,7 +212,7 @@ export function FormCheckboxPropertiesPanel({ block, selectedBlockId, updateBloc
})()}
<label className="flex flex-col gap-1">
<span className="text-slate-500 dark:text-slate-400"> </span>
<span className="text-slate-500 dark:text-slate-400">{m.submitKeyLabel}</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={checkboxProps.formFieldName ?? ""}
@@ -222,7 +226,7 @@ export function FormCheckboxPropertiesPanel({ block, selectedBlockId, updateBloc
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-slate-500 dark:text-slate-400"></span>
<span className="text-slate-500 dark:text-slate-400">{m.optionsLabel}</span>
<button
type="button"
className="rounded border border-slate-300 bg-white px-2 py-0.5 text-[10px] text-slate-900 hover:bg-slate-100 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100 dark:hover:bg-slate-800"
@@ -239,12 +243,12 @@ export function FormCheckboxPropertiesPanel({ block, selectedBlockId, updateBloc
} as any);
}}
>
{m.addOptionButtonLabel}
</button>
</div>
<div className="space-y-2">
<label className="flex flex-col gap-1">
<span className="text-slate-500 dark:text-slate-400"> </span>
<span className="text-slate-500 dark:text-slate-400">{m.optionImageSourceLabel}</span>
<select
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-[11px] text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
value={(() => {
@@ -259,8 +263,8 @@ export function FormCheckboxPropertiesPanel({ block, selectedBlockId, updateBloc
} as any)
}
>
<option value="url">URL</option>
<option value="upload"> </option>
<option value="url">{m.optionImageSourceOptionUrl}</option>
<option value="upload">{m.optionImageSourceOptionUpload}</option>
</select>
</label>
@@ -269,7 +273,7 @@ export function FormCheckboxPropertiesPanel({ block, selectedBlockId, updateBloc
<div className="grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto] items-center gap-1">
<input
className="flex-1 rounded border border-slate-300 bg-white px-2 py-1 text-[11px] text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
placeholder="라벨"
placeholder={m.optionLabelPlaceholder}
value={opt.label ?? ""}
onChange={(e) => {
const next = [...(((checkboxProps as any).options ?? []) as any[])];
@@ -284,7 +288,7 @@ export function FormCheckboxPropertiesPanel({ block, selectedBlockId, updateBloc
/>
<input
className="flex-1 rounded border border-slate-300 bg-white px-2 py-1 text-[11px] text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
placeholder="값(value)"
placeholder={m.optionValuePlaceholder}
value={opt.value ?? ""}
onChange={(e) => {
const next = [...(((checkboxProps as any).options ?? []) as any[])];
@@ -321,7 +325,7 @@ export function FormCheckboxPropertiesPanel({ block, selectedBlockId, updateBloc
{source === "url" && (
<input
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-[11px] text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
placeholder="옵션 이미지 URL (선택)"
placeholder={m.optionImageUrlPlaceholder}
value={opt.labelImageUrl ?? ""}
onChange={(e) => {
const next = [...(((checkboxProps as any).options ?? []) as any[])];
@@ -337,12 +341,12 @@ export function FormCheckboxPropertiesPanel({ block, selectedBlockId, updateBloc
)}
{source === "upload" && (
<label className="flex flex-col gap-1">
<span className="text-slate-500 dark:text-slate-400"> </span>
<span className="text-slate-500 dark:text-slate-400">{m.optionImageUploadLabel}</span>
<input
type="file"
accept="image/*"
className="w-full text-[11px] text-slate-300 file:mr-2 file:rounded file:border file:border-slate-700 file:bg-slate-800 file:px-2 file:py-1 file:text-[11px] file:text-slate-100 hover:file:bg-slate-700"
aria-label="옵션 이미지 파일 업로드"
aria-label={m.optionImageUploadAria}
onChange={async (event) => {
const file = event.target.files?.[0];
if (!file) return;
@@ -393,15 +397,15 @@ export function FormCheckboxPropertiesPanel({ block, selectedBlockId, updateBloc
</div>
<label className="inline-flex items-center gap-2">
<span className="text-slate-500"> .</span>
<span className="text-slate-500">{m.requiredNoticeText}</span>
</label>
<div className="mt-3 space-y-2 border-t border-slate-800 pt-3">
<h4 className="text-[11px] font-semibold text-slate-800 dark:text-slate-200"> </h4>
<h4 className="text-[11px] font-semibold text-slate-800 dark:text-slate-200">{m.styleSectionTitle}</h4>
{/* px 기반 체크박스 타이포 입력값을 노출하여 em 스케일 변환을 위한 근거를 남긴다 */}
<NumericPropertyControl
label="체크박스 텍스트 크기 (px)"
unitLabel="(px)"
label={m.textSizeLabel}
unitLabel={m.textSizeUnitLabel}
value={(() => {
const raw = checkboxProps.fontSizeCustom ?? "";
const match = raw.match(/([0-9]+(?:\.[0-9]+)?)/);
@@ -418,16 +422,16 @@ export function FormCheckboxPropertiesPanel({ block, selectedBlockId, updateBloc
{ id: "lg", label: "L", value: 18 },
{ id: "xl", label: "XL", value: 20 },
]}
onChangeValue={(v) => {
onChangeValue={(v) =>
updateBlock(selectedBlockId, {
fontSizeCustom: `${v}px`,
} as any);
}}
} as any)
}
/>
{/* 줄간격 px 입력값을 노출해 preview em 변환과 TDD 케이스를 연동한다 */}
<NumericPropertyControl
label="체크박스 줄간격 (px)"
unitLabel="(px)"
label={m.lineHeightLabel}
unitLabel={m.lineHeightUnitLabel}
value={(() => {
const raw = checkboxProps.lineHeightCustom ?? "";
const match = raw.match(/([0-9]+(?:\.[0-9]+)?)/);
@@ -442,16 +446,16 @@ export function FormCheckboxPropertiesPanel({ block, selectedBlockId, updateBloc
{ id: "normal", label: "보통", value: 24 },
{ id: "loose", label: "루즈", value: 32 },
]}
onChangeValue={(v) => {
onChangeValue={(v) =>
updateBlock(selectedBlockId, {
lineHeightCustom: `${v}px`,
} as any);
}}
} as any)
}
/>
{/* 자간 px 입력을 통한 폼 체크박스 미세 조정값을 노출 */}
<NumericPropertyControl
label="체크박스 자간 (px)"
unitLabel="(px)"
label={m.letterSpacingLabel}
unitLabel={m.letterSpacingUnitLabel}
value={(() => {
const raw = checkboxProps.letterSpacingCustom ?? "";
const match = raw.match(/(-?[0-9]+(?:\.[0-9]+)?)/);
@@ -466,14 +470,14 @@ export function FormCheckboxPropertiesPanel({ block, selectedBlockId, updateBloc
{ id: "normal", label: "보통", value: 0 },
{ id: "wide", label: "넓게", value: 2 },
]}
onChangeValue={(v) => {
onChangeValue={(v) =>
updateBlock(selectedBlockId, {
letterSpacingCustom: `${v}px`,
} as any);
}}
} as any)
}
/>
<label className="flex flex-col gap-1 text-[11px] text-slate-400">
<span> </span>
<span>{m.widthModeLabel}</span>
<select
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-[11px] text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
value={checkboxProps.widthMode ?? "full"}
@@ -483,15 +487,15 @@ export function FormCheckboxPropertiesPanel({ block, selectedBlockId, updateBloc
} as any)
}
>
<option value="auto"></option>
<option value="full"> </option>
<option value="fixed"> </option>
<option value="auto">{m.widthModeOptionAuto}</option>
<option value="full">{m.widthModeOptionFull}</option>
<option value="fixed">{m.widthModeOptionFixed}</option>
</select>
</label>
{(checkboxProps.widthMode ?? "full") === "fixed" && (
<NumericPropertyControl
label="필드 고정 너비"
unitLabel="(px)"
label={m.fixedWidthLabel}
unitLabel={m.fixedWidthUnitLabel}
value={checkboxProps.widthPx ?? 240}
min={80}
max={800}
@@ -501,16 +505,16 @@ export function FormCheckboxPropertiesPanel({ block, selectedBlockId, updateBloc
{ id: "md", label: "보통", value: 280 },
{ id: "lg", label: "넓게", value: 360 },
]}
onChangeValue={(v) => {
onChangeValue={(v) =>
updateBlock(selectedBlockId, {
widthPx: v,
} as any);
}}
} as any)
}
/>
)}
<NumericPropertyControl
label="체크박스 가로 패딩 (px)"
unitLabel="(px)"
label={m.paddingXLabel}
unitLabel={m.paddingXUnitLabel}
value={typeof checkboxProps.paddingX === "number" ? checkboxProps.paddingX : 12}
min={0}
max={60}
@@ -529,8 +533,8 @@ export function FormCheckboxPropertiesPanel({ block, selectedBlockId, updateBloc
}
/>
<NumericPropertyControl
label="체크박스 세로 패딩 (px)"
unitLabel="(px)"
label={m.paddingYLabel}
unitLabel={m.paddingYUnitLabel}
value={typeof checkboxProps.paddingY === "number" ? checkboxProps.paddingY : 10}
min={0}
max={40}
@@ -549,8 +553,8 @@ export function FormCheckboxPropertiesPanel({ block, selectedBlockId, updateBloc
}
/>
<NumericPropertyControl
label="체크박스 옵션 간격 (px)"
unitLabel="(px)"
label={m.optionGapLabel}
unitLabel={m.optionGapUnitLabel}
value={typeof checkboxProps.optionGapPx === "number" ? checkboxProps.optionGapPx : 4}
min={0}
max={40}
@@ -568,9 +572,9 @@ export function FormCheckboxPropertiesPanel({ block, selectedBlockId, updateBloc
}
/>
<ColorPickerField
label="텍스트 색상"
ariaLabelColorInput="체크박스 텍스트 색상 피커"
ariaLabelHexInput="체크박스 텍스트 색상 HEX"
label={m.textColorLabel}
ariaLabelColorInput={m.textColorPickerAria}
ariaLabelHexInput={m.textColorHexAria}
value={
checkboxProps.textColorCustom && checkboxProps.textColorCustom.trim() !== ""
? checkboxProps.textColorCustom
@@ -589,9 +593,9 @@ export function FormCheckboxPropertiesPanel({ block, selectedBlockId, updateBloc
}}
/>
<ColorPickerField
label="배경 색상"
ariaLabelColorInput="체크박스 배경 색상 피커"
ariaLabelHexInput="체크박스 배경 색상 HEX"
label={m.fillColorLabel}
ariaLabelColorInput={m.fillColorPickerAria}
ariaLabelHexInput={m.fillColorHexAria}
value={
checkboxProps.fillColorCustom && checkboxProps.fillColorCustom.trim() !== ""
? checkboxProps.fillColorCustom
@@ -610,9 +614,9 @@ export function FormCheckboxPropertiesPanel({ block, selectedBlockId, updateBloc
}}
/>
<ColorPickerField
label="테두리 색상"
ariaLabelColorInput="체크박스 테두리 색상 피커"
ariaLabelHexInput="체크박스 테두리 색상 HEX"
label={m.strokeColorLabel}
ariaLabelColorInput={m.strokeColorPickerAria}
ariaLabelHexInput={m.strokeColorHexAria}
value={
checkboxProps.strokeColorCustom && checkboxProps.strokeColorCustom.trim() !== ""
? checkboxProps.strokeColorCustom
@@ -631,7 +635,7 @@ export function FormCheckboxPropertiesPanel({ block, selectedBlockId, updateBloc
}}
/>
<NumericPropertyControl
label="필드 모서리 둥글기"
label={m.borderRadiusLabel}
value={(() => {
const r = checkboxProps.borderRadius ?? "md";
return r === "none" ? 0 : r === "sm" ? 2 : r === "lg" ? 6 : r === "full" ? 8 : 4;
@@ -640,11 +644,11 @@ export function FormCheckboxPropertiesPanel({ block, selectedBlockId, updateBloc
max={8}
step={1}
presets={[
{ id: "none", label: "없음", value: 0 },
{ id: "sm", label: "작게", value: 2 },
{ id: "md", label: "보통", value: 4 },
{ id: "lg", label: "크게", value: 6 },
{ id: "full", label: "완전 둥글게", value: 8 },
{ id: "none", label: m.borderRadiusPresetNone, value: 0 },
{ id: "sm", label: m.borderRadiusPresetSmall, value: 2 },
{ id: "md", label: m.borderRadiusPresetMedium, value: 4 },
{ id: "lg", label: m.borderRadiusPresetLarge, value: 6 },
{ id: "full", label: m.borderRadiusPresetFull, value: 8 },
]}
onChangeValue={(v) => {
const next =
+50 -46
View File
@@ -4,6 +4,8 @@ import { useState } from "react";
import type { Block, ButtonBlockProps, FormBlockProps } from "@/features/editor/state/editorStore";
import { NumericPropertyControl } from "@/features/editor/components/NumericPropertyControl";
import { computeFormControllerPublicTokens } from "@/features/editor/utils/formHelpers";
import { useAppLocale } from "@/features/i18n/LocaleProvider";
import { getEditorFormControllerPanelMessages } from "@/features/i18n/messages/editorFormControllerPanel";
interface FormControllerPanelProps {
block: Block; // type === "form"
@@ -18,6 +20,8 @@ export function FormControllerPanel({ block, blocks, selectedBlockId, updateBloc
const [showSheetsGuide, setShowSheetsGuide] = useState(false);
const formProps = block.props as FormBlockProps;
const locale = useAppLocale();
const m = getEditorFormControllerPanelMessages(locale);
const sheetsScriptExample = (() => {
const tokens = computeFormControllerPublicTokens(block, blocks);
@@ -71,13 +75,14 @@ export function FormControllerPanel({ block, blocks, selectedBlockId, updateBloc
return (
<div className="space-y-4 text-xs">
<p className="text-slate-400">
<code className="font-mono text-[11px]">/api/forms/submit</code>
, Webhook .
{m.introTextPrefix}
<code className="font-mono text-[11px]">/api/forms/submit</code>
{m.introTextSuffix}
</p>
<div className="space-y-2">
<label className="flex flex-col gap-1">
<span className="text-slate-500 dark:text-slate-400"> </span>
<span className="text-slate-500 dark:text-slate-400">{m.submitTargetLabel}</span>
<select
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-[11px] text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
value={formProps.submitTarget ?? "internal"}
@@ -87,18 +92,18 @@ export function FormControllerPanel({ block, blocks, selectedBlockId, updateBloc
} as any)
}
>
<option value="internal"> (Webhook )</option>
<option value="webhook"> Webhook / Google Sheets </option>
<option value="internal">{m.submitTargetOptionInternal}</option>
<option value="webhook">{m.submitTargetOptionWebhook}</option>
</select>
</label>
{formProps.submitTarget === "webhook" && (
<div className="space-y-3">
<label className="flex flex-col gap-1">
<span className="text-slate-500 dark:text-slate-400">Webhook URL (: Google Apps Script URL)</span>
<span className="text-slate-500 dark:text-slate-400">{m.webhookUrlLabel}</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"
placeholder="예: https://script.google.com/macros/s/.../exec"
placeholder={m.webhookUrlPlaceholder}
value={formProps.destinationUrl ?? ""}
onChange={(e) =>
updateBlock(selectedBlockId, {
@@ -108,7 +113,7 @@ export function FormControllerPanel({ block, blocks, selectedBlockId, updateBloc
/>
</label>
<label className="flex items-center justify-between gap-2">
<span className="text-slate-500 dark:text-slate-400"> </span>
<span className="text-slate-500 dark:text-slate-400">{m.payloadFormatLabel}</span>
<select
className="w-40 rounded border border-slate-300 bg-white px-2 py-1 text-[11px] text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
value={formProps.payloadFormat ?? "form"}
@@ -118,12 +123,12 @@ export function FormControllerPanel({ block, blocks, selectedBlockId, updateBloc
} as any)
}
>
<option value="form"> (x-www-form-urlencoded)</option>
<option value="json">JSON (application/json)</option>
<option value="form">{m.payloadFormatOptionForm}</option>
<option value="json">{m.payloadFormatOptionJson}</option>
</select>
</label>
<label className="flex items-center justify-between gap-2">
<span className="text-slate-500 dark:text-slate-400">HTTP </span>
<span className="text-slate-500 dark:text-slate-400">{m.httpMethodLabel}</span>
<select
className="w-28 rounded border border-slate-300 bg-white px-2 py-1 text-[11px] text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
value={formProps.method ?? "POST"}
@@ -138,10 +143,10 @@ export function FormControllerPanel({ block, blocks, selectedBlockId, updateBloc
</select>
</label>
<label className="flex flex-col gap-1">
<span className="text-slate-500 dark:text-slate-400">Authorization ()</span>
<span className="text-slate-500 dark:text-slate-400">{m.authTokenLabel}</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"
placeholder="예: Bearer xxxxxx"
placeholder={m.authTokenPlaceholder}
value={formProps.headers?.Authorization ?? ""}
onChange={(e) =>
updateBlock(selectedBlockId, {
@@ -154,10 +159,10 @@ export function FormControllerPanel({ block, blocks, selectedBlockId, updateBloc
/>
</label>
<label className="flex flex-col gap-1">
<span className="text-slate-500 dark:text-slate-400"> (key=value , )</span>
<span className="text-slate-500 dark:text-slate-400">{m.extraParamsLabel}</span>
<textarea
className="w-full h-16 rounded border border-slate-300 bg-white px-2 py-1 text-[11px] font-mono text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
placeholder={"예:\nsource=landing\nformId=contact-hero"}
placeholder={m.extraParamsPlaceholder}
value={Object.entries(formProps.extraParams ?? {})
.map(([k, v]) => `${k}=${v}`)
.join("\n")}
@@ -186,7 +191,7 @@ export function FormControllerPanel({ block, blocks, selectedBlockId, updateBloc
className="inline-flex items-center gap-1 rounded border border-slate-300 bg-white px-2 py-1 text-[11px] 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={() => setShowSheetsGuide(true)}
>
Google Sheets
{m.sheetsGuideButtonLabel}
</button>
</div>
</div>
@@ -194,9 +199,9 @@ export function FormControllerPanel({ block, blocks, selectedBlockId, updateBloc
</div>
<div className="space-y-2 border-t border-slate-800 pt-3 mt-4">
<h3 className="text-[11px] font-semibold text-slate-800 dark:text-slate-200"> </h3>
<h3 className="text-[11px] font-semibold text-slate-800 dark:text-slate-200">{m.layoutSectionTitle}</h3>
<label className="flex flex-col gap-1 text-xs text-slate-500 dark:text-slate-400">
<span> </span>
<span>{m.formWidthModeLabel}</span>
<select
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-[11px] text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
value={formProps.formWidthMode ?? "auto"}
@@ -206,14 +211,14 @@ export function FormControllerPanel({ block, blocks, selectedBlockId, updateBloc
} as any)
}
>
<option value="auto"></option>
<option value="full"> </option>
<option value="fixed"> </option>
<option value="auto">{m.formWidthModeOptionAuto}</option>
<option value="full">{m.formWidthModeOptionFull}</option>
<option value="fixed">{m.formWidthModeOptionFixed}</option>
</select>
</label>
{(formProps.formWidthMode ?? "auto") === "fixed" && (
<NumericPropertyControl
label="폼 고정 너비 (px)"
label={m.formFixedWidthLabel}
unitLabel="(px)"
value={typeof formProps.formWidthPx === "number" ? formProps.formWidthPx : 480}
min={160}
@@ -232,17 +237,17 @@ export function FormControllerPanel({ block, blocks, selectedBlockId, updateBloc
/>
)}
<NumericPropertyControl
label="폼 위/아래 여백 (px)"
label={m.marginYLabel}
unitLabel="(px)"
value={typeof formProps.marginYPx === "number" ? formProps.marginYPx : 24}
min={0}
max={160}
step={4}
presets={[
{ id: "none", label: "없음", value: 0 },
{ id: "compact", label: "좁게", value: 16 },
{ id: "normal", label: "보통", value: 24 },
{ id: "spacious", label: "넓게", value: 40 },
{ id: "none", label: m.marginYPresetNone, value: 0 },
{ id: "compact", label: m.marginYPresetCompact, value: 16 },
{ id: "normal", label: m.marginYPresetNormal, value: 24 },
{ id: "spacious", label: m.marginYPresetSpacious, value: 40 },
]}
onChangeValue={(v) =>
updateBlock(selectedBlockId, {
@@ -253,12 +258,12 @@ export function FormControllerPanel({ block, blocks, selectedBlockId, updateBloc
</div>
<div className="space-y-2 border-t border-slate-800 pt-3 mt-4">
<h3 className="text-[11px] font-semibold text-slate-800 dark:text-slate-200"> </h3>
<h3 className="text-[11px] font-semibold text-slate-800 dark:text-slate-200">{m.messagesSectionTitle}</h3>
<label className="flex flex-col gap-1 text-xs text-slate-500 dark:text-slate-400">
<span> </span>
<span>{m.successMessageLabel}</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"
placeholder="예: 성공적으로 전송되었습니다."
placeholder={m.successMessagePlaceholder}
value={formProps.successMessage ?? ""}
onChange={(e) =>
updateBlock(selectedBlockId, {
@@ -268,10 +273,10 @@ export function FormControllerPanel({ block, blocks, selectedBlockId, updateBloc
/>
</label>
<label className="flex flex-col gap-1 text-xs text-slate-500 dark:text-slate-400">
<span> </span>
<span>{m.errorMessageLabel}</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"
placeholder="예: 전송 중 오류가 발생했습니다."
placeholder={m.errorMessagePlaceholder}
value={formProps.errorMessage ?? ""}
onChange={(e) =>
updateBlock(selectedBlockId, {
@@ -283,10 +288,10 @@ export function FormControllerPanel({ block, blocks, selectedBlockId, updateBloc
</div>
<div className="space-y-3 border-t border-slate-800 pt-3 mt-4">
<h3 className="text-[11px] font-semibold text-slate-800 dark:text-slate-200"> </h3>
<h3 className="text-[11px] font-semibold text-slate-800 dark:text-slate-200">{m.controllerSectionTitle}</h3>
<fieldset className="space-y-2" aria-label="폼 필드 매핑">
<legend className="text-[11px] text-slate-400 mb-1"> </legend>
<fieldset className="space-y-2" aria-label={m.fieldMappingAriaLabel}>
<legend className="text-[11px] text-slate-400 mb-1">{m.fieldMappingLegend}</legend>
{blocks
.filter((b) =>
b.type === "formInput" ||
@@ -355,10 +360,10 @@ export function FormControllerPanel({ block, blocks, selectedBlockId, updateBloc
</fieldset>
<div className="space-y-1">
<span className="text-[11px] text-slate-400">Submit </span>
<span className="text-[11px] text-slate-400">{m.submitButtonLabel}</span>
<select
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-[11px] text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
aria-label="Submit 버튼"
aria-label={m.submitButtonAriaLabel}
value={formProps.submitButtonId ?? ""}
onChange={(e) =>
updateBlock(selectedBlockId, {
@@ -393,27 +398,26 @@ export function FormControllerPanel({ block, blocks, selectedBlockId, updateBloc
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60">
<div className="w-full max-w-xl rounded border border-slate-200 bg-white px-4 py-3 text-xs text-slate-900 shadow-lg dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100">
<div className="mb-2 flex items-center justify-between">
<h4 className="text-[11px] font-semibold">Google Sheets </h4>
<h4 className="text-[11px] font-semibold">{m.sheetsGuideHeading}</h4>
<button
type="button"
className="text-[11px] text-slate-400 hover:text-slate-100"
onClick={() => setShowSheetsGuide(false)}
aria-label="Google Sheets 연동 가이드 닫기"
aria-label={m.sheetsGuideCloseAriaLabel}
>
×
</button>
</div>
<p className="text-[11px] text-slate-500 dark:text-slate-400 leading-relaxed">
, Google Apps Script URL .
.
{m.sheetsGuideIntro}
</p>
<ol className="list-decimal list-inside space-y-1 text-[11px] text-slate-400 mt-2">
<li>Google Sheets , 1 name, email, message .</li>
<li> "확장 프로그램 → 앱스 스크립트" .</li>
<li>"배포 → 새 배포" , URL(/exec) Webhook URL .</li>
<li>{m.sheetsGuideStep1}</li>
<li>{m.sheetsGuideStep2}</li>
<li>{m.sheetsGuideStep3}</li>
</ol>
<div className="mt-2 space-y-1">
<span className="text-[11px] text-slate-500 dark:text-slate-400"> Apps Script </span>
<span className="text-[11px] text-slate-500 dark:text-slate-400">{m.sheetsGuideExampleCodeLabel}</span>
<textarea
className="w-full h-40 rounded border border-slate-300 bg-white px-2 py-1 text-[11px] font-mono text-slate-900 outline-none dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
readOnly
@@ -3,6 +3,8 @@
import type { Block, FormInputBlockProps } from "@/features/editor/state/editorStore";
import { ColorPickerField, TEXT_COLOR_PALETTE } from "@/features/editor/components/ColorPickerField";
import { NumericPropertyControl } from "@/features/editor/components/NumericPropertyControl";
import { useAppLocale } from "@/features/i18n/LocaleProvider";
import { getEditorFormInputPanelMessages } from "@/features/i18n/messages/editorFormInputPanel";
interface FormInputPropertiesPanelProps {
block: Block;
@@ -11,6 +13,8 @@ interface FormInputPropertiesPanelProps {
}
export function FormInputPropertiesPanel({ block, selectedBlockId, updateBlock }: FormInputPropertiesPanelProps) {
const locale = useAppLocale();
const m = getEditorFormInputPanelMessages(locale);
if (!selectedBlockId || block.id !== selectedBlockId || block.type !== "formInput") return null;
const inputProps = block.props as FormInputBlockProps;
@@ -18,9 +22,9 @@ export function FormInputPropertiesPanel({ block, selectedBlockId, updateBlock }
return (
<div className="space-y-3 text-xs">
<h3 className="text-[11px] font-semibold text-slate-800 dark:text-slate-200"> </h3>
<h3 className="text-[11px] font-semibold text-slate-800 dark:text-slate-200">{m.sectionTitle}</h3>
<label className="flex flex-col gap-1">
<span className="text-slate-500 dark:text-slate-400"> </span>
<span className="text-slate-500 dark:text-slate-400">{m.labelTypeLabel}</span>
<select
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-[11px] text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
value={inputProps.labelMode ?? "text"}
@@ -30,12 +34,12 @@ export function FormInputPropertiesPanel({ block, selectedBlockId, updateBlock }
} as any)
}
>
<option value="text"></option>
<option value="image"></option>
<option value="text">{m.labelTypeOptionText}</option>
<option value="image">{m.labelTypeOptionImage}</option>
</select>
</label>
<label className="flex flex-col gap-1">
<span className="text-slate-500 dark:text-slate-400"> </span>
<span className="text-slate-500 dark:text-slate-400">{m.fieldLabelLabel}</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={inputProps.label ?? ""}
@@ -47,7 +51,7 @@ export function FormInputPropertiesPanel({ block, selectedBlockId, updateBlock }
/>
</label>
<label className="flex flex-col gap-1">
<span className="text-slate-500 dark:text-slate-400"> </span>
<span className="text-slate-500 dark:text-slate-400">{m.labelDisplayLabel}</span>
<select
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-[11px] text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
value={labelDisplay}
@@ -57,14 +61,14 @@ export function FormInputPropertiesPanel({ block, selectedBlockId, updateBlock }
} as any)
}
>
<option value="visible"> ()</option>
<option value="hidden"></option>
<option value="floating"> </option>
<option value="visible">{m.labelDisplayOptionVisible}</option>
<option value="hidden">{m.labelDisplayOptionHidden}</option>
<option value="floating">{m.labelDisplayOptionFloating}</option>
</select>
</label>
{labelDisplay === "visible" && (
<label className="flex flex-col gap-1 text-[11px] text-slate-500 dark:text-slate-400">
<span></span>
<span>{m.layoutLabel}</span>
<select
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-[11px] text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
value={inputProps.labelLayout ?? "stacked"}
@@ -74,15 +78,15 @@ export function FormInputPropertiesPanel({ block, selectedBlockId, updateBlock }
} as any)
}
>
<option value="stacked"> ()</option>
<option value="inline"> ()</option>
<option value="stacked">{m.layoutOptionStacked}</option>
<option value="inline">{m.layoutOptionInline}</option>
</select>
</label>
)}
{labelDisplay === "visible" && (inputProps.labelLayout ?? "stacked") === "inline" && (
<NumericPropertyControl
label="라벨/필드 간격 (px)"
unitLabel="(px)"
label={m.labelGapLabel}
unitLabel={m.labelGapUnitLabel}
value={typeof inputProps.labelGapPx === "number" ? inputProps.labelGapPx : 8}
min={0}
max={80}
@@ -103,7 +107,7 @@ export function FormInputPropertiesPanel({ block, selectedBlockId, updateBlock }
{inputProps.labelMode === "image" && (
<>
<label className="flex flex-col gap-1">
<span className="text-slate-500 dark:text-slate-400"> URL</span>
<span className="text-slate-500 dark:text-slate-400">{m.labelImageUrlLabel}</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={inputProps.labelImageUrl ?? ""}
@@ -115,7 +119,7 @@ export function FormInputPropertiesPanel({ block, selectedBlockId, updateBlock }
/>
</label>
<label className="flex flex-col gap-1">
<span className="text-slate-500 dark:text-slate-400"> </span>
<span className="text-slate-500 dark:text-slate-400">{m.labelImageAltLabel}</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={inputProps.labelImageAlt ?? ""}
@@ -129,7 +133,7 @@ export function FormInputPropertiesPanel({ block, selectedBlockId, updateBlock }
</>
)}
<label className="flex flex-col gap-1">
<span className="text-slate-500 dark:text-slate-400"> </span>
<span className="text-slate-500 dark:text-slate-400">{m.submitKeyLabel}</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={inputProps.formFieldName ?? ""}
@@ -141,10 +145,10 @@ export function FormInputPropertiesPanel({ block, selectedBlockId, updateBlock }
/>
</label>
<label className="flex flex-col gap-1">
<span className="text-slate-500 dark:text-slate-400"> </span>
<span className="text-slate-500 dark:text-slate-400">{m.fieldTypeLabel}</span>
<select
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-[11px] text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
aria-label="필드 타입"
aria-label={m.fieldTypeAria}
value={inputProps.inputType ?? "text"}
onChange={(e) =>
updateBlock(selectedBlockId, {
@@ -152,16 +156,16 @@ export function FormInputPropertiesPanel({ block, selectedBlockId, updateBlock }
} as any)
}
>
<option value="text"></option>
<option value="email"></option>
<option value="textarea"> </option>
<option value="text">{m.fieldTypeOptionText}</option>
<option value="email">{m.fieldTypeOptionEmail}</option>
<option value="textarea">{m.fieldTypeOptionTextarea}</option>
</select>
</label>
<label className="flex flex-col gap-1">
<span className="text-slate-500 dark:text-slate-400">Placeholder</span>
<span className="text-slate-500 dark:text-slate-400">{m.placeholderLabel}</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"
aria-label="Placeholder"
aria-label={m.placeholderAria}
value={inputProps.placeholder ?? ""}
onChange={(e) =>
updateBlock(selectedBlockId, {
@@ -171,13 +175,13 @@ export function FormInputPropertiesPanel({ block, selectedBlockId, updateBlock }
/>
</label>
<label className="inline-flex items-center gap-2">
<span className="text-slate-500 dark:text-slate-400"> .</span>
<span className="text-slate-500 dark:text-slate-400">{m.requiredNoticeText}</span>
</label>
<div className="mt-3 space-y-2 border-t border-slate-800 pt-3">
<h4 className="text-[11px] font-semibold text-slate-800 dark:text-slate-200"> </h4>
<h4 className="text-[11px] font-semibold text-slate-800 dark:text-slate-200">{m.styleSectionTitle}</h4>
<NumericPropertyControl
label="필드 텍스트 크기 (px)"
unitLabel="(px)"
label={m.textSizeLabel}
unitLabel={m.textSizeUnitLabel}
value={(() => {
const raw = inputProps.fontSizeCustom ?? "";
const match = raw.match(/([0-9]+(?:\.[0-9]+)?)/);
@@ -202,8 +206,8 @@ export function FormInputPropertiesPanel({ block, selectedBlockId, updateBlock }
/>
{/* 줄간격 px 입력을 노출하여 preview em 변환의 근거를 제공 */}
<NumericPropertyControl
label="필드 줄간격 (px)"
unitLabel="(px)"
label={m.lineHeightLabel}
unitLabel={m.lineHeightUnitLabel}
value={(() => {
const raw = inputProps.lineHeightCustom ?? "";
const match = raw.match(/([0-9]+(?:\.[0-9]+)?)/);
@@ -226,8 +230,8 @@ export function FormInputPropertiesPanel({ block, selectedBlockId, updateBlock }
/>
{/* 자간 px 입력으로 폼 입력 타이포를 세밀하게 조정 */}
<NumericPropertyControl
label="필드 자간 (px)"
unitLabel="(px)"
label={m.letterSpacingLabel}
unitLabel={m.letterSpacingUnitLabel}
value={(() => {
const raw = inputProps.letterSpacingCustom ?? "";
const match = raw.match(/(-?[0-9]+(?:\.[0-9]+)?)/);
@@ -249,7 +253,7 @@ export function FormInputPropertiesPanel({ block, selectedBlockId, updateBlock }
}}
/>
<label className="flex flex-col gap-1 text-[11px] text-slate-500 dark:text-slate-400">
<span> </span>
<span>{m.textAlignLabel}</span>
<select
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-[11px] text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
value={inputProps.align ?? "left"}
@@ -259,16 +263,16 @@ export function FormInputPropertiesPanel({ block, selectedBlockId, updateBlock }
} as any)
}
>
<option value="left"></option>
<option value="center"></option>
<option value="right"></option>
<option value="left">{m.textAlignOptionLeft}</option>
<option value="center">{m.textAlignOptionCenter}</option>
<option value="right">{m.textAlignOptionRight}</option>
</select>
</label>
<label className="flex flex-col gap-1 text-[11px] text-slate-500 dark:text-slate-400">
<span></span>
<span>{m.widthModeLabel}</span>
<select
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-[11px] text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
aria-label="너비"
aria-label={m.widthModeAria}
value={inputProps.widthMode ?? "full"}
onChange={(e) =>
updateBlock(selectedBlockId, {
@@ -276,14 +280,14 @@ export function FormInputPropertiesPanel({ block, selectedBlockId, updateBlock }
} as any)
}
>
<option value="auto"></option>
<option value="full"> </option>
<option value="fixed"> </option>
<option value="auto">{m.widthModeOptionAuto}</option>
<option value="full">{m.widthModeOptionFull}</option>
<option value="fixed">{m.widthModeOptionFixed}</option>
</select>
</label>
<NumericPropertyControl
label="필드 고정 너비"
unitLabel="(px)"
label={m.fixedWidthLabel}
unitLabel={m.fixedWidthUnitLabel}
value={inputProps.widthPx ?? 240}
min={80}
max={800}
@@ -300,8 +304,8 @@ export function FormInputPropertiesPanel({ block, selectedBlockId, updateBlock }
}}
/>
<NumericPropertyControl
label="필드 가로 패딩 (px)"
unitLabel="(px)"
label={m.paddingXLabel}
unitLabel={m.paddingXUnitLabel}
value={typeof inputProps.paddingX === "number" ? inputProps.paddingX : 12}
min={0}
max={60}
@@ -320,8 +324,8 @@ export function FormInputPropertiesPanel({ block, selectedBlockId, updateBlock }
}}
/>
<NumericPropertyControl
label="필드 세로 패딩 (px)"
unitLabel="(px)"
label={m.paddingYLabel}
unitLabel={m.paddingYUnitLabel}
value={typeof inputProps.paddingY === "number" ? inputProps.paddingY : 10}
min={0}
max={40}
@@ -340,9 +344,9 @@ export function FormInputPropertiesPanel({ block, selectedBlockId, updateBlock }
}}
/>
<ColorPickerField
label="필드 텍스트 색상"
ariaLabelColorInput="필드 텍스트 색상 피커"
ariaLabelHexInput="필드 텍스트 색상 HEX"
label={m.textColorLabel}
ariaLabelColorInput={m.textColorPickerAria}
ariaLabelHexInput={m.textColorHexAria}
value={
inputProps.textColorCustom && inputProps.textColorCustom.trim() !== ""
? inputProps.textColorCustom
@@ -361,9 +365,9 @@ export function FormInputPropertiesPanel({ block, selectedBlockId, updateBlock }
}}
/>
<ColorPickerField
label="필드 채움 색상"
ariaLabelColorInput="필드 채움 색상 피커"
ariaLabelHexInput="필드 채움 색상 HEX"
label={m.fillColorLabel}
ariaLabelColorInput={m.fillColorPickerAria}
ariaLabelHexInput={m.fillColorHexAria}
value={
inputProps.fillColorCustom && inputProps.fillColorCustom.trim() !== ""
? inputProps.fillColorCustom
@@ -382,9 +386,9 @@ export function FormInputPropertiesPanel({ block, selectedBlockId, updateBlock }
}}
/>
<ColorPickerField
label="필드 테두리 색상"
ariaLabelColorInput="필드 테두리 색상 피커"
ariaLabelHexInput="필드 테두리 색상 HEX"
label={m.strokeColorLabel}
ariaLabelColorInput={m.strokeColorPickerAria}
ariaLabelHexInput={m.strokeColorHexAria}
value={
inputProps.strokeColorCustom && inputProps.strokeColorCustom.trim() !== ""
? inputProps.strokeColorCustom
@@ -403,7 +407,7 @@ export function FormInputPropertiesPanel({ block, selectedBlockId, updateBlock }
}}
/>
<NumericPropertyControl
label="필드 모서리 둥글기"
label={m.borderRadiusLabel}
value={(() => {
const r = inputProps.borderRadius ?? "md";
return r === "none" ? 0 : r === "sm" ? 2 : r === "lg" ? 6 : r === "full" ? 8 : 4;
@@ -412,15 +416,14 @@ export function FormInputPropertiesPanel({ block, selectedBlockId, updateBlock }
max={8}
step={1}
presets={[
{ id: "none", label: "없음", value: 0 },
{ id: "sm", label: "작게", value: 2 },
{ id: "md", label: "보통", value: 4 },
{ id: "lg", label: "크게", value: 6 },
{ id: "full", label: "완전 둥글게", value: 8 },
{ id: "none", label: m.borderRadiusPresetNone, value: 0 },
{ id: "sm", label: m.borderRadiusPresetSmall, value: 2 },
{ id: "md", label: m.borderRadiusPresetMedium, value: 4 },
{ id: "lg", label: m.borderRadiusPresetLarge, value: 6 },
{ id: "full", label: m.borderRadiusPresetFull, value: 8 },
]}
onChangeValue={(v) => {
const next =
v <= 0 ? "none" : v <= 2 ? "sm" : v <= 5 ? "md" : v <= 7 ? "lg" : "full";
const next = v <= 0 ? "none" : v <= 2 ? "sm" : v <= 5 ? "md" : v <= 7 ? "lg" : "full";
updateBlock(selectedBlockId, {
borderRadius: next,
} as any);
@@ -3,6 +3,8 @@
import type { Block, FormRadioBlockProps } from "@/features/editor/state/editorStore";
import { ColorPickerField, TEXT_COLOR_PALETTE } from "@/features/editor/components/ColorPickerField";
import { NumericPropertyControl } from "@/features/editor/components/NumericPropertyControl";
import { useAppLocale } from "@/features/i18n/LocaleProvider";
import { getEditorFormRadioPanelMessages } from "@/features/i18n/messages/editorFormRadioPanel";
interface FormRadioPropertiesPanelProps {
block: Block;
@@ -11,6 +13,8 @@ interface FormRadioPropertiesPanelProps {
}
export function FormRadioPropertiesPanel({ block, selectedBlockId, updateBlock }: FormRadioPropertiesPanelProps) {
const locale = useAppLocale();
const m = getEditorFormRadioPanelMessages(locale);
if (!selectedBlockId || block.id !== selectedBlockId) return null;
const radioProps = block.props as FormRadioBlockProps;
@@ -18,7 +22,7 @@ export function FormRadioPropertiesPanel({ block, selectedBlockId, updateBlock }
return (
<div className="space-y-3 text-xs">
<h3 className="text-[11px] font-semibold text-slate-800 dark:text-slate-200"> </h3>
<h3 className="text-[11px] font-semibold text-slate-800 dark:text-slate-200">{m.sectionTitle}</h3>
<label className="flex flex-col gap-1">
<span className="text-slate-500 dark:text-slate-400"> </span>
@@ -225,7 +229,7 @@ export function FormRadioPropertiesPanel({ block, selectedBlockId, updateBlock }
<span className="text-slate-500 dark:text-slate-400"></span>
<button
type="button"
className="rounded border border-slate-300 bg-white px-2 py-0.5 text-[10px] text-slate-900 hover:bg-slate-100 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100 dark:hover:bg-slate-800"
className="rounded border border-slate-300 bg-white px-2 py-0.5 text-[10px] text-slate-900 hover:bg-slate-100 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100 dark:hover:bg-red-900/60 dark:hover:border-red-700"
onClick={() => {
const next = Array.isArray((radioProps as any).options)
? [...(radioProps as any).options]
@@ -393,15 +397,15 @@ export function FormRadioPropertiesPanel({ block, selectedBlockId, updateBlock }
</div>
<label className="inline-flex items-center gap-2">
<span className="text-slate-500"> .</span>
<span className="text-slate-500">{m.requiredNoticeText}</span>
</label>
<div className="mt-3 space-y-2 border-t border-slate-800 pt-3">
<h4 className="text-[11px] font-semibold text-slate-800 dark:text-slate-200"> </h4>
{/* 라디오 타이포 px 값을 직접 입력받아 em 스케일 변환 로직과 연동한다 */}
<h4 className="text-[11px] font-semibold text-slate-800 dark:text-slate-200">{m.styleSectionTitle}</h4>
<NumericPropertyControl
label="라디오 텍스트 크기 (px)"
unitLabel="(px)"
label={m.textSizeLabel}
unitLabel={m.textSizeUnitLabel}
value={(() => {
const raw = radioProps.fontSizeCustom ?? "";
const match = raw.match(/([0-9]+(?:\.[0-9]+)?)/);
@@ -424,10 +428,10 @@ export function FormRadioPropertiesPanel({ block, selectedBlockId, updateBlock }
} as any);
}}
/>
{/* 줄간격 px 입력을 노출하여 preview 에서 em 변환 근거로 사용 */}
<NumericPropertyControl
label="라디오 줄간격 (px)"
unitLabel="(px)"
label={m.lineHeightLabel}
unitLabel={m.lineHeightUnitLabel}
value={(() => {
const raw = radioProps.lineHeightCustom ?? "";
const match = raw.match(/([0-9]+(?:\.[0-9]+)?)/);
@@ -448,10 +452,10 @@ export function FormRadioPropertiesPanel({ block, selectedBlockId, updateBlock }
} as any);
}}
/>
{/* 자간 px 입력 컨트롤로 프리뷰 적용 근거 제공 */}
<NumericPropertyControl
label="라디오 자간 (px)"
unitLabel="(px)"
label={m.letterSpacingLabel}
unitLabel={m.letterSpacingUnitLabel}
value={(() => {
const raw = radioProps.letterSpacingCustom ?? "";
const match = raw.match(/(-?[0-9]+(?:\.[0-9]+)?)/);
@@ -472,8 +476,9 @@ export function FormRadioPropertiesPanel({ block, selectedBlockId, updateBlock }
} as any);
}}
/>
<label className="flex flex-col gap-1 text-[11px] text-slate-400">
<span> </span>
<span>Field width</span>
<select
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-[11px] text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
value={radioProps.widthMode ?? "full"}
@@ -483,23 +488,24 @@ export function FormRadioPropertiesPanel({ block, selectedBlockId, updateBlock }
} as any)
}
>
<option value="auto"></option>
<option value="full"> </option>
<option value="fixed"> </option>
<option value="auto">Auto</option>
<option value="full">Full width</option>
<option value="fixed">Fixed width</option>
</select>
</label>
{(radioProps.widthMode ?? "full") === "fixed" && (
<NumericPropertyControl
label="필드 고정 너비"
label="Field fixed width"
unitLabel="(px)"
value={radioProps.widthPx ?? 240}
min={80}
max={800}
step={10}
presets={[
{ id: "sm", label: "작게", value: 200 },
{ id: "md", label: "보통", value: 280 },
{ id: "lg", label: "넓게", value: 360 },
{ id: "sm", label: "Small", value: 200 },
{ id: "md", label: "Medium", value: 280 },
{ id: "lg", label: "Large", value: 360 },
]}
onChangeValue={(v) => {
updateBlock(selectedBlockId, {
@@ -508,19 +514,20 @@ export function FormRadioPropertiesPanel({ block, selectedBlockId, updateBlock }
}}
/>
)}
<NumericPropertyControl
label="라디오 가로 패딩 (px)"
unitLabel="(px)"
label={m.paddingXLabel}
unitLabel={m.paddingXUnitLabel}
value={typeof radioProps.paddingX === "number" ? radioProps.paddingX : 12}
min={0}
max={60}
step={2}
presets={[
{ id: "xs", label: "아주 얇게", value: 6 },
{ id: "sm", label: "얇게", value: 10 },
{ id: "md", label: "보통", value: 12 },
{ id: "lg", label: "넓게", value: 16 },
{ id: "xl", label: "아주 넓게", value: 20 },
{ id: "xs", label: "Extra small", value: 6 },
{ id: "sm", label: "Small", value: 10 },
{ id: "md", label: "Medium", value: 12 },
{ id: "lg", label: "Large", value: 16 },
{ id: "xl", label: "Extra large", value: 20 },
]}
onChangeValue={(v) =>
updateBlock(selectedBlockId, {
@@ -528,19 +535,20 @@ export function FormRadioPropertiesPanel({ block, selectedBlockId, updateBlock }
} as any)
}
/>
<NumericPropertyControl
label="라디오 세로 패딩 (px)"
unitLabel="(px)"
label={m.paddingYLabel}
unitLabel={m.paddingYUnitLabel}
value={typeof radioProps.paddingY === "number" ? radioProps.paddingY : 10}
min={0}
max={40}
step={1}
presets={[
{ id: "xs", label: "아주 얇게", value: 6 },
{ id: "sm", label: "얇게", value: 8 },
{ id: "md", label: "보통", value: 10 },
{ id: "lg", label: "넓게", value: 12 },
{ id: "xl", label: "아주 넓게", value: 16 },
{ id: "xs", label: "Extra small", value: 6 },
{ id: "sm", label: "Small", value: 8 },
{ id: "md", label: "Medium", value: 10 },
{ id: "lg", label: "Large", value: 12 },
{ id: "xl", label: "Extra large", value: 16 },
]}
onChangeValue={(v) =>
updateBlock(selectedBlockId, {
@@ -548,18 +556,19 @@ export function FormRadioPropertiesPanel({ block, selectedBlockId, updateBlock }
} as any)
}
/>
<NumericPropertyControl
label="라디오 옵션 간격 (px)"
unitLabel="(px)"
label={m.optionGapLabel}
unitLabel={m.optionGapUnitLabel}
value={typeof radioProps.optionGapPx === "number" ? radioProps.optionGapPx : 4}
min={0}
max={40}
step={1}
presets={[
{ id: "tight", label: "타이트", value: 2 },
{ id: "normal", label: "보통", value: 4 },
{ id: "relaxed", label: "느슨", value: 8 },
{ id: "extra", label: "넓게", value: 12 },
{ id: "tight", label: "Tight", value: 2 },
{ id: "normal", label: "Normal", value: 4 },
{ id: "relaxed", label: "Relaxed", value: 8 },
{ id: "extra", label: "Extra", value: 12 },
]}
onChangeValue={(v) =>
updateBlock(selectedBlockId, {
@@ -567,10 +576,11 @@ export function FormRadioPropertiesPanel({ block, selectedBlockId, updateBlock }
} as any)
}
/>
<ColorPickerField
label="텍스트 색상"
ariaLabelColorInput="라디오 텍스트 색상 피커"
ariaLabelHexInput="라디오 텍스트 색상 HEX"
label={m.textColorLabel}
ariaLabelColorInput={m.textColorPickerAria}
ariaLabelHexInput={m.textColorHexAria}
value={
radioProps.textColorCustom && radioProps.textColorCustom.trim() !== ""
? radioProps.textColorCustom
@@ -588,10 +598,11 @@ export function FormRadioPropertiesPanel({ block, selectedBlockId, updateBlock }
} as any);
}}
/>
<ColorPickerField
label="배경 색상"
ariaLabelColorInput="라디오 배경 색상 피커"
ariaLabelHexInput="라디오 배경 색상 HEX"
label={m.fillColorLabel}
ariaLabelColorInput={m.fillColorPickerAria}
ariaLabelHexInput={m.fillColorHexAria}
value={
radioProps.fillColorCustom && radioProps.fillColorCustom.trim() !== ""
? radioProps.fillColorCustom
@@ -609,10 +620,11 @@ export function FormRadioPropertiesPanel({ block, selectedBlockId, updateBlock }
} as any);
}}
/>
<ColorPickerField
label="테두리 색상"
ariaLabelColorInput="라디오 테두리 색상 피커"
ariaLabelHexInput="라디오 테두리 색상 HEX"
label={m.strokeColorLabel}
ariaLabelColorInput={m.strokeColorPickerAria}
ariaLabelHexInput={m.strokeColorHexAria}
value={
radioProps.strokeColorCustom && radioProps.strokeColorCustom.trim() !== ""
? radioProps.strokeColorCustom
@@ -630,8 +642,9 @@ export function FormRadioPropertiesPanel({ block, selectedBlockId, updateBlock }
} as any);
}}
/>
<NumericPropertyControl
label="필드 모서리 둥글기"
label={m.borderRadiusLabel}
value={(() => {
const r = radioProps.borderRadius ?? "md";
return r === "none" ? 0 : r === "sm" ? 2 : r === "lg" ? 6 : r === "full" ? 8 : 4;
@@ -640,11 +653,11 @@ export function FormRadioPropertiesPanel({ block, selectedBlockId, updateBlock }
max={8}
step={1}
presets={[
{ id: "none", label: "없음", value: 0 },
{ id: "sm", label: "작게", value: 2 },
{ id: "md", label: "보통", value: 4 },
{ id: "lg", label: "크게", value: 6 },
{ id: "full", label: "완전 둥글게", value: 8 },
{ id: "none", label: m.borderRadiusPresetNone, value: 0 },
{ id: "sm", label: m.borderRadiusPresetSmall, value: 2 },
{ id: "md", label: m.borderRadiusPresetMedium, value: 4 },
{ id: "lg", label: m.borderRadiusPresetLarge, value: 6 },
{ id: "full", label: m.borderRadiusPresetFull, value: 8 },
]}
onChangeValue={(v) => {
const next =
@@ -3,6 +3,8 @@
import type { Block, FormSelectBlockProps } from "@/features/editor/state/editorStore";
import { ColorPickerField, TEXT_COLOR_PALETTE } from "@/features/editor/components/ColorPickerField";
import { NumericPropertyControl } from "@/features/editor/components/NumericPropertyControl";
import { useAppLocale } from "@/features/i18n/LocaleProvider";
import { getEditorFormSelectPanelMessages } from "@/features/i18n/messages/editorFormSelectPanel";
interface FormSelectPropertiesPanelProps {
block: Block;
@@ -11,6 +13,8 @@ interface FormSelectPropertiesPanelProps {
}
export function FormSelectPropertiesPanel({ block, selectedBlockId, updateBlock }: FormSelectPropertiesPanelProps) {
const locale = useAppLocale();
const m = getEditorFormSelectPanelMessages(locale);
if (!selectedBlockId || block.id !== selectedBlockId) return null;
const selectProps = block.props as FormSelectBlockProps;
@@ -18,10 +22,10 @@ export function FormSelectPropertiesPanel({ block, selectedBlockId, updateBlock
return (
<div className="space-y-3 text-xs">
<h3 className="text-[11px] font-semibold text-slate-800 dark:text-slate-200"> </h3>
<h3 className="text-[11px] font-semibold text-slate-800 dark:text-slate-200">{m.sectionTitle}</h3>
<label className="flex flex-col gap-1">
<span className="text-slate-500 dark:text-slate-400"> </span>
<span className="text-slate-500 dark:text-slate-400">{m.labelTypeLabel}</span>
<select
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-[11px] text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
value={selectProps.labelMode ?? "text"}
@@ -31,13 +35,13 @@ export function FormSelectPropertiesPanel({ block, selectedBlockId, updateBlock
} as any)
}
>
<option value="text"></option>
<option value="image"></option>
<option value="text">{m.labelTypeOptionText}</option>
<option value="image">{m.labelTypeOptionImage}</option>
</select>
</label>
<label className="flex flex-col gap-1">
<span className="text-slate-500 dark:text-slate-400"> </span>
<span className="text-slate-500 dark:text-slate-400">{m.labelDisplayLabel}</span>
<select
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-[11px] text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
value={labelDisplay}
@@ -47,14 +51,14 @@ export function FormSelectPropertiesPanel({ block, selectedBlockId, updateBlock
} as any)
}
>
<option value="visible"> ()</option>
<option value="hidden"></option>
<option value="visible">{m.labelDisplayOptionVisible}</option>
<option value="hidden">{m.labelDisplayOptionHidden}</option>
</select>
</label>
{labelDisplay === "visible" && (
<label className="flex flex-col gap-1 text-[11px] text-slate-500 dark:text-slate-400">
<span></span>
<span>{m.layoutLabel}</span>
<select
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-[11px] text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
value={selectProps.labelLayout ?? "stacked"}
@@ -64,15 +68,15 @@ export function FormSelectPropertiesPanel({ block, selectedBlockId, updateBlock
} as any)
}
>
<option value="stacked"> ()</option>
<option value="inline"> ()</option>
<option value="stacked">{m.layoutOptionStacked}</option>
<option value="inline">{m.layoutOptionInline}</option>
</select>
</label>
)}
{labelDisplay === "visible" && (selectProps.labelLayout ?? "stacked") === "inline" && (
<NumericPropertyControl
label="라벨/필드 간격 (px)"
label={m.labelGapLabel}
unitLabel="(px)"
value={typeof selectProps.labelGapPx === "number" ? selectProps.labelGapPx : 8}
min={0}
@@ -93,7 +97,7 @@ export function FormSelectPropertiesPanel({ block, selectedBlockId, updateBlock
)}
<label className="flex flex-col gap-1">
<span className="text-slate-500 dark:text-slate-400"> </span>
<span className="text-slate-500 dark:text-slate-400">{m.fieldLabelLabel}</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={selectProps.label ?? ""}
@@ -106,7 +110,7 @@ export function FormSelectPropertiesPanel({ block, selectedBlockId, updateBlock
</label>
<label className="flex flex-col gap-1">
<span className="text-slate-500 dark:text-slate-400"> </span>
<span className="text-slate-500 dark:text-slate-400">{m.submitKeyLabel}</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={selectProps.formFieldName ?? ""}
@@ -120,7 +124,7 @@ export function FormSelectPropertiesPanel({ block, selectedBlockId, updateBlock
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-slate-500 dark:text-slate-400"></span>
<span className="text-slate-500 dark:text-slate-400">{m.optionsLabel}</span>
<button
type="button"
className="rounded border border-slate-300 bg-white px-2 py-0.5 text-[10px] text-slate-900 hover:bg-slate-100 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100 dark:hover:bg-slate-800"
@@ -137,7 +141,7 @@ export function FormSelectPropertiesPanel({ block, selectedBlockId, updateBlock
} as any);
}}
>
{m.addOptionButtonLabel}
</button>
</div>
<div className="space-y-2">
@@ -146,7 +150,7 @@ export function FormSelectPropertiesPanel({ block, selectedBlockId, updateBlock
<div className="grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto] items-center gap-1">
<input
className="flex-1 rounded border border-slate-300 bg-white px-2 py-1 text-[11px] text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
placeholder="라벨"
placeholder={m.optionLabelPlaceholder}
value={opt.label ?? ""}
onChange={(e) => {
const next = [...(((selectProps as any).options ?? []) as any[])];
@@ -161,7 +165,7 @@ export function FormSelectPropertiesPanel({ block, selectedBlockId, updateBlock
/>
<input
className="flex-1 rounded border border-slate-300 bg-white px-2 py-1 text-[11px] text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
placeholder="값(value)"
placeholder={m.optionValuePlaceholder}
value={opt.value ?? ""}
onChange={(e) => {
const next = [...(((selectProps as any).options ?? []) as any[])];
@@ -193,13 +197,13 @@ export function FormSelectPropertiesPanel({ block, selectedBlockId, updateBlock
</div>
<label className="inline-flex items-center gap-2">
<span className="text-slate-500"> .</span>
<span className="text-slate-500">{m.requiredNoticeText}</span>
</label>
<div className="mt-3 space-y-2 border-t border-slate-800 pt-3">
<h4 className="text-[11px] font-semibold text-slate-800 dark:text-slate-200"> </h4>
<h4 className="text-[11px] font-semibold text-slate-800 dark:text-slate-200">{m.styleSectionTitle}</h4>
<NumericPropertyControl
label="셀렉트 텍스트 크기 (px)"
unitLabel="(px)"
label={m.textSizeLabel}
unitLabel={m.textSizeUnitLabel}
value={(() => {
const raw = selectProps.fontSizeCustom ?? "";
const match = raw.match(/([0-9]+(?:\.[0-9]+)?)/);
@@ -216,16 +220,16 @@ export function FormSelectPropertiesPanel({ block, selectedBlockId, updateBlock
{ id: "lg", label: "L", value: 18 },
{ id: "xl", label: "XL", value: 20 },
]}
onChangeValue={(v) => {
onChangeValue={(v) =>
updateBlock(selectedBlockId, {
fontSizeCustom: `${v}px`,
} as any);
}}
} as any)
}
/>
{/* 줄간격 px 입력을 받아 preview 렌더러의 em 변환 근거로 사용한다 */}
<NumericPropertyControl
label="셀렉트 줄간격 (px)"
unitLabel="(px)"
label={m.lineHeightLabel}
unitLabel={m.lineHeightUnitLabel}
value={(() => {
const raw = selectProps.lineHeightCustom ?? "";
const match = raw.match(/([0-9]+(?:\.[0-9]+)?)/);
@@ -240,16 +244,16 @@ export function FormSelectPropertiesPanel({ block, selectedBlockId, updateBlock
{ id: "normal", label: "보통", value: 24 },
{ id: "loose", label: "루즈", value: 32 },
]}
onChangeValue={(v) => {
onChangeValue={(v) =>
updateBlock(selectedBlockId, {
lineHeightCustom: `${v}px`,
} as any);
}}
} as any)
}
/>
{/* 자간 px 입력을 제공하여 TDD 시나리오와 동기화한다 */}
<NumericPropertyControl
label="셀렉트 자간 (px)"
unitLabel="(px)"
label={m.letterSpacingLabel}
unitLabel={m.letterSpacingUnitLabel}
value={(() => {
const raw = selectProps.letterSpacingCustom ?? "";
const match = raw.match(/(-?[0-9]+(?:\.[0-9]+)?)/);
@@ -264,14 +268,14 @@ export function FormSelectPropertiesPanel({ block, selectedBlockId, updateBlock
{ id: "normal", label: "보통", value: 0 },
{ id: "wide", label: "넓게", value: 2 },
]}
onChangeValue={(v) => {
onChangeValue={(v) =>
updateBlock(selectedBlockId, {
letterSpacingCustom: `${v}px`,
} as any);
}}
} as any)
}
/>
<label className="flex flex-col gap-1 text-[11px] text-slate-500 dark:text-slate-400">
<span> </span>
<span>{m.widthModeLabel}</span>
<select
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-[11px] text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
value={selectProps.widthMode ?? "full"}
@@ -282,14 +286,14 @@ export function FormSelectPropertiesPanel({ block, selectedBlockId, updateBlock
}
>
<option value="auto"></option>
<option value="full"> </option>
<option value="fixed"> </option>
<option value="full"></option>
<option value="fixed"></option>
</select>
</label>
{(selectProps.widthMode ?? "full") === "fixed" && (
<NumericPropertyControl
label="필드 고정 너비"
unitLabel="(px)"
label={m.fixedWidthLabel}
unitLabel={m.fixedWidthUnitLabel}
value={selectProps.widthPx ?? 240}
min={80}
max={800}
@@ -299,16 +303,16 @@ export function FormSelectPropertiesPanel({ block, selectedBlockId, updateBlock
{ id: "md", label: "보통", value: 280 },
{ id: "lg", label: "넓게", value: 360 },
]}
onChangeValue={(v) => {
onChangeValue={(v) =>
updateBlock(selectedBlockId, {
widthPx: v,
} as any);
}}
} as any)
}
/>
)}
<NumericPropertyControl
label="셀렉트 가로 패딩 (px)"
unitLabel="(px)"
label={m.paddingXLabel}
unitLabel={m.paddingXUnitLabel}
value={typeof selectProps.paddingX === "number" ? selectProps.paddingX : 12}
min={0}
max={60}
@@ -327,8 +331,8 @@ export function FormSelectPropertiesPanel({ block, selectedBlockId, updateBlock
}
/>
<NumericPropertyControl
label="셀렉트 세로 패딩 (px)"
unitLabel="(px)"
label={m.paddingYLabel}
unitLabel={m.paddingYUnitLabel}
value={typeof selectProps.paddingY === "number" ? selectProps.paddingY : 10}
min={0}
max={40}
@@ -347,9 +351,9 @@ export function FormSelectPropertiesPanel({ block, selectedBlockId, updateBlock
}
/>
<ColorPickerField
label="필드 텍스트 색상"
ariaLabelColorInput="셀렉트 텍스트 색상 피커"
ariaLabelHexInput="셀렉트 텍스트 색상 HEX"
label={m.textColorLabel}
ariaLabelColorInput={m.textColorPickerAria}
ariaLabelHexInput={m.textColorHexAria}
value={
selectProps.textColorCustom && selectProps.textColorCustom.trim() !== ""
? selectProps.textColorCustom
@@ -368,9 +372,9 @@ export function FormSelectPropertiesPanel({ block, selectedBlockId, updateBlock
}}
/>
<ColorPickerField
label="필드 채움 색상"
ariaLabelColorInput="셀렉트 채움 색상 피커"
ariaLabelHexInput="셀렉트 채움 색상 HEX"
label={m.fillColorLabel}
ariaLabelColorInput={m.fillColorPickerAria}
ariaLabelHexInput={m.fillColorHexAria}
value={
selectProps.fillColorCustom && selectProps.fillColorCustom.trim() !== ""
? selectProps.fillColorCustom
@@ -389,9 +393,9 @@ export function FormSelectPropertiesPanel({ block, selectedBlockId, updateBlock
}}
/>
<ColorPickerField
label="필드 테두리 색상"
ariaLabelColorInput="셀렉트 테두리 색상 피커"
ariaLabelHexInput="셀렉트 테두리 색상 HEX"
label={m.strokeColorLabel}
ariaLabelColorInput={m.strokeColorPickerAria}
ariaLabelHexInput={m.strokeColorHexAria}
value={
selectProps.strokeColorCustom && selectProps.strokeColorCustom.trim() !== ""
? selectProps.strokeColorCustom
@@ -410,7 +414,7 @@ export function FormSelectPropertiesPanel({ block, selectedBlockId, updateBlock
}}
/>
<NumericPropertyControl
label="필드 모서리 둥글기"
label={m.borderRadiusLabel}
value={(() => {
const r = selectProps.borderRadius ?? "md";
return r === "none" ? 0 : r === "sm" ? 2 : r === "lg" ? 6 : r === "full" ? 8 : 4;
@@ -419,11 +423,11 @@ export function FormSelectPropertiesPanel({ block, selectedBlockId, updateBlock
max={8}
step={1}
presets={[
{ id: "none", label: "없음", value: 0 },
{ id: "sm", label: "작게", value: 2 },
{ id: "md", label: "보통", value: 4 },
{ id: "lg", label: "크게", value: 6 },
{ id: "full", label: "완전 둥글게", value: 8 },
{ id: "none", label: m.borderRadiusPresetNone, value: 0 },
{ id: "sm", label: m.borderRadiusPresetSmall, value: 2 },
{ id: "md", label: m.borderRadiusPresetMedium, value: 4 },
{ id: "lg", label: m.borderRadiusPresetLarge, value: 6 },
{ id: "full", label: m.borderRadiusPresetFull, value: 8 },
]}
onChangeValue={(v) => {
const next =
+86 -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);
}
};
@@ -879,6 +893,8 @@ function EditorPageInner() {
if (!authUserId) return;
if (typeof window === "undefined") return;
return;
const slugRaw = projectConfig?.slug;
const slug = typeof slugRaw === "string" ? slugRaw.trim() : "";
if (!slug || slug === "my-landing") return;
@@ -1063,7 +1079,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 +1097,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 +1107,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 +1123,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 +1132,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 +1141,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 +1156,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 +1169,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 +1182,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 +1195,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 +1240,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 +1251,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 +1259,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 +1272,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 +1297,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 +1315,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 +1335,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 +1346,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 +1421,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 +1445,10 @@ function SortableEditorBlock({
allBlocks,
renderBlocks,
updateBlock,
listItemToolbarMoveUpLabel,
listItemToolbarMoveDownLabel,
listItemToolbarIndentLabel,
listItemToolbarOutdentLabel,
}: SortableEditorBlockProps) {
const { attributes, listeners, setNodeRef, transform, transition } = useSortable({
id: block.id,
@@ -2172,7 +2183,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 +2193,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 +2207,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 +2221,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 +2235,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)}
+41 -39
View File
@@ -2,6 +2,8 @@
import { useCallback, useState } from "react";
import { useEditorStore } from "@/features/editor/state/editorStore";
import { useAppLocale } from "@/features/i18n/LocaleProvider";
import { getEditorSidebarMessages } from "@/features/i18n/messages/editorSidebar";
import {
FilePlus2,
ListChecks,
@@ -21,6 +23,8 @@ import {
// 좌측 블록/폼/템플릿 추가 사이드바를 분리한 컴포넌트
export function BlocksSidebar() {
const locale = useAppLocale();
const m = getEditorSidebarMessages(locale);
const addTextBlock = useEditorStore((state) => state.addTextBlock);
const addButtonBlock = useEditorStore((state) => state.addButtonBlock);
const addImageBlock = useEditorStore((state) => state.addImageBlock);
@@ -103,7 +107,7 @@ export function BlocksSidebar() {
>
<span className="inline-flex items-center gap-2">
<Pencil className="w-4 h-4 text-sky-400" aria-hidden="true" />
<span></span>
<span>{m.blocksTitle}</span>
</span>
</button>
</h2>
@@ -116,7 +120,7 @@ export function BlocksSidebar() {
>
<span className="inline-flex items-center gap-2">
<Type className="w-3 h-3" aria-hidden="true" />
<span></span>
<span>{m.blocksText}</span>
</span>
</button>
<button
@@ -126,7 +130,7 @@ export function BlocksSidebar() {
>
<span className="inline-flex items-center gap-2">
<MousePointerClick className="w-3 h-3" aria-hidden="true" />
<span></span>
<span>{m.blocksButton}</span>
</span>
</button>
<button
@@ -136,7 +140,7 @@ export function BlocksSidebar() {
>
<span className="inline-flex items-center gap-2">
<ImageIcon className="w-3 h-3" aria-hidden="true" />
<span></span>
<span>{m.blocksImage}</span>
</span>
</button>
<button
@@ -146,7 +150,7 @@ export function BlocksSidebar() {
>
<span className="inline-flex items-center gap-2">
<Video className="w-3 h-3" aria-hidden="true" />
<span></span>
<span>{m.blocksVideo}</span>
</span>
</button>
<button
@@ -156,7 +160,7 @@ export function BlocksSidebar() {
>
<span className="inline-flex items-center gap-2">
<Minus className="w-3 h-3" aria-hidden="true" />
<span></span>
<span>{m.blocksDivider}</span>
</span>
</button>
<button
@@ -166,7 +170,7 @@ export function BlocksSidebar() {
>
<span className="inline-flex items-center gap-2">
<List className="w-3 h-3" aria-hidden="true" />
<span></span>
<span>{m.blocksList}</span>
</span>
</button>
<button
@@ -176,7 +180,7 @@ export function BlocksSidebar() {
>
<span className="inline-flex items-center gap-2">
<LayoutTemplate className="w-3 h-3" aria-hidden="true" />
<span></span>
<span>{m.blocksSection}</span>
</span>
</button>
</>
@@ -192,7 +196,7 @@ export function BlocksSidebar() {
>
<span className="inline-flex items-center gap-2">
<ListChecks className="w-3 h-3" aria-hidden="true" />
<span> </span>
<span>{m.formsTitle}</span>
</span>
</button>
</h3>
@@ -205,7 +209,7 @@ export function BlocksSidebar() {
>
<span className="inline-flex items-center gap-2">
<FilePlus2 className="w-3 h-3" aria-hidden="true" />
<span> </span>
<span>{m.formsController}</span>
</span>
</button>
<button
@@ -215,7 +219,7 @@ export function BlocksSidebar() {
>
<span className="inline-flex items-center gap-2">
<FileText className="w-3 h-3" aria-hidden="true" />
<span>(Input)</span>
<span>{m.formsInput}</span>
</span>
</button>
<button
@@ -225,7 +229,7 @@ export function BlocksSidebar() {
>
<span className="inline-flex items-center gap-2">
<ListFilter className="w-3 h-3" aria-hidden="true" />
<span>(Select)</span>
<span>{m.formsSelect}</span>
</span>
</button>
<button
@@ -235,7 +239,7 @@ export function BlocksSidebar() {
>
<span className="inline-flex items-center gap-2">
<CircleDot className="w-3 h-3" aria-hidden="true" />
<span> (Radio)</span>
<span>{m.formsRadio}</span>
</span>
</button>
<button
@@ -245,7 +249,7 @@ export function BlocksSidebar() {
>
<span className="inline-flex items-center gap-2">
<SquareCheck className="w-3 h-3" aria-hidden="true" />
<span> (Checkbox)</span>
<span>{m.formsCheckbox}</span>
</span>
</button>
</>
@@ -262,7 +266,7 @@ export function BlocksSidebar() {
>
<span className="inline-flex items-center gap-2">
<FilePlus2 className="w-3 h-3" aria-hidden="true" />
<span>릿</span>
<span>{m.templatesTitle}</span>
</span>
</button>
</h3>
@@ -270,7 +274,7 @@ export function BlocksSidebar() {
{isTemplatesOpen && (
<div className="space-y-3">
<div className="space-y-2">
<p className="text-[10px] font-semibold text-slate-600 dark:text-slate-400"> · CTA</p>
<p className="text-[10px] font-semibold text-slate-600 dark:text-slate-400">{m.templatesGroupHeroCta}</p>
<div className="space-y-2">
<div className="rounded border border-slate-200 bg-slate-50 p-2 space-y-1 dark:border-slate-800 dark:bg-slate-950/50">
<div className="flex items-center justify-between gap-2">
@@ -279,7 +283,7 @@ export function BlocksSidebar() {
className="flex-1 rounded border border-slate-200 bg-slate-50 px-3 py-2 text-left text-xs 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={handleAddHeroTemplate}
>
Hero 릿
{m.templateHeroLabel}
</button>
<div
data-testid="template-preview-hero"
@@ -292,9 +296,7 @@ export function BlocksSidebar() {
</div>
</div>
</div>
<p className="text-[10px] text-slate-400 leading-snug">
Hero ( + + )
</p>
<p className="text-[10px] text-slate-400 leading-snug">{m.templateHeroDescription}</p>
</div>
<div className="rounded border border-slate-200 bg-slate-50 p-2 space-y-1 dark:border-slate-800 dark:bg-slate-950/50">
@@ -304,7 +306,7 @@ export function BlocksSidebar() {
className="flex-1 rounded border border-slate-200 bg-slate-50 px-3 py-2 text-left text-xs 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={handleAddCtaTemplate}
>
CTA 릿
{m.templateCtaLabel}
</button>
<div
data-testid="template-preview-cta"
@@ -319,13 +321,13 @@ export function BlocksSidebar() {
</div>
</div>
</div>
<p className="text-[10px] text-slate-400 leading-snug">(CTA) </p>
<p className="text-[10px] text-slate-400 leading-snug">{m.templateCtaDescription}</p>
</div>
</div>
</div>
<div className="space-y-2">
<p className="text-[10px] font-semibold text-slate-600 dark:text-slate-400"> </p>
<p className="text-[10px] font-semibold text-slate-600 dark:text-slate-400">{m.templatesGroupContent}</p>
<div className="space-y-2">
<div className="rounded border border-slate-200 bg-slate-50 p-2 space-y-1 dark:border-slate-800 dark:bg-slate-950/50">
<div className="flex items-center justify-between gap-2">
@@ -334,7 +336,7 @@ export function BlocksSidebar() {
className="flex-1 rounded border border-slate-200 bg-slate-50 px-3 py-2 text-left text-xs 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={handleAddFeaturesTemplate}
>
릿
{m.templateFeaturesLabel}
</button>
<div
data-testid="template-preview-features"
@@ -354,7 +356,7 @@ export function BlocksSidebar() {
</div>
</div>
</div>
<p className="text-[10px] text-slate-400 leading-snug">3 </p>
<p className="text-[10px] text-slate-400 leading-snug">{m.templateFeaturesDescription}</p>
</div>
<div className="rounded border border-slate-200 bg-slate-50 p-2 space-y-1 dark:border-slate-800 dark:bg-slate-950/50">
@@ -364,7 +366,7 @@ export function BlocksSidebar() {
className="flex-1 rounded border border-slate-200 bg-slate-50 px-3 py-2 text-left text-xs 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={handleAddFaqTemplate}
>
FAQ 릿
{m.templateFaqLabel}
</button>
<div
data-testid="template-preview-faq"
@@ -380,7 +382,7 @@ export function BlocksSidebar() {
</div>
</div>
</div>
<p className="text-[10px] text-slate-400 leading-snug"> (FAQ) </p>
<p className="text-[10px] text-slate-400 leading-snug">{m.templateFaqDescription}</p>
</div>
<div className="rounded border border-slate-200 bg-slate-50 p-2 space-y-1 dark:border-slate-800 dark:bg-slate-950/50">
@@ -390,7 +392,7 @@ export function BlocksSidebar() {
className="flex-1 rounded border border-slate-200 bg-slate-50 px-3 py-2 text-left text-xs 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={handleAddPricingTemplate}
>
릿
{m.templatePricingLabel}
</button>
<div
data-testid="template-preview-pricing"
@@ -411,7 +413,7 @@ export function BlocksSidebar() {
</div>
</div>
</div>
<p className="text-[10px] text-slate-400 leading-snug">/ </p>
<p className="text-[10px] text-slate-400 leading-snug">{m.templatePricingDescription}</p>
</div>
<div className="rounded border border-slate-200 bg-slate-50 p-2 space-y-1 dark:border-slate-800 dark:bg-slate-950/50">
@@ -421,7 +423,7 @@ export function BlocksSidebar() {
className="flex-1 rounded border border-slate-200 bg-slate-50 px-3 py-2 text-left text-xs 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={handleAddBlogTemplate}
>
릿
{m.templateBlogLabel}
</button>
<div
data-testid="template-preview-blog"
@@ -444,13 +446,13 @@ export function BlocksSidebar() {
</div>
</div>
</div>
<p className="text-[10px] text-slate-400 leading-snug"> </p>
<p className="text-[10px] text-slate-400 leading-snug">{m.templateBlogDescription}</p>
</div>
</div>
</div>
<div className="space-y-2">
<p className="text-[10px] font-semibold text-slate-600 dark:text-slate-400">/</p>
<p className="text-[10px] font-semibold text-slate-600 dark:text-slate-400">{m.templatesGroupTrust}</p>
<div className="space-y-2">
<div className="rounded border border-slate-200 bg-slate-50 p-2 space-y-1 dark:border-slate-800 dark:bg-slate-950/50">
<div className="flex items-center justify-between gap-2">
@@ -459,7 +461,7 @@ export function BlocksSidebar() {
className="flex-1 rounded border border-slate-200 bg-slate-50 px-3 py-2 text-left text-xs 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={handleAddTestimonialsTemplate}
>
릿
{m.templateTestimonialsLabel}
</button>
<div
data-testid="template-preview-testimonials"
@@ -479,7 +481,7 @@ export function BlocksSidebar() {
</div>
</div>
</div>
<p className="text-[10px] text-slate-400 leading-snug"> (Testimonials) </p>
<p className="text-[10px] text-slate-400 leading-snug">{m.templateTestimonialsDescription}</p>
</div>
<div className="rounded border border-slate-200 bg-slate-50 p-2 space-y-1 dark:border-slate-800 dark:bg-slate-950/50">
@@ -489,7 +491,7 @@ export function BlocksSidebar() {
className="flex-1 rounded border border-slate-200 bg-slate-50 px-3 py-2 text-left text-xs 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={handleAddTeamTemplate}
>
Team 릿
{m.templateTeamLabel}
</button>
<div
data-testid="template-preview-team"
@@ -512,13 +514,13 @@ export function BlocksSidebar() {
</div>
</div>
</div>
<p className="text-[10px] text-slate-400 leading-snug"> </p>
<p className="text-[10px] text-slate-400 leading-snug">{m.templateTeamDescription}</p>
</div>
</div>
</div>
<div className="space-y-2">
<p className="text-[10px] font-semibold text-slate-600 dark:text-slate-400">/</p>
<p className="text-[10px] font-semibold text-slate-600 dark:text-slate-400">{m.templatesGroupFooter}</p>
<div className="space-y-2">
<div className="rounded border border-slate-200 bg-slate-50 p-2 space-y-1 dark:border-slate-800 dark:bg-slate-950/50">
<div className="flex items-center justify-between gap-2">
@@ -527,7 +529,7 @@ export function BlocksSidebar() {
className="flex-1 rounded border border-slate-200 bg-slate-50 px-3 py-2 text-left text-xs 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={handleAddFooterTemplate}
>
Footer 릿
{m.templateFooterLabel}
</button>
<div
data-testid="template-preview-footer"
@@ -541,7 +543,7 @@ export function BlocksSidebar() {
<div className="flex-1 h-[1px] bg-slate-700/40" />
</div>
</div>
<p className="text-[10px] text-slate-400 leading-snug"> </p>
<p className="text-[10px] text-slate-400 leading-snug">{m.templateFooterDescription}</p>
</div>
</div>
</div>
+78 -74
View File
@@ -3,6 +3,8 @@
import type { ButtonBlockProps } from "@/features/editor/state/editorStore";
import { NumericPropertyControl } from "@/features/editor/components/NumericPropertyControl";
import { ColorPickerField, TEXT_COLOR_PALETTE } from "@/features/editor/components/ColorPickerField";
import { useAppLocale } from "@/features/i18n/LocaleProvider";
import { getEditorButtonPanelMessages } from "@/features/i18n/messages/editorButtonPanel";
export type ButtonPropertiesPanelProps = {
buttonProps: ButtonBlockProps;
@@ -11,6 +13,8 @@ export type ButtonPropertiesPanelProps = {
};
export function ButtonPropertiesPanel({ buttonProps, selectedBlockId, updateBlock }: ButtonPropertiesPanelProps) {
const locale = useAppLocale();
const m = getEditorButtonPanelMessages(locale);
const imageSource: "none" | "url" | "upload" = (() => {
const hasSrc = (buttonProps.imageSrc ?? "").trim() !== "";
if (!hasSrc) return "none";
@@ -22,10 +26,10 @@ export function ButtonPropertiesPanel({ buttonProps, selectedBlockId, updateBloc
<>
<div className="space-y-1">
<label className="flex flex-col gap-1 text-xs text-slate-400">
<span> </span>
<span>{m.buttonTextLabel}</span>
<textarea
className="w-full min-h-[60px] 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"
aria-label="버튼 텍스트"
aria-label={m.buttonTextAria}
value={buttonProps.label}
onChange={(e) => {
updateBlock(selectedBlockId, { label: e.target.value } as any);
@@ -34,14 +38,14 @@ export function ButtonPropertiesPanel({ buttonProps, selectedBlockId, updateBloc
</label>
</div>
<div className="mt-3 space-y-2 border-t border-slate-800 pt-3 text-xs text-slate-400">
<h4 className="text-[11px] font-semibold text-slate-800 dark:text-slate-200"> </h4>
<h4 className="text-[11px] font-semibold text-slate-800 dark:text-slate-200">{m.imageSectionTitle}</h4>
<div className="space-y-1">
<label className="flex flex-col gap-1">
<span> </span>
<span>{m.imageSourceLabel}</span>
<select
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-[11px] text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
aria-label="버튼 이미지 소스"
aria-label={m.imageSourceAria}
value={imageSource}
onChange={(e) => {
const next = e.target.value as "none" | "url" | "upload";
@@ -62,9 +66,9 @@ export function ButtonPropertiesPanel({ buttonProps, selectedBlockId, updateBloc
}
}}
>
<option value="none"> </option>
<option value="url">URL</option>
<option value="upload"> </option>
<option value="none">{m.imageSourceOptionNone}</option>
<option value="url">{m.imageSourceOptionUrl}</option>
<option value="upload">{m.imageSourceOptionUpload}</option>
</select>
</label>
</div>
@@ -72,10 +76,10 @@ export function ButtonPropertiesPanel({ buttonProps, selectedBlockId, updateBloc
{imageSource === "url" && (
<div className="space-y-1">
<label className="flex flex-col gap-1">
<span> URL</span>
<span>{m.imageUrlLabel}</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"
aria-label="버튼 이미지 URL"
aria-label={m.imageUrlAria}
value={buttonProps.imageSrc ?? ""}
onChange={(e) => {
const value = e.target.value;
@@ -93,12 +97,12 @@ export function ButtonPropertiesPanel({ buttonProps, selectedBlockId, updateBloc
{imageSource === "upload" && (
<div className="space-y-1">
<label className="flex flex-col gap-1">
<span> </span>
<span>{m.imageUploadLabel}</span>
<input
type="file"
accept="image/*"
className="w-full text-[11px] text-slate-300 file:mr-2 file:rounded file:border file:border-slate-700 file:bg-slate-800 file:px-2 file:py-1 file:text-[11px] file:text-slate-100 hover:file:bg-slate-700"
aria-label="버튼 이미지 파일 업로드"
aria-label={m.imageUploadAria}
onChange={async (event) => {
const file = event.target.files?.[0];
if (!file) return;
@@ -113,7 +117,7 @@ export function ButtonPropertiesPanel({ buttonProps, selectedBlockId, updateBloc
});
if (!response.ok) {
console.error("버튼 이미지 업로드 실패", await response.text());
console.error("Button image upload failed", await response.text());
return;
}
@@ -126,7 +130,7 @@ export function ButtonPropertiesPanel({ buttonProps, selectedBlockId, updateBloc
imageAssetId: data.id,
} as any);
} catch (error) {
console.error("버튼 이미지 업로드 중 오류", error);
console.error("Error while uploading button image", error);
} finally {
event.target.value = "";
}
@@ -140,10 +144,10 @@ export function ButtonPropertiesPanel({ buttonProps, selectedBlockId, updateBloc
<>
<div className="space-y-1">
<label className="flex flex-col gap-1">
<span> </span>
<span>{m.imageAltLabel}</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"
aria-label="버튼 이미지 대체 텍스트"
aria-label={m.imageAltAria}
value={buttonProps.imageAlt ?? ""}
onChange={(e) => {
updateBlock(selectedBlockId, { imageAlt: e.target.value } as any);
@@ -154,10 +158,10 @@ export function ButtonPropertiesPanel({ buttonProps, selectedBlockId, updateBloc
<div className="space-y-1">
<label className="flex flex-col gap-1">
<span> </span>
<span>{m.imagePlacementLabel}</span>
<select
className="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"
aria-label="버튼 이미지 위치"
aria-label={m.imagePlacementAria}
value={buttonProps.imagePlacement ?? "left"}
onChange={(e) => {
updateBlock(selectedBlockId, {
@@ -165,10 +169,10 @@ export function ButtonPropertiesPanel({ buttonProps, selectedBlockId, updateBloc
} as any);
}}
>
<option value="left"> </option>
<option value="right"> </option>
<option value="top"> </option>
<option value="bottom"> </option>
<option value="left">{m.imagePlacementOptionLeft}</option>
<option value="right">{m.imagePlacementOptionRight}</option>
<option value="top">{m.imagePlacementOptionTop}</option>
<option value="bottom">{m.imagePlacementOptionBottom}</option>
</select>
</label>
</div>
@@ -177,7 +181,7 @@ export function ButtonPropertiesPanel({ buttonProps, selectedBlockId, updateBloc
</div>
<div className="space-y-1">
<NumericPropertyControl
label="가로 패딩 (px)"
label={m.paddingXLabel}
unitLabel="(px)"
value={buttonProps.paddingX ?? 16}
min={0}
@@ -199,7 +203,7 @@ export function ButtonPropertiesPanel({ buttonProps, selectedBlockId, updateBloc
</div>
<div className="space-y-1">
<NumericPropertyControl
label="세로 패딩 (px)"
label={m.paddingYLabel}
unitLabel="(px)"
value={buttonProps.paddingY ?? 10}
min={0}
@@ -221,10 +225,10 @@ export function ButtonPropertiesPanel({ buttonProps, selectedBlockId, updateBloc
</div>
<div className="space-y-1">
<label className="flex flex-col gap-1 text-xs text-slate-400">
<span> </span>
<span>{m.linkLabel}</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"
aria-label="버튼 링크"
aria-label={m.linkAria}
value={buttonProps.href}
onChange={(e) => {
updateBlock(selectedBlockId, { href: e.target.value } as any);
@@ -234,19 +238,19 @@ export function ButtonPropertiesPanel({ buttonProps, selectedBlockId, updateBloc
</div>
<div className="space-y-1">
<label className="flex flex-col gap-1 text-xs text-slate-400">
<span></span>
<span>{m.alignLabel}</span>
<select
className="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"
aria-label="버튼 정렬"
aria-label={m.alignAria}
value={buttonProps.align ?? "left"}
onChange={(e) => {
const value = e.target.value as NonNullable<ButtonBlockProps["align"]>;
updateBlock(selectedBlockId, { align: value } as any);
}}
>
<option value="left"></option>
<option value="center"></option>
<option value="right"></option>
<option value="left">{m.alignOptionLeft}</option>
<option value="center">{m.alignOptionCenter}</option>
<option value="right">{m.alignOptionRight}</option>
</select>
</label>
</div>
@@ -255,9 +259,9 @@ export function ButtonPropertiesPanel({ buttonProps, selectedBlockId, updateBloc
</div>
<div className="space-y-1">
<ColorPickerField
label="텍스트 색상"
ariaLabelColorInput="버튼 텍스트 색상 피커"
ariaLabelHexInput="버튼 텍스트 색상 HEX"
label={m.textColorLabel}
ariaLabelColorInput={m.textColorPickerAria}
ariaLabelHexInput={m.textColorHexAria}
value={
buttonProps.textColorCustom && buttonProps.textColorCustom.trim() !== ""
? buttonProps.textColorCustom
@@ -278,27 +282,27 @@ export function ButtonPropertiesPanel({ buttonProps, selectedBlockId, updateBloc
</div>
<div className="space-y-1">
<label className="flex flex-col gap-1 text-xs text-slate-400">
<span></span>
<span>{m.styleLabel}</span>
<select
className="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"
aria-label="버튼 스타일"
aria-label={m.styleAria}
value={buttonProps.variant ?? "solid"}
onChange={(e) => {
const value = e.target.value as NonNullable<ButtonBlockProps["variant"]>;
updateBlock(selectedBlockId, { variant: value } as any);
}}
>
<option value="solid"></option>
<option value="outline"></option>
<option value="ghost"></option>
<option value="solid">{m.styleOptionSolid}</option>
<option value="outline">{m.styleOptionOutline}</option>
<option value="ghost">{m.styleOptionGhost}</option>
</select>
</label>
</div>
<div className="space-y-1">
<ColorPickerField
label="채움 색상"
ariaLabelColorInput="버튼 채움 색상 피커"
ariaLabelHexInput="버튼 채움 색상 HEX"
label={m.fillColorLabel}
ariaLabelColorInput={m.fillColorPickerAria}
ariaLabelHexInput={m.fillColorHexAria}
value={
buttonProps.fillColorCustom && buttonProps.fillColorCustom.trim() !== ""
? buttonProps.fillColorCustom
@@ -319,9 +323,9 @@ export function ButtonPropertiesPanel({ buttonProps, selectedBlockId, updateBloc
</div>
<div className="space-y-1">
<ColorPickerField
label="외곽선 색상"
ariaLabelColorInput="버튼 외곽선 색상 피커"
ariaLabelHexInput="버튼 외곽선 색상 HEX"
label={m.strokeColorLabel}
ariaLabelColorInput={m.strokeColorPickerAria}
ariaLabelHexInput={m.strokeColorHexAria}
value={
buttonProps.strokeColorCustom && buttonProps.strokeColorCustom.trim() !== ""
? buttonProps.strokeColorCustom
@@ -342,7 +346,7 @@ export function ButtonPropertiesPanel({ buttonProps, selectedBlockId, updateBloc
</div>
<div className="space-y-1">
<NumericPropertyControl
label="모서리 둥글기"
label={m.borderRadiusLabel}
value={(() => {
const r = buttonProps.borderRadius ?? "md";
return r === "none" ? 0 : r === "sm" ? 1 : r === "lg" ? 3 : r === "full" ? 4 : 2;
@@ -351,11 +355,11 @@ export function ButtonPropertiesPanel({ buttonProps, selectedBlockId, updateBloc
max={4}
step={1}
presets={[
{ id: "none", label: "없음", value: 0 },
{ id: "sm", label: "작게", value: 1 },
{ id: "md", label: "보통", value: 2 },
{ id: "lg", label: "크게", value: 3 },
{ id: "full", label: "완전 둥글게", value: 4 },
{ id: "none", label: m.borderRadiusPresetNone, value: 0 },
{ id: "sm", label: m.borderRadiusPresetSmall, value: 1 },
{ id: "md", label: m.borderRadiusPresetMedium, value: 2 },
{ id: "lg", label: m.borderRadiusPresetLarge, value: 3 },
{ id: "full", label: m.borderRadiusPresetFull, value: 4 },
]}
onChangeValue={(v) => {
const next =
@@ -366,10 +370,10 @@ export function ButtonPropertiesPanel({ buttonProps, selectedBlockId, updateBloc
</div>
<div className="space-y-1">
<label className="flex flex-col gap-1 text-xs text-slate-400">
<span> </span>
<span>{m.widthModeLabel}</span>
<select
className="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"
aria-label="버튼 너비 모드"
aria-label={m.widthModeAria}
value={buttonProps.widthMode ?? (buttonProps.fullWidth ? "full" : "auto")}
onChange={(e) => {
updateBlock(selectedBlockId, {
@@ -377,23 +381,23 @@ export function ButtonPropertiesPanel({ buttonProps, selectedBlockId, updateBloc
} as any);
}}
>
<option value="auto"></option>
<option value="full"> </option>
<option value="fixed"> </option>
<option value="auto">{m.widthModeOptionAuto}</option>
<option value="full">{m.widthModeOptionFull}</option>
<option value="fixed">{m.widthModeOptionFixed}</option>
</select>
</label>
{(buttonProps.widthMode ?? (buttonProps.fullWidth ? "full" : "auto")) === "fixed" && (
<NumericPropertyControl
label="버튼 고정 너비"
unitLabel="(px)"
label={m.fixedWidthLabel}
unitLabel={m.fixedWidthUnitLabel}
value={buttonProps.widthPx ?? 240}
min={80}
max={600}
step={10}
presets={[
{ id: "sm", label: "작게", value: 180 },
{ id: "md", label: "보통", value: 240 },
{ id: "lg", label: "넓게", value: 320 },
{ id: "sm", label: m.fixedWidthPresetSmall, value: 180 },
{ id: "md", label: m.fixedWidthPresetMedium, value: 240 },
{ id: "lg", label: m.fixedWidthPresetLarge, value: 320 },
]}
onChangeValue={(v) => {
updateBlock(selectedBlockId, {
@@ -405,8 +409,8 @@ export function ButtonPropertiesPanel({ buttonProps, selectedBlockId, updateBloc
</div>
<div className="space-y-1">
<NumericPropertyControl
label="버튼 크기"
unitLabel="(px)"
label={m.fontSizeLabel}
unitLabel={m.fontSizeUnitLabel}
value={(() => {
const raw = buttonProps.fontSizeCustom ?? "";
const match = raw.match(/([0-9]+(?:\.[0-9]+)?)/);
@@ -437,7 +441,7 @@ export function ButtonPropertiesPanel({ buttonProps, selectedBlockId, updateBloc
</div>
<div className="space-y-1">
<NumericPropertyControl
label="줄 간격"
label={m.lineHeightLabel}
value={(() => {
const raw = buttonProps.lineHeightCustom ?? "";
const match = raw.match(/(-?[0-9]+(?:\.[0-9]+)?)/);
@@ -448,9 +452,9 @@ export function ButtonPropertiesPanel({ buttonProps, selectedBlockId, updateBloc
max={3}
step={0.05}
presets={[
{ id: "tight", label: "좁게", value: 1.1 },
{ id: "normal", label: "보통", value: 1.4 },
{ id: "relaxed", label: "넓게", value: 1.8 },
{ id: "tight", label: m.lineHeightPresetTight, value: 1.1 },
{ id: "normal", label: m.lineHeightPresetNormal, value: 1.4 },
{ id: "relaxed", label: m.lineHeightPresetRelaxed, value: 1.8 },
]}
onChangeValue={(v) => {
updateBlock(selectedBlockId, {
@@ -461,8 +465,8 @@ export function ButtonPropertiesPanel({ buttonProps, selectedBlockId, updateBloc
</div>
<div className="space-y-1">
<NumericPropertyControl
label="글자 간격"
unitLabel="(px)"
label={m.letterSpacingLabel}
unitLabel={m.letterSpacingUnitLabel}
value={(() => {
const raw = buttonProps.letterSpacingCustom ?? "";
const match = raw.match(/(-?[0-9]+(?:\.[0-9]+)?)/);
@@ -476,11 +480,11 @@ export function ButtonPropertiesPanel({ buttonProps, selectedBlockId, updateBloc
max={32}
step={1}
presets={[
{ id: "tighter", label: "아주 좁게", value: -1.5 },
{ id: "tight", label: "좁게", value: -0.5 },
{ id: "normal", label: "보통", value: 0 },
{ id: "wide", label: "넓게", value: 1 },
{ id: "wider", label: "아주 넓게", value: 2 },
{ id: "tighter", label: m.letterSpacingPresetTighter, value: -1.5 },
{ id: "tight", label: m.letterSpacingPresetTight, value: -0.5 },
{ id: "normal", label: m.letterSpacingPresetNormal, value: 0 },
{ id: "wide", label: m.letterSpacingPresetWide, value: 1 },
{ id: "wider", label: m.letterSpacingPresetWider, value: 2 },
]}
onChangeValue={(px) => {
const em = px / 16;
@@ -3,6 +3,8 @@
import type { DividerBlockProps } from "@/features/editor/state/editorStore";
import { NumericPropertyControl } from "@/features/editor/components/NumericPropertyControl";
import { ColorPickerField, TEXT_COLOR_PALETTE } from "@/features/editor/components/ColorPickerField";
import { useAppLocale } from "@/features/i18n/LocaleProvider";
import { getEditorDividerPropertiesPanelMessages } from "@/features/i18n/messages/editorDividerPropertiesPanel";
export type DividerPropertiesPanelProps = {
dividerProps: DividerBlockProps;
@@ -11,77 +13,80 @@ export type DividerPropertiesPanelProps = {
};
export function DividerPropertiesPanel({ dividerProps, selectedBlockId, updateBlock }: DividerPropertiesPanelProps) {
const locale = useAppLocale();
const m = getEditorDividerPropertiesPanelMessages(locale);
return (
<>
<div className="space-y-1">
<label className="flex flex-col gap-1 text-xs text-slate-400">
<span></span>
<span>{m.alignLabel}</span>
<select
className="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"
aria-label="구분선 정렬"
aria-label={m.alignAria}
value={dividerProps.align}
onChange={(e) => {
const value = e.target.value as DividerBlockProps["align"];
updateBlock(selectedBlockId, { align: value } as any);
}}
>
<option value="left"></option>
<option value="center"></option>
<option value="right"></option>
<option value="left">{m.alignOptionLeft}</option>
<option value="center">{m.alignOptionCenter}</option>
<option value="right">{m.alignOptionRight}</option>
</select>
</label>
</div>
<div className="space-y-1">
<label className="flex flex-col gap-1 text-xs text-slate-400">
<span></span>
<span>{m.thicknessLabel}</span>
<select
className="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"
aria-label="구분선 두께"
aria-label={m.thicknessAria}
value={dividerProps.thickness}
onChange={(e) => {
const value = e.target.value as DividerBlockProps["thickness"];
updateBlock(selectedBlockId, { thickness: value } as any);
}}
>
<option value="thin"></option>
<option value="medium"></option>
<option value="thin">{m.thicknessOptionThin}</option>
<option value="medium">{m.thicknessOptionMedium}</option>
</select>
</label>
</div>
<div className="mt-3 space-y-2 border-t border-slate-800 pt-3 text-xs text-slate-400">
<h4 className="text-[11px] font-semibold text-slate-800 dark:text-slate-200"> </h4>
<h4 className="text-[11px] font-semibold text-slate-800 dark:text-slate-200">{m.styleSectionTitle}</h4>
{/* 길이/너비 모드 */}
<label className="flex flex-col gap-1">
<span> </span>
<span>{m.lengthModeLabel}</span>
<select
className="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"
aria-label="구분선 길이 모드"
aria-label={m.lengthModeAria}
value={dividerProps.widthMode ?? "full"}
onChange={(e) => {
const value = e.target.value as NonNullable<DividerBlockProps["widthMode"]>;
updateBlock(selectedBlockId, { widthMode: value } as any);
}}
>
<option value="auto"> </option>
<option value="full"> </option>
<option value="fixed"> (px)</option>
<option value="auto">{m.lengthModeOptionAuto}</option>
<option value="full">{m.lengthModeOptionFull}</option>
<option value="fixed">{m.lengthModeOptionFixed}</option>
</select>
</label>
{(dividerProps.widthMode ?? "full") === "fixed" && (
<NumericPropertyControl
label="고정 길이 (px)"
unitLabel="(px)"
label={m.fixedLengthLabel}
unitLabel={m.fixedLengthUnitLabel}
value={dividerProps.widthPx ?? 320}
min={40}
max={1200}
step={10}
presets={[
{ id: "sm", label: "짧게", value: 240 },
{ id: "md", label: "보통", value: 320 },
{ id: "lg", label: "길게", value: 480 },
{ id: "sm", label: m.fixedLengthPresetShort, value: 240 },
{ id: "md", label: m.fixedLengthPresetNormal, value: 320 },
{ id: "lg", label: m.fixedLengthPresetLong, value: 480 },
]}
onChangeValue={(v) => {
updateBlock(selectedBlockId, { widthPx: v } as any);
@@ -91,9 +96,9 @@ export function DividerPropertiesPanel({ dividerProps, selectedBlockId, updateBl
{/* 색상 */}
<ColorPickerField
label="선 색상"
ariaLabelColorInput="구분선 색상 피커"
ariaLabelHexInput="구분선 색상 HEX"
label={m.colorLabel}
ariaLabelColorInput={m.colorPickerAria}
ariaLabelHexInput={m.colorHexAria}
value={
dividerProps.colorHex && dividerProps.colorHex.trim() !== ""
? dividerProps.colorHex
@@ -110,8 +115,8 @@ export function DividerPropertiesPanel({ dividerProps, selectedBlockId, updateBl
{/* 상하 여백 (슬라이더) */}
<NumericPropertyControl
label="위/아래 여백"
unitLabel="(px)"
label={m.marginLabel}
unitLabel={m.marginUnitLabel}
value={(() => {
if (typeof dividerProps.marginYPx === "number") {
return dividerProps.marginYPx;
@@ -123,9 +128,9 @@ export function DividerPropertiesPanel({ dividerProps, selectedBlockId, updateBl
max={50}
step={2}
presets={[
{ id: "sm", label: "작게", value: 8 },
{ id: "md", label: "보통", value: 16 },
{ id: "lg", label: "크게", value: 24 },
{ id: "sm", label: m.marginPresetSmall, value: 8 },
{ id: "md", label: m.marginPresetNormal, value: 16 },
{ id: "lg", label: m.marginPresetLarge, value: 24 },
]}
onChangeValue={(v) => {
updateBlock(selectedBlockId, { marginYPx: v } as any);
+40 -36
View File
@@ -3,6 +3,8 @@
import type { ImageBlockProps } from "@/features/editor/state/editorStore";
import { NumericPropertyControl } from "@/features/editor/components/NumericPropertyControl";
import { ColorPickerField, TEXT_COLOR_PALETTE } from "@/features/editor/components/ColorPickerField";
import { useAppLocale } from "@/features/i18n/LocaleProvider";
import { getEditorImagePanelMessages } from "@/features/i18n/messages/editorImagePanel";
export type ImagePropertiesPanelProps = {
imageProps: ImageBlockProps;
@@ -11,6 +13,8 @@ export type ImagePropertiesPanelProps = {
};
export function ImagePropertiesPanel({ imageProps, selectedBlockId, updateBlock }: ImagePropertiesPanelProps) {
const locale = useAppLocale();
const m = getEditorImagePanelMessages(locale);
const source: "url" | "upload" =
imageProps.sourceType === "asset" || (imageProps.src && imageProps.src.startsWith("/api/image/"))
? "upload"
@@ -20,10 +24,10 @@ export function ImagePropertiesPanel({ imageProps, selectedBlockId, updateBlock
<>
<div className="space-y-1">
<label className="flex flex-col gap-1 text-xs text-slate-400">
<span> </span>
<span>{m.imageSourceLabel}</span>
<select
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-[11px] text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
aria-label="이미지 소스"
aria-label={m.imageSourceAria}
value={source}
onChange={(e) => {
const next = e.target.value as "url" | "upload";
@@ -39,8 +43,8 @@ export function ImagePropertiesPanel({ imageProps, selectedBlockId, updateBlock
}
}}
>
<option value="url">URL</option>
<option value="upload"> </option>
<option value="url">{m.imageSourceOptionUrl}</option>
<option value="upload">{m.imageSourceOptionUpload}</option>
</select>
</label>
</div>
@@ -48,10 +52,10 @@ export function ImagePropertiesPanel({ imageProps, selectedBlockId, updateBlock
{source === "url" && (
<div className="space-y-1">
<label className="flex flex-col gap-1 text-xs text-slate-400">
<span> URL</span>
<span>{m.imageUrlLabel}</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"
aria-label="이미지 URL"
aria-label={m.imageUrlAria}
value={imageProps.src}
onChange={(e) => {
const value = e.target.value;
@@ -69,12 +73,12 @@ export function ImagePropertiesPanel({ imageProps, selectedBlockId, updateBlock
{source === "upload" && (
<div className="space-y-1">
<label className="flex flex-col gap-1 text-xs text-slate-400">
<span> </span>
<span>{m.imageUploadLabel}</span>
<input
type="file"
accept="image/*"
className="w-full text-[11px] text-slate-300 file:mr-2 file:rounded file:border file:border-slate-700 file:bg-slate-800 file:px-2 file:py-1 file:text-[11px] file:text-slate-100 hover:file:bg-slate-700"
aria-label="이미지 파일 업로드"
aria-label={m.imageUploadAria}
onChange={async (event) => {
const file = event.target.files?.[0];
if (!file) return;
@@ -89,7 +93,7 @@ export function ImagePropertiesPanel({ imageProps, selectedBlockId, updateBlock
});
if (!response.ok) {
console.error("이미지 업로드 실패", await response.text());
console.error("Image upload failed", await response.text());
return;
}
@@ -102,7 +106,7 @@ export function ImagePropertiesPanel({ imageProps, selectedBlockId, updateBlock
assetId: data.id,
} as any);
} catch (error) {
console.error("이미지 업로드 중 오류", error);
console.error("Error while uploading image", error);
} finally {
event.target.value = "";
}
@@ -113,10 +117,10 @@ export function ImagePropertiesPanel({ imageProps, selectedBlockId, updateBlock
)}
<div className="space-y-1">
<label className="flex flex-col gap-1 text-xs text-slate-400">
<span> </span>
<span>{m.altLabel}</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"
aria-label="대체 텍스트"
aria-label={m.altAria}
value={imageProps.alt}
onChange={(e) => {
updateBlock(selectedBlockId, { alt: e.target.value } as any);
@@ -126,14 +130,14 @@ export function ImagePropertiesPanel({ imageProps, selectedBlockId, updateBlock
</div>
<div className="mt-3 space-y-2 border-t border-slate-800 pt-3 text-xs text-slate-400">
<h4 className="text-[11px] font-semibold text-slate-800 dark:text-slate-200"> </h4>
<h4 className="text-[11px] font-semibold text-slate-800 dark:text-slate-200">{m.styleSectionTitle}</h4>
{/* 카드 배경색 */}
<div className="space-y-1">
<ColorPickerField
label="카드 배경색"
ariaLabelColorInput="이미지 카드 배경색 피커"
ariaLabelHexInput="이미지 카드 배경색 HEX"
label={m.cardBackgroundLabel}
ariaLabelColorInput={m.cardBackgroundPickerAria}
ariaLabelHexInput={m.cardBackgroundHexAria}
value={imageProps.backgroundColorCustom ?? ""}
onChange={(hex) => {
const next = hex && hex.trim().length > 0 ? hex : undefined;
@@ -145,10 +149,10 @@ export function ImagePropertiesPanel({ imageProps, selectedBlockId, updateBlock
{/* 정렬 */}
<label className="flex flex-col gap-1">
<span></span>
<span>{m.alignLabel}</span>
<select
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-[11px] text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
aria-label="이미지 정렬"
aria-label={m.alignAria}
value={imageProps.align ?? "center"}
onChange={(e) =>
updateBlock(selectedBlockId, {
@@ -156,18 +160,18 @@ export function ImagePropertiesPanel({ imageProps, selectedBlockId, updateBlock
} as any)
}
>
<option value="left"></option>
<option value="center"></option>
<option value="right"></option>
<option value="left">{m.alignOptionLeft}</option>
<option value="center">{m.alignOptionCenter}</option>
<option value="right">{m.alignOptionRight}</option>
</select>
</label>
{/* 너비 모드 */}
<label className="flex flex-col gap-1">
<span> </span>
<span>{m.widthModeLabel}</span>
<select
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-[11px] text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
aria-label="이미지 너비 모드"
aria-label={m.widthModeAria}
value={imageProps.widthMode ?? "auto"}
onChange={(e) =>
updateBlock(selectedBlockId, {
@@ -175,23 +179,23 @@ export function ImagePropertiesPanel({ imageProps, selectedBlockId, updateBlock
} as any)
}
>
<option value="auto"> </option>
<option value="fixed"> (px)</option>
<option value="auto">{m.widthModeOptionAuto}</option>
<option value="fixed">{m.widthModeOptionFixed}</option>
</select>
</label>
{(imageProps.widthMode ?? "auto") === "fixed" && (
<NumericPropertyControl
label="고정 너비 (px)"
unitLabel="(px)"
label={m.fixedWidthLabel}
unitLabel={m.fixedWidthUnitLabel}
value={imageProps.widthPx ?? 320}
min={40}
max={1200}
step={10}
presets={[
{ id: "sm", label: "작게", value: 240 },
{ id: "md", label: "보통", value: 320 },
{ id: "lg", label: "넓게", value: 480 },
{ id: "sm", label: m.fixedWidthPresetSmall, value: 240 },
{ id: "md", label: m.fixedWidthPresetMedium, value: 320 },
{ id: "lg", label: m.fixedWidthPresetLarge, value: 480 },
]}
onChangeValue={(v) => {
updateBlock(selectedBlockId, {
@@ -203,7 +207,7 @@ export function ImagePropertiesPanel({ imageProps, selectedBlockId, updateBlock
{/* 모서리 둥글기 */}
<NumericPropertyControl
label="모서리 둥글기"
label={m.borderRadiusLabel}
value={(() => {
if (typeof imageProps.borderRadiusPx === "number") {
return imageProps.borderRadiusPx;
@@ -216,11 +220,11 @@ export function ImagePropertiesPanel({ imageProps, selectedBlockId, updateBlock
max={200}
step={1}
presets={[
{ id: "none", label: "없음", value: 0 },
{ id: "sm", label: "작게", value: 20 },
{ id: "md", label: "보통", value: 60 },
{ id: "lg", label: "크게", value: 120 },
{ id: "full", label: "완전 둥글게", value: 180 },
{ id: "none", label: m.borderRadiusPresetNone, value: 0 },
{ id: "sm", label: m.borderRadiusPresetSmall, value: 20 },
{ id: "md", label: m.borderRadiusPresetMedium, value: 60 },
{ id: "lg", label: m.borderRadiusPresetLarge, value: 120 },
{ id: "full", label: m.borderRadiusPresetFull, value: 180 },
]}
onChangeValue={(v) => {
// 0~50 범위를 토큰으로 매핑한다.
+43 -39
View File
@@ -10,6 +10,8 @@ import {
} from "@/features/editor/state/editorStore";
import { NumericPropertyControl } from "@/features/editor/components/NumericPropertyControl";
import { ColorPickerField, TEXT_COLOR_PALETTE } from "@/features/editor/components/ColorPickerField";
import { useAppLocale } from "@/features/i18n/LocaleProvider";
import { getEditorListPanelMessages } from "@/features/i18n/messages/editorListPanel";
export type ListPropertiesPanelProps = {
listProps: ListBlockProps;
@@ -24,14 +26,16 @@ export function ListPropertiesPanel({
selectedListItemId,
updateBlock,
}: ListPropertiesPanelProps) {
const locale = useAppLocale();
const m = getEditorListPanelMessages(locale);
return (
<>
<div className="space-y-1">
<label className="flex flex-col gap-1 text-xs text-slate-400">
<span> ( )</span>
<span>{m.listItemsLabel}</span>
<textarea
className="w-full min-h-[80px] 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"
aria-label="리스트 아이템들"
aria-label={m.listItemsAria}
value={(() => {
const tree = (listProps as any).itemsTree as any[] | undefined;
@@ -83,30 +87,30 @@ export function ListPropertiesPanel({
</div>
<div className="flex items-center justify-between text-xs text-slate-400">
<label className="flex items-center gap-2">
<span></span>
<span>{m.alignLabel}</span>
<select
className="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"
aria-label="리스트 정렬"
aria-label={m.alignAria}
value={listProps.align}
onChange={(e) => {
const value = e.target.value as ListBlockProps["align"];
updateBlock(selectedBlockId, { align: value } as any);
}}
>
<option value="left"></option>
<option value="center"></option>
<option value="right"></option>
<option value="left">{m.alignOptionLeft}</option>
<option value="center">{m.alignOptionCenter}</option>
<option value="right">{m.alignOptionRight}</option>
</select>
</label>
</div>
<div className="mt-3 space-y-2 border-t border-slate-800 pt-3 text-xs text-slate-400">
<h4 className="text-[11px] font-semibold text-slate-800 dark:text-slate-200"> </h4>
<h4 className="text-[11px] font-semibold text-slate-800 dark:text-slate-200">{m.styleSectionTitle}</h4>
{/* 글자 크기 */}
<NumericPropertyControl
label="글자 크기 (px)"
unitLabel="(px)"
label={m.fontSizeLabel}
unitLabel={m.fontSizeUnitLabel}
value={(() => {
const raw = listProps.fontSizeCustom ?? "";
const match = raw.match(/([0-9]+(?:\.[0-9]+)?)/);
@@ -117,9 +121,9 @@ export function ListPropertiesPanel({
max={32}
step={1}
presets={[
{ id: "sm", label: "작게", value: 12 },
{ id: "md", label: "보통", value: 14 },
{ id: "lg", label: "크게", value: 18 },
{ id: "sm", label: m.fontSizePresetSmall, value: 12 },
{ id: "md", label: m.fontSizePresetMedium, value: 14 },
{ id: "lg", label: m.fontSizePresetLarge, value: 18 },
]}
onChangeValue={(px) => {
updateBlock(selectedBlockId, { fontSizeCustom: `${px}px` } as any);
@@ -128,7 +132,7 @@ export function ListPropertiesPanel({
{/* 줄 간격 */}
<NumericPropertyControl
label="줄 간격"
label={m.lineHeightLabel}
value={(() => {
const raw = listProps.lineHeightCustom ?? "";
const match = raw.match(/(-?[0-9]+(?:\.[0-9]+)?)/);
@@ -139,9 +143,9 @@ export function ListPropertiesPanel({
max={3}
step={0.05}
presets={[
{ id: "tight", label: "좁게", value: 1.2 },
{ id: "normal", label: "보통", value: 1.5 },
{ id: "relaxed", label: "넓게", value: 1.8 },
{ id: "tight", label: m.lineHeightPresetTight, value: 1.2 },
{ id: "normal", label: m.lineHeightPresetNormal, value: 1.5 },
{ id: "relaxed", label: m.lineHeightPresetRelaxed, value: 1.8 },
]}
onChangeValue={(v) => {
updateBlock(selectedBlockId, { lineHeightCustom: v.toString() } as any);
@@ -150,9 +154,9 @@ export function ListPropertiesPanel({
{/* 텍스트 색상 */}
<ColorPickerField
label="텍스트 색상"
ariaLabelColorInput="리스트 텍스트 색상 피커"
ariaLabelHexInput="리스트 텍스트 색상 HEX"
label={m.textColorLabel}
ariaLabelColorInput={m.textColorPickerAria}
ariaLabelHexInput={m.textColorHexAria}
// textColorCustom 이 비어 있으면 커스텀 색상을 사용하지 않고, "없음" 상태로 취급한다.
// 이 경우 HEX 인풋은 빈 문자열을 유지하고, 팔레트 라벨은 "없음"으로 표시된다.
value={
@@ -170,9 +174,9 @@ export function ListPropertiesPanel({
/>
<ColorPickerField
label="블록 배경색"
ariaLabelColorInput="리스트 배경색 피커"
ariaLabelHexInput="리스트 배경색 HEX"
label={m.backgroundColorLabel}
ariaLabelColorInput={m.backgroundColorPickerAria}
ariaLabelHexInput={m.backgroundColorHexAria}
value={listProps.backgroundColorCustom ?? ""}
onChange={(hex) => {
updateBlock(selectedBlockId, { backgroundColorCustom: hex } as any);
@@ -182,10 +186,10 @@ export function ListPropertiesPanel({
{/* 불릿 스타일 (● / ○ / ■ / 숫자형 / 없음) */}
<label className="flex flex-col gap-1">
<span>릿 </span>
<span>{m.bulletStyleLabel}</span>
<select
className="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"
aria-label="리스트 불릿 스타일"
aria-label={m.bulletStyleAria}
value={listProps.bulletStyle ?? "disc"}
onChange={(e) => {
const value = e.target.value as NonNullable<ListBlockProps["bulletStyle"]>;
@@ -203,22 +207,22 @@ export function ListPropertiesPanel({
updateBlock(selectedBlockId, { bulletStyle: value, ordered } as any);
}}
>
<option value="disc"> ()</option>
<option value="circle"> ()</option>
<option value="square"> ()</option>
<option value="decimal"> (1.)</option>
<option value="lower-alpha"> (a.)</option>
<option value="upper-alpha"> (A.)</option>
<option value="lower-roman"> (i.)</option>
<option value="upper-roman"> (I.)</option>
<option value="none"></option>
<option value="disc">{m.bulletStyleOptionDisc}</option>
<option value="circle">{m.bulletStyleOptionCircle}</option>
<option value="square">{m.bulletStyleOptionSquare}</option>
<option value="decimal">{m.bulletStyleOptionDecimal}</option>
<option value="lower-alpha">{m.bulletStyleOptionLowerAlpha}</option>
<option value="upper-alpha">{m.bulletStyleOptionUpperAlpha}</option>
<option value="lower-roman">{m.bulletStyleOptionLowerRoman}</option>
<option value="upper-roman">{m.bulletStyleOptionUpperRoman}</option>
<option value="none">{m.bulletStyleOptionNone}</option>
</select>
</label>
{/* 아이템 간 여백 (슬라이더) */}
<NumericPropertyControl
label="아이템 간 여백 (px)"
unitLabel="(px)"
label={m.gapLabel}
unitLabel={m.gapUnitLabel}
value={(() => {
if (typeof listProps.gapYPx === "number") {
return listProps.gapYPx;
@@ -230,9 +234,9 @@ export function ListPropertiesPanel({
max={40}
step={2}
presets={[
{ id: "tight", label: "좁게", value: 4 },
{ id: "normal", label: "보통", value: 8 },
{ id: "relaxed", label: "넓게", value: 16 },
{ id: "tight", label: m.gapPresetTight, value: 4 },
{ id: "normal", label: m.gapPresetNormal, value: 8 },
{ id: "relaxed", label: m.gapPresetRelaxed, value: 16 },
]}
onChangeValue={(v) => {
updateBlock(selectedBlockId, { gapYPx: v } as any);
@@ -4,6 +4,8 @@ import { useEditorStore } from "@/features/editor/state/editorStore";
import type { CanvasPreset, ProjectConfig } from "@/features/editor/state/editorStore";
import { NumericPropertyControl } from "@/features/editor/components/NumericPropertyControl";
import { ColorPickerField, TEXT_COLOR_PALETTE } from "@/features/editor/components/ColorPickerField";
import { useAppLocale } from "@/features/i18n/LocaleProvider";
import { getEditorProjectPropertiesPanelMessages } from "@/features/i18n/messages/editorProjectPropertiesPanel";
export function ProjectPropertiesPanel() {
const projectConfig = useEditorStore((state) => (state as any).projectConfig as ProjectConfig);
@@ -11,6 +13,9 @@ export function ProjectPropertiesPanel() {
(state) => (state as any).updateProjectConfig as (partial: Partial<ProjectConfig>) => void,
);
const locale = useAppLocale();
const m = getEditorProjectPropertiesPanelMessages(locale);
const handleChangePreset = (preset: CanvasPreset) => {
if (preset === "mobile") {
updateProjectConfig({ canvasPreset: preset, canvasWidthPx: 390 });
@@ -39,14 +44,14 @@ export function ProjectPropertiesPanel() {
return (
<div className="space-y-4 text-xs text-slate-900 dark:text-slate-200">
<h3 className="text-sm font-medium text-slate-100"> </h3>
<h3 className="text-sm font-medium text-slate-100">{m.sectionTitle}</h3>
<div className="space-y-1">
<label className="flex flex-col gap-1">
<span className="text-slate-500 dark:text-slate-400"> </span>
<span className="text-slate-500 dark:text-slate-400">{m.projectTitleLabel}</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"
aria-label="프로젝트 제목"
aria-label={m.projectTitleAria}
value={projectConfig.title}
onChange={(e) => updateProjectConfig({ title: e.target.value })}
/>
@@ -55,10 +60,10 @@ export function ProjectPropertiesPanel() {
<div className="space-y-1">
<label className="flex flex-col gap-1">
<span className="text-slate-500 dark:text-slate-400"> (slug)</span>
<span className="text-slate-500 dark:text-slate-400">{m.projectSlugLabel}</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"
aria-label="프로젝트 주소 (slug)"
aria-label={m.projectSlugAria}
value={projectConfig.slug}
onChange={(e) => updateProjectConfig({ slug: e.target.value })}
/>
@@ -67,16 +72,16 @@ export function ProjectPropertiesPanel() {
<div className="space-y-1">
<NumericPropertyControl
label="캔버스 너비"
unitLabel="(px)"
label={m.canvasWidthLabel}
unitLabel={m.canvasWidthUnitLabel}
value={projectConfig.canvasWidthPx ?? 1024}
min={320}
max={1920}
step={10}
presets={[
{ id: "mobile", label: "모바일 (390px)", value: 390 },
{ id: "tablet", label: "태블릿 (768px)", value: 768 },
{ id: "desktop", label: "데스크톱 (1200px)", value: 1200 },
{ id: "mobile", label: m.canvasWidthPresetMobile, value: 390 },
{ id: "tablet", label: m.canvasWidthPresetTablet, value: 768 },
{ id: "desktop", label: m.canvasWidthPresetDesktop, value: 1200 },
]}
onChangeValue={handleChangeCanvasWidth}
/>
@@ -84,9 +89,9 @@ export function ProjectPropertiesPanel() {
<div className="space-y-1">
<ColorPickerField
label="캔버스 배경색"
ariaLabelColorInput="캔버스 배경색"
ariaLabelHexInput="캔버스 배경색 HEX"
label={m.canvasBgLabel}
ariaLabelColorInput={m.canvasBgColorAria}
ariaLabelHexInput={m.canvasBgHexAria}
value={projectConfig.canvasBgColorHex ?? "#020617"}
onChange={(hex) => updateProjectConfig({ canvasBgColorHex: hex })}
palette={TEXT_COLOR_PALETTE}
@@ -95,9 +100,9 @@ export function ProjectPropertiesPanel() {
<div className="space-y-1">
<ColorPickerField
label="페이지 배경색"
ariaLabelColorInput="페이지 배경색"
ariaLabelHexInput="페이지 배경색 HEX"
label={m.pageBgLabel}
ariaLabelColorInput={m.pageBgColorAria}
ariaLabelHexInput={m.pageBgHexAria}
value={projectConfig.bodyBgColorHex ?? "#020617"}
onChange={(hex) => updateProjectConfig({ bodyBgColorHex: hex })}
palette={TEXT_COLOR_PALETTE}
@@ -105,47 +110,47 @@ export function ProjectPropertiesPanel() {
</div>
<div className="space-y-2 border-t border-slate-800 pt-3">
<h4 className="text-[11px] font-semibold text-slate-800 dark:text-slate-200">SEO / </h4>
<h4 className="text-[11px] font-semibold text-slate-800 dark:text-slate-200">{m.seoSectionTitle}</h4>
<label className="flex flex-col gap-1">
<span className="text-slate-500 dark:text-slate-400">SEO </span>
<span className="text-slate-500 dark:text-slate-400">{m.seoTitleLabel}</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"
aria-label="SEO 타이틀"
placeholder={projectConfig.title || "페이지 제목"}
aria-label={m.seoTitleAria}
placeholder={projectConfig.title || m.seoTitlePlaceholderFallback}
value={projectConfig.seoTitle ?? ""}
onChange={(e) => updateProjectConfig({ seoTitle: e.target.value })}
/>
</label>
<label className="flex flex-col gap-1">
<span className="text-slate-500 dark:text-slate-400"> </span>
<span className="text-slate-500 dark:text-slate-400">{m.seoDescriptionLabel}</span>
<textarea
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-[11px] text-slate-900 outline-none focus:border-sky-500 min-h-[56px] dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
aria-label="메타 디스크립션"
placeholder="검색엔진 및 SNS 공유에 노출될 페이지 설명을 입력하세요."
aria-label={m.seoDescriptionAria}
placeholder={m.seoDescriptionPlaceholder}
value={projectConfig.seoDescription ?? ""}
onChange={(e) => updateProjectConfig({ seoDescription: e.target.value })}
/>
</label>
<label className="flex flex-col gap-1">
<span className="text-slate-500 dark:text-slate-400">OG/Twitter URL</span>
<span className="text-slate-500 dark:text-slate-400">{m.seoOgImageUrlLabel}</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"
aria-label="OG/Twitter 이미지 URL"
placeholder="예: https://example.com/og-image.png"
aria-label={m.seoOgImageUrlAria}
placeholder={m.seoOgImageUrlPlaceholder}
value={projectConfig.seoOgImageUrl ?? ""}
onChange={(e) => updateProjectConfig({ seoOgImageUrl: e.target.value })}
/>
</label>
<label className="flex flex-col gap-1">
<span className="text-slate-500 dark:text-slate-400">Canonical URL</span>
<span className="text-slate-500 dark:text-slate-400">{m.seoCanonicalUrlLabel}</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"
aria-label="Canonical URL"
placeholder="예: https://example.com/landing"
aria-label={m.seoCanonicalUrlAria}
placeholder={m.seoCanonicalUrlPlaceholder}
value={projectConfig.seoCanonicalUrl ?? ""}
onChange={(e) => updateProjectConfig({ seoCanonicalUrl: e.target.value })}
/>
@@ -155,36 +160,36 @@ export function ProjectPropertiesPanel() {
<input
type="checkbox"
className="h-3 w-3 rounded border-slate-300 bg-white text-sky-600 dark:border-slate-600 dark:bg-slate-900"
aria-label="검색 엔진에 노출하지 않기 (noindex)"
aria-label={m.seoNoIndexAria}
checked={Boolean(projectConfig.seoNoIndex)}
onChange={(e) => updateProjectConfig({ seoNoIndex: e.target.checked })}
/>
<span> (noindex)</span>
<span>{m.seoNoIndexLabel}</span>
</label>
</div>
<div className="space-y-1">
<label className="flex flex-col gap-1">
<span className="text-slate-500 dark:text-slate-400"> head HTML</span>
<span className="text-slate-500 dark:text-slate-400">{m.headHtmlLabel}</span>
<textarea
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-[11px] font-mono text-slate-900 outline-none focus:border-sky-500 min-h-[72px] dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
aria-label="페이지 head HTML"
aria-label={m.headHtmlAria}
value={projectConfig.headHtml ?? ""}
onChange={(e) => updateProjectConfig({ headHtml: e.target.value })}
placeholder="예: &lt;meta name=&quot;description&quot; content=&quot;...&quot; /&gt;"
placeholder={m.headHtmlPlaceholder}
/>
</label>
</div>
<div className="space-y-1">
<label className="flex flex-col gap-1">
<span className="text-slate-500 dark:text-slate-400"> </span>
<span className="text-slate-500 dark:text-slate-400">{m.trackingScriptLabel}</span>
<textarea
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-[11px] font-mono text-slate-900 outline-none focus:border-sky-500 min-h-[72px] dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
aria-label="추적 스크립트"
aria-label={m.trackingScriptAria}
value={projectConfig.trackingScript ?? ""}
onChange={(e) => updateProjectConfig({ trackingScript: e.target.value })}
placeholder="예: &lt;script&gt;/* GA, Pixel 코드 */&lt;/script&gt;"
placeholder={m.trackingScriptPlaceholder}
/>
</label>
</div>
+27 -588
View File
@@ -16,6 +16,8 @@ import { FormRadioPropertiesPanel } from "../forms/FormRadioPropertiesPanel";
import { FormControllerPanel } from "../forms/FormControllerPanel";
import { ProjectPropertiesPanel } from "./ProjectPropertiesPanel";
import { Trash2, Copy, SlidersHorizontal, HelpCircle } from "lucide-react";
import { useAppLocale } from "@/features/i18n/LocaleProvider";
import { getEditorPropertiesSidebarMessages } from "@/features/i18n/messages/editorPropertiesSidebar";
type BlockHelpProperty = {
label: string;
@@ -62,6 +64,8 @@ export function PropertiesSidebar(props: PropertiesSidebarProps) {
} = props;
const [helpOpen, setHelpOpen] = useState(false);
const locale = useAppLocale();
const m = getEditorPropertiesSidebarMessages(locale);
const getHelpContentForSelectedBlock = (): BlockHelpContent | null => {
if (!selectedBlockId) return null;
@@ -70,596 +74,31 @@ export function PropertiesSidebar(props: PropertiesSidebarProps) {
switch (selectedBlock.type) {
case "text":
return {
title: "텍스트 블록 튜토리얼",
description:
"제목과 본문 텍스트를 입력할 때 사용하는 기본 블록입니다.\n\n좌측 패널에서 텍스트를 추가한 뒤, 캔버스에서 텍스트를 더블클릭하거나 속성 패널에서 내용을 수정해 보세요. 정렬, 크기, 색상 등을 조절해 다양한 스타일의 텍스트를 만들 수 있습니다.",
properties: [
{
label: "선택한 텍스트 블록 내용",
description:
"이 블록에 실제로 표시될 문장을 입력하는 영역입니다. 캔버스에서 텍스트를 더블클릭했을 때와 동일한 내용이며, 여러 줄을 입력하면 줄바꿈이 그대로 반영됩니다.",
},
{
label: "정렬",
description:
"텍스트를 왼쪽/가운데/오른쪽 중 어디에 맞출지 결정합니다. 일반 본문은 왼쪽, Hero 제목이나 짧은 강조 문구는 가운데 정렬을 추천합니다.",
},
{
label: "텍스트 스타일 (밑줄/가운데줄/이탤릭)",
description:
"밑줄은 링크처럼 보이게 하거나 특정 단어를 강조할 때, 가운데줄은 할인 전 가격처럼 취소선을 표현할 때, 이탤릭은 인용구나 제품명 등 살짝 톤을 바꾸고 싶을 때 사용합니다.",
},
{
label: "글자 크기",
description:
"텍스트의 폰트 크기를 px 단위로 조절합니다. 프리셋(XS~3XL)으로 대략적인 크기를 고른 뒤, 슬라이더/숫자 입력으로 세밀하게 조정할 수 있습니다.",
},
{
label: "글자 간격",
description:
"문자 사이의 간격(자간)을 조절합니다. 본문은 보통 또는 약간 넓게, 대문자 위주의 짧은 타이틀은 살짝 넓게 설정하면 가독성과 디자인이 좋아집니다.",
},
{
label: "줄 간격",
description:
"행과 행 사이의 간격(행간)을 정합니다. 본문은 1.5~1.7 정도가 읽기 좋고, 아주 짧은 제목은 1.25 정도로 줄이면 한 덩어리로 단단해 보입니다.",
},
{
label: "굵기",
description:
"폰트 두께를 100~900 범위에서 조절합니다. 본문은 보통(400), 섹션 제목은 세미볼드(600), 강한 CTA 문구는 볼드(700) 정도를 추천합니다.",
},
{
label: "텍스트 색상",
description:
"글자 색을 팔레트 또는 HEX 코드로 지정합니다. 본문은 대비가 높은 짙은 색을, 강조 문구는 브랜드 메인 색을 사용하는 것이 좋습니다.",
},
{
label: "블록 배경색",
description:
"텍스트가 들어 있는 카드/박스 전체 배경색을 지정합니다. 공지사항, 강조 박스, 경고 문구 등을 만들 때 연한 배경색과 함께 사용하면 좋습니다.",
},
{
label: "최대 너비",
description:
"한 줄 텍스트가 지나치게 길어지지 않도록 최대 줄 길이를 제한합니다. `본문 폭(60ch)`는 일반 문단에, `좁게(40ch)`는 카드/배너 같은 짧은 문구에 적합합니다.",
},
],
};
return m.text;
case "button":
return {
title: "버튼 블록 튜토리얼",
description:
"폼 제출이나 링크 이동을 위한 CTA 버튼을 만들 때 사용하는 블록입니다. 라벨, 링크(URL), 정렬, 색상/크기를 조절해 원하는 액션 버튼을 구성해 보세요.",
properties: [
{
label: "버튼 텍스트",
description:
"버튼 위에 표시될 문구입니다. 예: '지금 시작하기', '문의 남기기'. 짧고 행동을 유도하는 문장을 사용하는 것이 좋습니다.",
},
{
label: "가로 패딩 (px)",
description:
"버튼 양 옆의 여백을 조절합니다. 값이 클수록 버튼이 가로로 넓어져 더 강조됩니다.",
},
{
label: "세로 패딩 (px)",
description:
"버튼 위·아래 여백을 조절합니다. 값이 클수록 버튼 높이가 높아져 더 눈에 띱니다.",
},
{
label: "버튼 링크",
description:
"버튼 클릭 시 이동할 URL 또는 앵커(#section)입니다. 외부 페이지, 페이지 내 섹션, 폼 제출 엔드포인트 등으로 연결할 수 있습니다.",
},
{
label: "정렬",
description:
"해당 버튼이 포함된 영역 안에서 왼쪽/가운데/오른쪽 중 어디에 위치할지 결정합니다. 주요 CTA 버튼은 가운데 정렬이 많이 쓰입니다.",
},
{
label: "텍스트 색상",
description:
"버튼 라벨 글자 색입니다. 채움 색상과 충분한 대비가 나도록 설정해야 가독성이 좋습니다.",
},
{
label: "스타일",
description:
"버튼 외형 스타일입니다. '채움'은 꽉 찬 스타일, '외곽선'은 테두리만 있는 스타일, '고스트'는 배경 없이 텍스트/테두리만 있는 스타일입니다.",
},
{
label: "채움 색상",
description:
"채움 스타일에서 버튼 내부를 채우는 색입니다. 브랜드 메인 색이나 강조 색을 사용하면 좋습니다.",
},
{
label: "외곽선 색상",
description:
"외곽선/고스트 스타일에서 버튼 테두리 색입니다. 배경색과의 대비를 고려해 설정합니다.",
},
{
label: "모서리 둥글기",
description:
"버튼 모서리를 얼마나 둥글게 처리할지 결정합니다. '없음'은 각진 버튼, '완전 둥글게'는 알약 모양 버튼입니다.",
},
{
label: "버튼 너비 모드 / 버튼 고정 너비",
description:
"버튼이 컨테이너 너비에 맞춰 늘어날지(전체 폭), 내용 길이에 맞출지(자동), 고정 px 너비를 가질지 결정합니다.",
},
{
label: "버튼 크기",
description:
"버튼 라벨 텍스트의 폰트 크기입니다. 중요한 CTA 버튼은 주변 텍스트보다 조금 더 크게 설정하면 좋습니다.",
},
{
label: "줄 간격 / 글자 간격",
description:
"버튼 라벨의 행간과 자간을 조절합니다. 글자가 너무 답답해 보이면 자간을 약간 넓히고, 여러 줄 버튼 문구는 줄 간격을 조금 넓혀 주세요.",
},
],
};
return m.button;
case "image":
return {
title: "이미지 블록 튜토리얼",
description:
"배너, 썸네일, 설명 이미지를 배치할 때 사용하는 블록입니다. 외부 이미지 URL을 입력하거나 업로드 이미지를 선택하고, 너비/정렬/모서리 둥글기를 조절해 레이아웃에 맞게 배치해 보세요.",
properties: [
{
label: "이미지 소스",
description:
"이미지를 외부 URL에서 가져올지(URL), 빌더에 업로드한 에셋을 사용할지(파일 업로드) 선택합니다.",
},
{
label: "이미지 URL / 이미지 파일 업로드",
description:
"URL 모드에서는 외부 이미지 주소를 입력하고, 업로드 모드에서는 로컬 이미지를 업로드해 `/api/image/:id` 형식으로 관리합니다.",
},
{
label: "대체 텍스트",
description:
"이미지가 로드되지 않거나 스크린리더를 사용하는 사용자에게 보여질 설명 텍스트입니다. 이미지가 전달하는 의미를 한두 문장으로 작성해 주세요.",
},
{
label: "카드 배경색",
description:
"이미지를 감싸는 카드 영역의 배경색입니다. 투명한 PNG 아이콘이나 로고를 올릴 때 배경을 살짝 깔아주면 더 잘 보입니다.",
},
{
label: "정렬",
description:
"이미지가 포함된 영역 안에서 왼쪽/가운데/오른쪽 중 어디에 위치할지 결정합니다.",
},
{
label: "너비 모드 / 고정 너비 (px)",
description:
"이미지를 내용 크기에 맞출지(auto) 또는 px 단위로 고정된 너비를 사용할지 선택합니다. 썸네일 그리드에서는 고정 너비를 사용하는 편이 레이아웃이 안정적입니다.",
},
{
label: "모서리 둥글기",
description:
"이미지 카드 모서리 둥글기를 px 단위로 조절합니다. 아바타/아이콘은 완전 둥글게, 일반 사진은 살짝 둥글게 설정하면 자연스럽습니다.",
},
],
};
return m.image;
case "list":
return {
title: "리스트 블록 튜토리얼",
description:
"불릿/번호 리스트를 만들 때 사용하는 블록입니다. 아이템 텍스트를 수정하고, 정렬/불릿 스타일을 바꿔 체크리스트나 단계별 안내 등을 표현할 수 있습니다.",
properties: [
{
label: "리스트 아이템 (줄바꿈으로 구분)",
description:
"각 줄이 하나의 리스트 항목이 됩니다. 들여쓰기를 이용한 중첩 리스트는 TDD 기준의 변환 로직에 따라 자동으로 트리 구조로 변환됩니다.",
},
{
label: "정렬",
description:
"리스트 전체를 왼쪽/가운데/오른쪽 중 어디에 정렬할지 결정합니다. 체크리스트는 왼쪽 정렬, 특징 요약은 가운데 정렬이 자주 사용됩니다.",
},
{
label: "글자 크기 (px)",
description:
"리스트 텍스트의 폰트 크기를 조절합니다. 본문보다 약간 작게 설정하면 보조 정보 느낌을 줄 수 있습니다.",
},
{
label: "줄 간격",
description:
"리스트 항목 내부의 행간을 조절합니다. 항목 내용이 길어질수록 1.5~1.8 정도의 넉넉한 값을 추천합니다.",
},
{
label: "텍스트 색상",
description:
"리스트 텍스트의 색상입니다. 배경색과의 대비를 고려해 본문과 동일하거나 약간 연한 색상을 사용할 수 있습니다.",
},
{
label: "블록 배경색",
description:
"리스트 전체 카드의 배경색입니다. 단계별 안내나 체크리스트 박스를 강조하는 용도로 사용할 수 있습니다.",
},
{
label: "불릿 스타일",
description:
"불릿(●/○/■) 또는 번호(1., a., i.) 형식을 선택합니다. 숫자/알파벳/로마 숫자 스타일을 선택하면 자동으로 순서형 리스트(ordered)가 됩니다.",
},
{
label: "아이템 간 여백 (px)",
description:
"각 리스트 항목 사이의 세로 간격입니다. 설명이 긴 항목이 많다면 여백을 넉넉히 두어 가독성을 확보하세요.",
},
],
};
return m.list;
case "divider":
return {
title: "구분선 블록 튜토리얼",
description:
"섹션과 섹션 사이를 나누거나 콘텐츠를 시각적으로 구분할 때 사용하는 블록입니다. 정렬, 두께, 길이, 색상, 여백을 조절해 페이지 흐름을 정리해 보세요.",
properties: [
{
label: "정렬",
description:
"구분선을 컨테이너 안에서 왼쪽/가운데/오른쪽 중 어디에 위치시킬지 결정합니다.",
},
{
label: "두께",
description:
"선의 두께입니다. '얇게'는 가벼운 보조 구분선, '보통'은 섹션 경계를 확실히 나눌 때 사용합니다.",
},
{
label: "길이 모드 / 고정 길이 (px)",
description:
"구분선을 전체 폭으로 쓸지, 내용에 맞출지, 또는 px 단위의 고정 길이를 사용할지 선택합니다.",
},
{
label: "선 색상",
description:
"구분선 컬러입니다. 너무 진한 색보다는 섹션 배경보다 살짝 진한 정도의 중간 톤을 추천합니다.",
},
{
label: "위/아래 여백",
description:
"구분선 위·아래에 들어가는 공백입니다. 값이 클수록 섹션이 더 명확하게 나뉘어 보입니다.",
},
],
};
return m.divider;
case "section":
return {
title: "섹션 블록 튜토리얼",
description:
"레이아웃의 큰 덩어리를 나누는 컨테이너 블록입니다. 배경색/배경 이미지/비디오, 위아래 패딩, 너비를 설정해 Hero, Features, Footer 같은 영역을 구성할 수 있습니다.",
properties: [
{
label: "배경 색상",
description:
"섹션 전체의 기본 배경색입니다. 페이지의 큰 분위기를 결정하는 요소이므로, 섹션 간 배경 대비를 적절히 주는 것이 좋습니다.",
},
{
label: "배경 이미지 소스 / URL / 파일 업로드",
description:
"섹션 배경으로 사용할 이미지를 외부 URL 또는 업로드 파일로 지정합니다. Hero 배경, 패턴, 질감 이미지를 설정할 때 사용합니다.",
},
{
label: "배경 이미지 크기 / 위치 / 반복",
description:
"cover/contain/auto로 크기를 조절하고, 프리셋 또는 X/Y 퍼센트로 위치를 조절하며, 반복 여부를 설정해 패턴 형태로 사용할 수 있습니다.",
},
{
label: "배경 비디오 소스 / URL / 파일 업로드",
description:
"섹션 배경에 비디오를 설정합니다. 시선을 끌어야 하는 Hero 영역 등에 사용하되, 가독성 저하를 막기 위해 텍스트 대비를 꼭 확인하세요.",
},
{
label: "세로 패딩",
description:
"섹션 위·아래 여백입니다. 값이 클수록 섹션이 여유 있고 강조되어 보입니다. Hero 섹션은 넉넉한 패딩을, 보조 섹션은 중간 정도를 추천합니다.",
},
],
};
return m.section;
case "video":
return {
title: "비디오 블록 튜토리얼",
description:
"YouTube/영상 URL 또는 직접 호스팅한 비디오를 삽입할 때 사용하는 블록입니다. 동영상 주소를 입력하고, 비율/정렬/재생 옵션을 조정해 페이지에 맞게 배치해 보세요.",
properties: [
{
label: "비디오 소스 / 비디오 URL / 비디오 파일 업로드",
description:
"외부 동영상 URL(YouTube, Vimeo 등)을 직접 입력하거나, 비디오 파일을 업로드해 `/api/video/:id` 형태로 사용할 수 있습니다.",
},
{
label: "포스터 이미지 URL",
description:
"동영상 재생 전/로딩 중에 보여줄 썸네일 이미지입니다. 영상의 핵심 장면이나 대표 이미지를 사용하면 클릭률을 높일 수 있습니다.",
},
{
label: "카드 배경색",
description:
"비디오가 들어가는 카드 영역의 배경색입니다. 주변 섹션과의 대비를 위해 살짝 어둡거나 밝은 색을 설정할 수 있습니다.",
},
{
label: "비디오 정렬",
description:
"비디오를 컨테이너 안에서 왼쪽/가운데/오른쪽 중 어디에 위치시킬지 결정합니다.",
},
{
label: "비디오 너비 모드 / 고정 너비 (px)",
description:
"비디오를 내용 너비에 맞출지, 가로 전체를 채울지, 특정 px 너비로 고정할지 선택합니다.",
},
{
label: "카드 패딩 / 카드 모서리 둥글기",
description:
"비디오 주변 카드의 안쪽 여백과 모서리 둥글기입니다. 카드형 레이아웃에서는 적당한 패딩과 둥근 모서리를 주면 디자인이 정돈됩니다.",
},
{
label: "시작 시점 (초) / 종료 시점 (초)",
description:
"영상의 특정 구간만 재생하고 싶을 때 사용하는 옵션입니다. 예를 들어 10초~30초만 재생하도록 설정할 수 있습니다.",
},
{
label: "화면 비율",
description:
"동영상 프레임 비율입니다. 대부분의 웹 영상은 16:9를 사용하며, 정사각형/세로형 콘텐츠는 1:1, 4:3 등을 사용할 수 있습니다.",
},
{
label: "자동 재생 / 반복 재생 / 음소거 / 재생 컨트롤 표시",
description:
"자동 재생/반복 재생/음소거/컨트롤 표시 여부를 설정합니다. 자동 재생 영상은 보통 음소거를 함께 켜 두는 것이 좋습니다.",
},
],
};
return m.video;
case "form":
return {
title: "폼 컨트롤러 블록 튜토리얼",
description:
"입력 필드(formInput/select/checkbox/radio)를 하나의 폼으로 묶는 컨트롤러입니다. 전송 대상, 전송 포맷, 너비, 여백 등을 설정해 네이티브 폼 제출을 구성하고, 필드 ID를 연결해 폼 구조를 완성할 수 있습니다.",
properties: [
{
label: "전송 대상 / Webhook 설정",
description:
"폼 데이터를 내부 처리로만 사용할지, 외부 Webhook/Google Sheets 등으로 전송할지 결정하고, 목적지 URL/전송 포맷/HTTP 메서드 등을 설정합니다.",
},
{
label: "추가 파라미터",
description:
"`key=value` 형식으로 줄바꿈해 작성하면, 모든 폼 제출에 공통으로 포함될 추가 파라미터를 지정할 수 있습니다. 예: `source=landing`.",
},
{
label: "폼 너비 모드 / 폼 고정 너비 (px)",
description:
"폼 전체의 가로 폭을 자동/전체 폭/고정 px 값 중에서 선택합니다. 문의 폼은 보통 480~640px 정도의 고정 폭이 읽기 좋습니다.",
},
{
label: "폼 위/아래 여백 (px)",
description:
"폼 블록 위·아래에 들어가는 공백입니다. 페이지 내 다른 섹션과의 간격을 조절할 때 사용합니다.",
},
{
label: "성공 메시지 / 에러 메시지",
description:
"폼 전송 성공 또는 실패 시 사용자에게 보여줄 텍스트입니다. 명확하고 친절한 문구로 작성하는 것이 좋습니다.",
},
{
label: "폼 필드 매핑",
description:
"폼 컨트롤러와 연결할 입력 필드(formInput/select/checkbox/radio)를 체크박스로 선택합니다. 체크된 필드만 실제 폼 제출 payload에 포함됩니다.",
},
{
label: "Submit 버튼",
description:
"폼 제출을 담당할 버튼 블록을 지정합니다. 별도 버튼 블록을 만들어 이 컨트롤러와 연결하면, 해당 버튼 클릭 시 폼이 제출됩니다.",
},
],
};
return m.form;
case "formInput":
return {
title: "폼 입력 블록 튜토리얼",
description:
"이름, 이메일 등 한 줄 텍스트 또는 긴 텍스트 입력 필드를 만들 때 사용하는 블록입니다. 레이블, 플레이스홀더, 필수 여부, 너비 등을 설정해 폼 컨트롤러와 함께 사용합니다.",
properties: [
{
label: "라벨 타입 / 필드 라벨",
description:
"입력 필드 위나 옆에 표시될 설명입니다. 텍스트 또는 이미지로 라벨을 구성할 수 있으며, 사용자가 어떤 값을 입력해야 하는지 명확하게 알려줍니다.",
},
{
label: "라벨 이미지 URL / 대체 텍스트",
description:
"라벨을 이미지로 사용할 때 경로와 alt 텍스트를 지정합니다. 로고형 라벨이나 아이콘 기반 UI를 만들 때 활용할 수 있습니다.",
},
{
label: "전송 키",
description:
"이 입력값이 서버/웹훅에 전달될 때 사용되는 필드 이름입니다. 백엔드나 스프레드시트 컬럼명과 일치시키면 이후 처리가 편해집니다.",
},
{
label: "필드 타입",
description:
"텍스트/이메일/긴 텍스트 중 어떤 형태의 입력을 받을지 결정합니다. 이메일 타입은 브라우저 기본 이메일 검증을 활용할 수 있습니다.",
},
{
label: "Placeholder",
description:
"입력 전 필드 안에 흐릿하게 보여줄 안내 문구입니다. 예: '이메일을 입력해 주세요'.",
},
{
label: "필수 필드",
description:
"체크 시 이 필드는 반드시 채워야 합니다. 이름/이메일 같은 필수 값에는 활성화하고, 선택 항목에는 비활성화하는 것을 추천합니다.",
},
{
label: "텍스트 크기 / 줄간격 / 자간",
description:
"입력 값의 폰트 크기(px), 줄간격(px), 자간(px)을 조절해 폼 필드의 가독성과 분위기를 세밀하게 튜닝합니다.",
},
{
label: "텍스트 정렬",
description:
"필드 안 텍스트를 왼쪽/가운데/오른쪽 중 어디에 정렬할지 결정합니다. 일반 입력은 왼쪽 정렬을 권장합니다.",
},
{
label: "레이아웃 / 라벨/필드 간격",
description:
"라벨과 필드를 세로(stack) 또는 가로(inline)로 배치하고, inline 모드에서 라벨과 입력 사이 간격(px)을 조절합니다.",
},
{
label: "너비 / 필드 고정 너비",
description:
"필드를 자동/전체 폭/고정 px 중 어떤 너비로 렌더링할지 결정합니다. 짧은 필드는 고정 값, 긴 입력은 전체 폭을 사용할 수 있습니다.",
},
{
label: "필드 가로/세로 패딩",
description:
"입력 상자 안쪽 여백을 조절합니다. 값이 클수록 필드가 커지고 누르기/입력하기 편해집니다.",
},
{
label: "필드 텍스트/채움/테두리 색상",
description:
"입력 텍스트, 배경, 테두리 색상을 각각 조절합니다. 에러 상태/강조 상태 등을 표현할 때 조합해서 사용할 수 있습니다.",
},
{
label: "필드 모서리 둥글기",
description:
"입력 상자의 모서리를 얼마나 둥글게 할지 결정합니다. 페이지의 전체 디자인 톤과 맞춰 사용하세요.",
},
],
};
return m.formInput;
case "formSelect":
return {
title: "폼 셀렉트 블록 튜토리얼",
description:
"드롭다운 선택 필드를 만들 때 사용하는 블록입니다. 옵션 목록과 기본 선택값, 너비를 설정해 폼 컨트롤러와 함께 사용합니다.",
properties: [
{
label: "라벨 타입 / 필드 라벨",
description:
"셀렉트 필드의 제목 역할을 하는 라벨입니다. 텍스트 또는 이미지로 구성할 수 있으며, 선택해야 할 값의 의미를 설명합니다.",
},
{
label: "전송 키",
description:
"선택된 옵션 값이 서버/웹훅에 전달될 때 사용되는 필드 이름입니다. 백엔드나 스프레드시트 컬럼명과 일치시키면 이후 처리가 편해집니다.",
},
{
label: "옵션",
description:
"사용자가 선택할 수 있는 옵션 목록입니다. 라벨과 value를 함께 설정하며, '옵션 추가' 버튼으로 새 옵션을 만들 수 있습니다.",
},
{
label: "필수 필드",
description:
"체크 시 반드시 하나의 옵션을 선택해야 합니다. 중요 선택 항목에는 필수로 설정하는 것을 권장합니다.",
},
{
label: "셀렉트 텍스트 크기 / 줄간격 / 자간",
description:
"드롭다운 필드의 텍스트 타이포그래피를 px 단위로 조절해 가독성과 분위기를 맞춥니다.",
},
{
label: "필드 너비 / 필드 고정 너비",
description:
"셀렉트 필드를 자동/전체 폭/고정 px 중 어떤 너비로 렌더링할지 결정합니다.",
},
{
label: "셀렉트 가로/세로 패딩",
description:
"드롭다운 안쪽 여백을 조절해 클릭 영역과 시각적 무게감을 조정합니다.",
},
{
label: "필드 텍스트/채움/테두리 색상",
description:
"선택 영역의 텍스트, 배경, 테두리 색상을 설정합니다. 폼 전체 톤과 잘 어울리는 색상을 사용하는 것이 좋습니다.",
},
{
label: "필드 모서리 둥글기",
description:
"셀렉트 박스의 모서리를 얼마나 둥글게 표시할지 결정합니다.",
},
],
};
return m.formSelect;
case "formCheckbox":
return {
title: "폼 체크박스 블록 튜토리얼",
description:
"여러 항목 중 복수 선택이 필요한 경우 사용하는 체크박스 그룹 블록입니다. 그룹 타이틀과 옵션 라벨/이미지를 설정해 동의 항목이나 관심사 선택 등을 구성할 수 있습니다.",
properties: [
{
label: "그룹 타이틀 타입 / 그룹 타이틀",
description:
"체크박스 그룹 전체의 제목입니다. 텍스트 또는 이미지로 표현할 수 있으며, 사용자가 어떤 범주를 선택하는지 알려줍니다.",
},
{
label: "그룹 타이틀 이미지 소스 / URL / 파일 업로드",
description:
"그룹 라벨을 이미지로 사용할 때, URL 또는 업로드된 이미지를 선택합니다.",
},
{
label: "전송 키",
description:
"체크된 옵션들의 값이 서버/웹훅에 전달될 때 사용되는 필드 이름입니다.",
},
{
label: "옵션 / 옵션 이미지 소스",
description:
"각 체크박스 옵션의 라벨/값/이미지를 설정합니다. URL 또는 업로드 이미지로 옵션별 아이콘을 붙일 수 있습니다.",
},
{
label: "필수 필드",
description:
"체크 시 하나 이상 옵션을 선택해야 합니다. 약관 동의 등 필수 동의 항목에 사용합니다.",
},
{
label: "체크박스 텍스트 크기 / 줄간격 / 자간",
description:
"체크박스 라벨의 폰트 크기, 줄간격, 자간을 조절합니다. 항목이 길어질수록 줄간격을 넉넉히 두는 것이 좋습니다.",
},
],
};
return m.formCheckbox;
case "formRadio":
return {
title: "폼 라디오 블록 튜토리얼",
description:
"여러 옵션 중 하나만 선택해야 할 때 사용하는 라디오 버튼 그룹 블록입니다. 옵션 라벨/이미지와 기본 선택값을 설정해 요금제 선택 등 단일 선택 시나리오를 구성할 수 있습니다.",
properties: [
{
label: "그룹 타이틀 타입 / 그룹 타이틀",
description:
"라디오 그룹의 제목입니다. 요금제 이름, 질문 문구 등 단일 선택의 맥락을 설명합니다.",
},
{
label: "그룹 타이틀 이미지 소스 / URL / 파일 업로드",
description:
"그룹 라벨을 이미지로 사용할 때, URL 또는 업로드 이미지를 선택합니다.",
},
{
label: "전송 키",
description:
"선택된 하나의 옵션 값이 서버/웹훅에 전달될 때 사용되는 필드 이름입니다.",
},
{
label: "옵션 / 옵션 이미지 소스",
description:
"각 라디오 옵션의 라벨/값/이미지를 설정합니다. 옵션별 아이콘이나 썸네일을 붙여 더 풍부한 선택 UI를 만들 수 있습니다.",
},
{
label: "필수 필드",
description:
"체크 시 반드시 하나의 옵션을 선택해야 합니다. 요금제/유형 선택처럼 반드시 선택이 필요한 경우에 사용합니다.",
},
{
label: "라디오 텍스트 크기 / 줄간격 / 자간",
description:
"라디오 라벨의 폰트 크기, 줄간격, 자간을 조절합니다. 옵션 설명이 긴 경우 줄간격을 넉넉히 두세요.",
},
],
};
return m.formRadio;
default:
return {
title: "블록 튜토리얼",
description: "이 블록에 대한 자세한 도움말은 추후 추가될 예정입니다.",
};
return m.fallback;
}
};
@@ -674,7 +113,7 @@ export function PropertiesSidebar(props: PropertiesSidebarProps) {
>
<h2 className="font-medium mb-2 flex items-center gap-2 text-slate-900 dark:text-slate-100">
<SlidersHorizontal className="w-4 h-4 text-sky-400" aria-hidden="true" />
<span> </span>
<span>{m.panelTitle}</span>
</h2>
{selectedBlockId ? (
<div className="space-y-3">
@@ -686,7 +125,7 @@ export function PropertiesSidebar(props: PropertiesSidebarProps) {
>
<span className="inline-flex items-center gap-2">
<Trash2 className="w-3 h-3" aria-hidden="true" />
<span> </span>
<span>{m.actionDeleteBlock}</span>
</span>
</button>
<button
@@ -696,7 +135,7 @@ export function PropertiesSidebar(props: PropertiesSidebarProps) {
>
<span className="inline-flex items-center gap-2">
<Copy className="w-3 h-3" aria-hidden="true" />
<span> </span>
<span>{m.actionDuplicateBlock}</span>
</span>
</button>
</div>
@@ -707,7 +146,7 @@ export function PropertiesSidebar(props: PropertiesSidebarProps) {
onClick={() => setHelpOpen(true)}
>
<HelpCircle className="w-3 h-3" aria-hidden="true" />
<span>HELP</span>
<span>{m.actionHelp}</span>
</button>
</div>
{(() => {
@@ -869,21 +308,21 @@ export function PropertiesSidebar(props: PropertiesSidebarProps) {
<h3 className="text-sm font-medium">{help.title}</h3>
<button
type="button"
className="text-slate-400 hover:text-slate-100 text-xs"
className="text-slate-600 dark:text-slate-400 hover:text-slate-100 text-xs"
onClick={() => setHelpOpen(false)}
>
{m.modalCloseLabel}
</button>
</div>
<p className="text-[11px] text-slate-200 whitespace-pre-line">{help.description}</p>
<p className="text-[11px] text-slate-600 dark:text-slate-200 whitespace-pre-line">{help.description}</p>
{help.properties && help.properties.length > 0 && (
<div className="mt-3 border-t border-slate-700 pt-2 space-y-2">
<h4 className="text-[11px] font-semibold text-slate-200"> </h4>
<h4 className="text-[11px] font-semibold text-slate-900 dark:text-slate-200">{m.modalPropertiesSectionTitle}</h4>
<ul className="space-y-1">
{help.properties.map((prop) => (
<li key={prop.label}>
<div className="text-[11px] font-semibold text-slate-100">{prop.label}</div>
<div className="text-[11px] text-slate-300">{prop.description}</div>
<div className="text-[11px] font-semibold text-slate-600 dark:text-slate:-100">{prop.label}</div>
<div className="text-[11px] text-slate-500 dark:text-slate-300">{prop.description}</div>
</li>
))}
</ul>
@@ -3,6 +3,8 @@
import type { SectionBlockProps } from "@/features/editor/state/editorStore";
import { ColorPickerField, TEXT_COLOR_PALETTE } from "@/features/editor/components/ColorPickerField";
import { NumericPropertyControl } from "@/features/editor/components/NumericPropertyControl";
import { useAppLocale } from "@/features/i18n/LocaleProvider";
import { getEditorSectionPanelMessages } from "@/features/i18n/messages/editorSectionPanel";
export type SectionPropertiesPanelProps = {
sectionProps: SectionBlockProps;
@@ -11,6 +13,8 @@ export type SectionPropertiesPanelProps = {
};
export function SectionPropertiesPanel({ sectionProps, selectedBlockId, updateBlock }: SectionPropertiesPanelProps) {
const locale = useAppLocale();
const m = getEditorSectionPanelMessages(locale);
const backgroundSource: "none" | "url" | "upload" = (() => {
const src = sectionProps.backgroundImageSrc?.trim();
const type = sectionProps.backgroundImageSourceType;
@@ -44,9 +48,9 @@ export function SectionPropertiesPanel({ sectionProps, selectedBlockId, updateBl
{/* 섹션 배경: 커스텀 색상 피커만 사용 */}
<div className="mt-1">
<ColorPickerField
label="배경 색상"
ariaLabelColorInput="섹션 배경 색상 선택"
ariaLabelHexInput="섹션 배경 색상 HEX 입력"
label={m.backgroundColorLabel}
ariaLabelColorInput={m.backgroundColorPickerAria}
ariaLabelHexInput={m.backgroundColorHexAria}
value={sectionProps.backgroundColorCustom ?? ""}
onChange={(hex) => {
const next = hex && hex.trim().length > 0 ? hex : undefined;
@@ -59,10 +63,10 @@ export function SectionPropertiesPanel({ sectionProps, selectedBlockId, updateBl
{/* 섹션 배경 이미지 */}
<div className="mt-3 space-y-2 text-xs text-slate-400">
<label className="flex flex-col gap-1">
<span> </span>
<span>{m.backgroundImageSourceLabel}</span>
<select
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-[11px] text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
aria-label="배경 이미지 소스"
aria-label={m.backgroundImageSourceAria}
value={backgroundSource}
onChange={(e) => {
const next = e.target.value as "none" | "url" | "upload";
@@ -84,18 +88,18 @@ export function SectionPropertiesPanel({ sectionProps, selectedBlockId, updateBl
}
}}
>
<option value="none"></option>
<option value="url">URL</option>
<option value="upload"> </option>
<option value="none">{m.backgroundVideoSourceOptionNone}</option>
<option value="url">{m.backgroundVideoSourceOptionUrl}</option>
<option value="upload">{m.backgroundVideoSourceOptionUpload}</option>
</select>
</label>
{backgroundSource === "url" && (
<label className="flex flex-col gap-1">
<span> URL</span>
<span>{m.backgroundImageUrlLabel}</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"
aria-label="배경 이미지 URL"
aria-label={m.backgroundImageUrlAria}
value={sectionProps.backgroundImageSrc ?? ""}
onChange={(e) => {
const value = e.target.value;
@@ -111,12 +115,12 @@ export function SectionPropertiesPanel({ sectionProps, selectedBlockId, updateBl
{backgroundSource === "upload" && (
<label className="flex flex-col gap-1">
<span> </span>
<span>{m.backgroundImageUploadLabel}</span>
<input
type="file"
accept="image/*"
className="w-full text-[11px] text-slate-300 file:mr-2 file:rounded file:border file:border-slate-700 file:bg-slate-800 file:px-2 file:py-1 file:text-[11px] file:text-slate-100 hover:file:bg-slate-700"
aria-label="배경 이미지 파일 업로드"
aria-label={m.backgroundImageUploadAria}
onChange={async (event) => {
const file = event.target.files?.[0];
if (!file) return;
@@ -155,10 +159,10 @@ export function SectionPropertiesPanel({ sectionProps, selectedBlockId, updateBl
{backgroundSource !== "none" && (
<>
<label className="flex flex-col gap-1">
<span> </span>
<span>{m.backgroundImagePositionModeLabel}</span>
<select
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-[11px] text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
aria-label="배경 이미지 위치 모드"
aria-label={m.backgroundImagePositionModeAria}
value={positionMode}
onChange={(e) =>
updateBlock(selectedBlockId, {
@@ -166,16 +170,16 @@ export function SectionPropertiesPanel({ sectionProps, selectedBlockId, updateBl
} as any)
}
>
<option value="preset"></option>
<option value="custom"> (X/Y)</option>
<option value="preset">{m.backgroundImagePositionModeOptionPreset}</option>
<option value="custom">{m.backgroundImagePositionModeOptionCustom}</option>
</select>
</label>
<label className="flex flex-col gap-1">
<span> </span>
<span>{m.backgroundImageSizeLabel}</span>
<select
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-[11px] text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
aria-label="배경 이미지 크기"
aria-label={m.backgroundImageSizeAria}
value={sectionProps.backgroundImageSize ?? "cover"}
onChange={(e) =>
updateBlock(selectedBlockId, {
@@ -191,10 +195,10 @@ export function SectionPropertiesPanel({ sectionProps, selectedBlockId, updateBl
{positionMode === "preset" && (
<label className="flex flex-col gap-1">
<span> </span>
<span>{m.backgroundImagePositionLabel}</span>
<select
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-[11px] text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
aria-label="배경 이미지 위치"
aria-label={m.backgroundImagePositionAria}
value={sectionProps.backgroundImagePosition ?? "center"}
onChange={(e) =>
updateBlock(selectedBlockId, {
@@ -202,11 +206,11 @@ export function SectionPropertiesPanel({ sectionProps, selectedBlockId, updateBl
} as any)
}
>
<option value="center"></option>
<option value="top"></option>
<option value="bottom"></option>
<option value="left"></option>
<option value="right"></option>
<option value="center">{m.backgroundImagePositionOptionCenter}</option>
<option value="top">{m.backgroundImagePositionOptionTop}</option>
<option value="bottom">{m.backgroundImagePositionOptionBottom}</option>
<option value="left">{m.backgroundImagePositionOptionLeft}</option>
<option value="right">{m.backgroundImagePositionOptionRight}</option>
</select>
</label>
)}
@@ -214,7 +218,7 @@ export function SectionPropertiesPanel({ sectionProps, selectedBlockId, updateBl
{positionMode === "custom" && (
<>
<NumericPropertyControl
label="배경 이미지 가로 위치"
label={m.backgroundImagePositionXLabel}
unitLabel="(%)"
value={
typeof sectionProps.backgroundImagePositionXPercent === "number"
@@ -238,7 +242,7 @@ export function SectionPropertiesPanel({ sectionProps, selectedBlockId, updateBl
/>
<NumericPropertyControl
label="배경 이미지 세로 위치"
label={m.backgroundImagePositionYLabel}
unitLabel="(%)"
value={
typeof sectionProps.backgroundImagePositionYPercent === "number"
@@ -264,10 +268,10 @@ export function SectionPropertiesPanel({ sectionProps, selectedBlockId, updateBl
)}
<label className="flex flex-col gap-1">
<span> </span>
<span>{m.backgroundImageRepeatLabel}</span>
<select
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-[11px] text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
aria-label="배경 이미지 반복"
aria-label={m.backgroundImageRepeatAria}
value={sectionProps.backgroundImageRepeat ?? "no-repeat"}
onChange={(e) =>
updateBlock(selectedBlockId, {
@@ -275,10 +279,10 @@ export function SectionPropertiesPanel({ sectionProps, selectedBlockId, updateBl
} as any)
}
>
<option value="no-repeat"> </option>
<option value="repeat">/ </option>
<option value="repeat-x"> </option>
<option value="repeat-y"> </option>
<option value="no-repeat">{m.backgroundImageRepeatOptionNone}</option>
<option value="repeat">{m.backgroundImageRepeatOptionBoth}</option>
<option value="repeat-x">{m.backgroundImageRepeatOptionX}</option>
<option value="repeat-y">{m.backgroundImageRepeatOptionY}</option>
</select>
</label>
</>
@@ -288,10 +292,10 @@ export function SectionPropertiesPanel({ sectionProps, selectedBlockId, updateBl
{/* 섹션 배경 비디오 */}
<div className="mt-3 space-y-2 text-xs text-slate-400">
<label className="flex flex-col gap-1">
<span> </span>
<span>{m.backgroundVideoSourceLabel}</span>
<select
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-[11px] text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
aria-label="배경 비디오 소스"
aria-label={m.backgroundVideoSourceAria}
value={backgroundVideoSource}
onChange={(e) => {
const next = e.target.value as "none" | "url" | "upload";
@@ -321,10 +325,10 @@ export function SectionPropertiesPanel({ sectionProps, selectedBlockId, updateBl
{backgroundVideoSource === "url" && (
<label className="flex flex-col gap-1">
<span> URL</span>
<span>{m.backgroundVideoUrlLabel}</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"
aria-label="배경 비디오 URL"
aria-label={m.backgroundVideoUrlAria}
value={sectionProps.backgroundVideoSrc ?? ""}
onChange={(e) => {
const value = e.target.value;
@@ -340,12 +344,12 @@ export function SectionPropertiesPanel({ sectionProps, selectedBlockId, updateBl
{backgroundVideoSource === "upload" && (
<label className="flex flex-col gap-1">
<span> </span>
<span>{m.backgroundVideoUploadLabel}</span>
<input
type="file"
accept="video/*"
className="w-full text-[11px] text-slate-300 file:mr-2 file:rounded file:border file:border-slate-700 file:bg-slate-800 file:px-2 file:py-1 file:text-[11px] file:text-slate-100 hover:file:bg-slate-700"
aria-label="배경 비디오 파일 업로드"
aria-label={m.backgroundVideoUploadAria}
onChange={async (event) => {
const file = event.target.files?.[0];
if (!file) return;
@@ -386,8 +390,8 @@ export function SectionPropertiesPanel({ sectionProps, selectedBlockId, updateBl
{/* 세로 패딩 슬라이더 (px) - 프리셋 + 자유 슬라이더 */}
<div className="mt-3 space-y-1">
<NumericPropertyControl
label="세로 패딩"
unitLabel="(px)"
label={m.paddingYLabel}
unitLabel={m.paddingYUnitLabel}
value={typeof sectionProps.paddingYPx === "number" ? sectionProps.paddingYPx : 48}
min={16}
max={80}
@@ -407,14 +411,14 @@ export function SectionPropertiesPanel({ sectionProps, selectedBlockId, updateBl
</div>
<div className="mt-3 space-y-2 border-t border-slate-800 pt-3 text-xs text-slate-400">
<h4 className="text-[11px] font-semibold text-slate-200"> </h4>
<h4 className="text-[11px] font-semibold text-slate-200">{m.layoutSectionTitle}</h4>
{/* 컬럼 레이아웃 프리셋 */}
<label className="flex flex-col gap-1">
<span> </span>
<span>{m.columnLayoutLabel}</span>
<select
className="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"
aria-label="섹션 컬럼 레이아웃"
aria-label={m.columnLayoutAria}
value={(() => {
const cols = sectionProps.columns ?? [];
const spans = cols.map((c) => c.span).join("-");
@@ -471,12 +475,12 @@ export function SectionPropertiesPanel({ sectionProps, selectedBlockId, updateBl
updateBlock(selectedBlockId, { columns: nextColumns } as any);
}}
>
<option value="one-full">1 (1/1)</option>
<option value="two-equal">2 (1/2 - 1/2)</option>
<option value="two-1-2">2 (1/3 - 2/3)</option>
<option value="two-2-1">2 (2/3 - 1/3)</option>
<option value="three-equal">3 (1/3 - 1/3 - 1/3)</option>
<option value="custom"> </option>
<option value="one-full">{m.columnLayoutOptionOneFull}</option>
<option value="two-equal">{m.columnLayoutOptionTwoEqual}</option>
<option value="two-1-2">{m.columnLayoutOptionTwoOneTwo}</option>
<option value="two-2-1">{m.columnLayoutOptionTwoTwoOne}</option>
<option value="three-equal">{m.columnLayoutOptionThreeEqual}</option>
<option value="custom">{m.columnLayoutOptionCustom}</option>
</select>
</label>
@@ -680,8 +684,8 @@ export function SectionPropertiesPanel({ sectionProps, selectedBlockId, updateBl
{/* 최대 폭 슬라이더 (px) - 프리셋 + 자유 슬라이더 */}
<NumericPropertyControl
label="최대 폭 (px)"
unitLabel="(px)"
label={m.maxWidthLabel}
unitLabel={m.maxWidthUnitLabel}
value={typeof sectionProps.maxWidthPx === "number" ? sectionProps.maxWidthPx : 960}
min={640}
max={1440}
@@ -701,8 +705,8 @@ export function SectionPropertiesPanel({ sectionProps, selectedBlockId, updateBl
{/* 컬럼 간 간격 슬라이더 (px) - 프리셋 + 자유 슬라이더 */}
<NumericPropertyControl
label="컬럼 간 간격 (px)"
unitLabel="(px)"
label={m.gapXLabel}
unitLabel={m.gapXUnitLabel}
value={typeof sectionProps.gapXPx === "number" ? sectionProps.gapXPx : 24}
min={0}
max={64}
@@ -723,19 +727,19 @@ export function SectionPropertiesPanel({ sectionProps, selectedBlockId, updateBl
{/* 세로 정렬 */}
<label className="flex flex-col gap-1">
<span> </span>
<span>{m.alignItemsLabel}</span>
<select
className="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"
aria-label="섹션 컬럼 세로 정렬"
aria-label={m.alignItemsAria}
value={sectionProps.alignItems ?? "top"}
onChange={(e) => {
const value = e.target.value as NonNullable<SectionBlockProps["alignItems"]>;
updateBlock(selectedBlockId, { alignItems: value } as any);
}}
>
<option value="top"></option>
<option value="center"></option>
<option value="bottom"></option>
<option value="top">{m.alignItemsOptionTop}</option>
<option value="center">{m.alignItemsOptionCenter}</option>
<option value="bottom">{m.alignItemsOptionBottom}</option>
</select>
</label>
</div>
+51 -47
View File
@@ -4,6 +4,8 @@ import type { TextBlockProps } from "@/features/editor/state/editorStore";
import { PropertySliderField } from "@/features/editor/components/PropertySliderField";
import { ColorPickerField, TEXT_COLOR_PALETTE } from "@/features/editor/components/ColorPickerField";
import { NumericPropertyControl } from "@/features/editor/components/NumericPropertyControl";
import { useAppLocale } from "@/features/i18n/LocaleProvider";
import { getEditorTextPanelMessages } from "@/features/i18n/messages/editorTextPanel";
export type TextPropertiesPanelProps = {
textProps: TextBlockProps;
@@ -20,6 +22,8 @@ export function TextPropertiesPanel({
editingBlockId,
setEditingText,
}: TextPropertiesPanelProps) {
const locale = useAppLocale();
const m = getEditorTextPanelMessages(locale);
const fontSizeMode = textProps.fontSizeMode ?? "scale";
const fallbackScale =
textProps.size === "sm" ? "sm" : textProps.size === "lg" ? "lg" : "base";
@@ -97,10 +101,10 @@ export function TextPropertiesPanel({
return (
<>
<div className="space-y-1">
<p className="text-xs text-slate-400"> </p>
<p className="text-xs text-slate-400">{m.selectedTextLabel}</p>
<textarea
className="w-full min-h-[80px] 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"
aria-label="선택한 텍스트 블록 내용"
aria-label={m.selectedTextAria}
value={textProps.text}
onChange={(e) => {
const value = e.target.value;
@@ -114,26 +118,26 @@ export function TextPropertiesPanel({
<div className="space-y-1">
<label className="flex flex-col gap-1 text-xs text-slate-400">
<span></span>
<span>{m.alignLabel}</span>
<select
className="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"
aria-label="정렬"
aria-label={m.alignAria}
value={textProps.align}
onChange={(e) => {
const value = e.target.value as "left" | "center" | "right";
updateBlock(selectedBlockId, { align: value });
}}
>
<option value="left"></option>
<option value="center"></option>
<option value="right"></option>
<option value="left">{m.alignOptionLeft}</option>
<option value="center">{m.alignOptionCenter}</option>
<option value="right">{m.alignOptionRight}</option>
</select>
</label>
</div>
<div className="space-y-1">
<div className="flex items-center justify-between text-[11px] text-slate-400">
<span> </span>
<span>{m.textStyleLabel}</span>
<div className="flex gap-1">
<button
type="button"
@@ -148,7 +152,7 @@ export function TextPropertiesPanel({
} as any);
}}
>
{m.underlineLabel}
</button>
<button
type="button"
@@ -163,7 +167,7 @@ export function TextPropertiesPanel({
} as any);
}}
>
{m.strikeLabel}
</button>
<button
type="button"
@@ -178,7 +182,7 @@ export function TextPropertiesPanel({
} as any);
}}
>
{m.italicLabel}
</button>
</div>
</div>
@@ -186,8 +190,8 @@ export function TextPropertiesPanel({
<div className="space-y-1">
<NumericPropertyControl
label="글자 크기"
unitLabel="(px)"
label={m.fontSizeLabel}
unitLabel={m.fontSizeUnitLabel}
value={Number.isFinite(parsedFontSize) ? parsedFontSize : 16}
min={10}
max={72}
@@ -243,11 +247,11 @@ export function TextPropertiesPanel({
<div className="space-y-1">
<label className="flex flex-col gap-1 text-xs text-slate-400">
<span> </span>
<span>{m.letterSpacingLabel}</span>
<div className="flex items-center gap-2">
<select
className="w-32 rounded border border-slate-300 bg-white px-2 py-1 text-[11px] text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
aria-label="글자 간격 프리셋"
aria-label={m.letterSpacingPresetAria}
value={letterSpacingPreset}
onChange={(e) => {
const preset = e.target.value as
@@ -276,17 +280,17 @@ export function TextPropertiesPanel({
} as any);
}}
>
<option value="tighter"> </option>
<option value="tight"></option>
<option value="normal"></option>
<option value="wide"></option>
<option value="wider"> </option>
<option value="tighter">{m.letterSpacingPresetTighter}</option>
<option value="tight">{m.letterSpacingPresetTight}</option>
<option value="normal">{m.letterSpacingPresetNormal}</option>
<option value="wide">{m.letterSpacingPresetWide}</option>
<option value="wider">{m.letterSpacingPresetWider}</option>
</select>
<PropertySliderField
label=""
ariaLabelSlider="글자 간격 슬라이더"
ariaLabelInput="글자 간격 커스텀 (px)"
ariaLabelSlider={m.letterSpacingSliderAria}
ariaLabelInput={m.letterSpacingInputAria}
value={Number.isFinite(parsedLetterSpacingPx) ? parsedLetterSpacingPx : 0}
min={-2}
max={10}
@@ -304,18 +308,18 @@ export function TextPropertiesPanel({
<div className="space-y-1">
<NumericPropertyControl
label="줄 간격"
label={m.lineHeightLabel}
unitLabel=""
value={Number.isFinite(parsedLineHeight) ? parsedLineHeight : 1.5}
min={-1}
max={3}
step={0.05}
presets={[
{ id: "tight", label: "좁게", value: 1.25 },
{ id: "snug", label: "약간 좁게", value: 1.35 },
{ id: "normal", label: "보통", value: 1.5 },
{ id: "relaxed", label: "넓게", value: 1.7 },
{ id: "loose", label: "아주 넓게", value: 1.9 },
{ id: "tight", label: m.lineHeightPresetTight, value: 1.25 },
{ id: "snug", label: m.lineHeightPresetSnug, value: 1.35 },
{ id: "normal", label: m.lineHeightPresetNormal, value: 1.5 },
{ id: "relaxed", label: m.lineHeightPresetRelaxed, value: 1.7 },
{ id: "loose", label: m.lineHeightPresetLoose, value: 1.9 },
]}
onChangeValue={(v) => {
const preset: Record<NonNullable<TextBlockProps["lineHeightScale"]>, number> = {
@@ -350,7 +354,7 @@ export function TextPropertiesPanel({
<div className="space-y-1">
<NumericPropertyControl
label="굵기"
label={m.fontWeightLabel}
value={Number.isFinite(parsedFontWeight) ? parsedFontWeight : 400}
min={100}
max={900}
@@ -363,12 +367,12 @@ export function TextPropertiesPanel({
id: scale,
label:
scale === "normal"
? "보통"
? m.fontWeightPresetNormal
: scale === "medium"
? "중간"
? m.fontWeightPresetMedium
: scale === "semibold"
? "세미볼드"
: "볼드",
? m.fontWeightPresetSemibold
: m.fontWeightPresetBold,
value:
scale === "normal"
? 400
@@ -410,9 +414,9 @@ export function TextPropertiesPanel({
<div className="space-y-1">
<ColorPickerField
label="텍스트 색상"
ariaLabelColorInput="텍스트 색상 피커"
ariaLabelHexInput="텍스트 색상 HEX"
label={m.textColorLabel}
ariaLabelColorInput={m.textColorPickerAria}
ariaLabelHexInput={m.textColorHexAria}
value={
(textProps.colorCustom && textProps.colorCustom.startsWith("#")
? textProps.colorCustom
@@ -439,9 +443,9 @@ export function TextPropertiesPanel({
<div className="space-y-1">
<ColorPickerField
label="블록 배경색"
ariaLabelColorInput="텍스트 블록 배경색 피커"
ariaLabelHexInput="텍스트 블록 배경색 HEX"
label={m.backgroundColorLabel}
ariaLabelColorInput={m.backgroundColorPickerAria}
ariaLabelHexInput={m.backgroundColorHexAria}
value={textProps.backgroundColorCustom ?? ""}
onChange={(hex) => {
updateBlock(selectedBlockId, {
@@ -454,11 +458,11 @@ export function TextPropertiesPanel({
<div className="space-y-1">
<label className="flex flex-col gap-1 text-xs text-slate-400">
<span> </span>
<span>{m.maxWidthLabel}</span>
<div className="flex items-center gap-2">
<select
className="w-32 rounded border border-slate-300 bg-white px-2 py-1 text-[11px] text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
aria-label="최대 너비 프리셋"
aria-label={m.maxWidthPresetAria}
value={maxWidthScale}
onChange={(e) => {
const value = e.target.value as NonNullable<TextBlockProps["maxWidthScale"]>;
@@ -473,16 +477,16 @@ export function TextPropertiesPanel({
} as any);
}}
>
<option value="none"> </option>
<option value="prose"> (60ch)</option>
<option value="narrow"> (40ch)</option>
<option value="none">{m.maxWidthPresetNone}</option>
<option value="prose">{m.maxWidthPresetProse}</option>
<option value="narrow">{m.maxWidthPresetNarrow}</option>
</select>
<input
type="range"
min={20}
max={120}
step={5}
aria-label="최대 너비 슬라이더 (ch 단위)"
aria-label={m.maxWidthSliderAria}
value={(() => {
const raw = textProps.maxWidthCustom ?? "";
const match = raw.match(/([0-9]+)ch/);
@@ -503,8 +507,8 @@ export function TextPropertiesPanel({
</div>
<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"
aria-label="최대 너비 커스텀"
placeholder="예: 600px, 40rem, 80%, 60ch"
aria-label={m.maxWidthCustomAria}
placeholder={m.maxWidthPlaceholder}
value={textProps.maxWidthCustom ?? ""}
onChange={(e) => {
updateBlock(selectedBlockId, {
+53 -49
View File
@@ -3,6 +3,8 @@
import type { VideoBlockProps } from "@/features/editor/state/editorStore";
import { NumericPropertyControl } from "@/features/editor/components/NumericPropertyControl";
import { ColorPickerField, TEXT_COLOR_PALETTE } from "@/features/editor/components/ColorPickerField";
import { useAppLocale } from "@/features/i18n/LocaleProvider";
import { getEditorVideoPanelMessages } from "@/features/i18n/messages/editorVideoPanel";
export type VideoPropertiesPanelProps = {
videoProps: VideoBlockProps;
@@ -11,6 +13,8 @@ export type VideoPropertiesPanelProps = {
};
export function VideoPropertiesPanel({ videoProps, selectedBlockId, updateBlock }: VideoPropertiesPanelProps) {
const locale = useAppLocale();
const m = getEditorVideoPanelMessages(locale);
// 비디오 소스 유형: 업로드(/api/video/:id) 또는 외부 URL 구분
const source: "url" | "upload" =
videoProps.sourceType === "asset" || (videoProps.sourceUrl && videoProps.sourceUrl.startsWith("/api/video/"))
@@ -22,10 +26,10 @@ export function VideoPropertiesPanel({ videoProps, selectedBlockId, updateBlock
{/* 비디오 소스 선택 (URL / 업로드) */}
<div className="space-y-1">
<label className="flex flex-col gap-1 text-xs text-slate-400">
<span> </span>
<span>{m.sourceLabel}</span>
<select
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-[11px] text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
aria-label="비디오 소스"
aria-label={m.sourceAria}
value={source}
onChange={(e) => {
const next = e.target.value as "url" | "upload";
@@ -41,8 +45,8 @@ export function VideoPropertiesPanel({ videoProps, selectedBlockId, updateBlock
}
}}
>
<option value="url">URL</option>
<option value="upload"> </option>
<option value="url">{m.sourceOptionUrl}</option>
<option value="upload">{m.sourceOptionUpload}</option>
</select>
</label>
</div>
@@ -51,10 +55,10 @@ export function VideoPropertiesPanel({ videoProps, selectedBlockId, updateBlock
{source === "url" && (
<div className="space-y-1">
<label className="flex flex-col gap-1 text-xs text-slate-400">
<span> URL</span>
<span>{m.urlLabel}</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"
aria-label="비디오 URL"
aria-label={m.urlAria}
value={videoProps.sourceUrl ?? ""}
onChange={(e) => {
const value = e.target.value;
@@ -70,10 +74,10 @@ export function VideoPropertiesPanel({ videoProps, selectedBlockId, updateBlock
{/* 접근성: 제목 / aria-label / 캡션 텍스트 (현재는 UI에서 숨김 처리) */}
<div className="hidden">
<label className="flex flex-col gap-1 mt-2">
<span> </span>
<span>{m.titleLabel}</span>
<input
className="w-full rounded border border-slate-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
aria-label="비디오 제목"
aria-label={m.titleAria}
value={(videoProps as any).titleText ?? ""}
onChange={(e) => {
updateBlock(selectedBlockId, { titleText: e.target.value } as any);
@@ -82,10 +86,10 @@ export function VideoPropertiesPanel({ videoProps, selectedBlockId, updateBlock
</label>
<label className="mt-2 flex flex-col gap-1">
<span> aria-label</span>
<span>{m.ariaLabelLabel}</span>
<input
className="w-full rounded border border-slate-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
aria-label="비디오 aria-label"
aria-label={m.ariaLabelAria}
value={(videoProps as any).ariaLabel ?? ""}
onChange={(e) => {
updateBlock(selectedBlockId, { ariaLabel: e.target.value } as any);
@@ -94,10 +98,10 @@ export function VideoPropertiesPanel({ videoProps, selectedBlockId, updateBlock
</label>
<label className="mt-2 flex flex-col gap-1">
<span> </span>
<span>{m.captionTextLabel}</span>
<input
className="w-full rounded border border-slate-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
aria-label="비디오 캡션 텍스트"
aria-label={m.captionTextAria}
value={(videoProps as any).captionText ?? ""}
onChange={(e) => {
updateBlock(selectedBlockId, { captionText: e.target.value } as any);
@@ -110,10 +114,10 @@ export function VideoPropertiesPanel({ videoProps, selectedBlockId, updateBlock
<div className="mt-3 space-y-1 text-xs text-slate-400">
<label className="flex flex-col gap-1">
<span> URL</span>
<span>{m.posterImageUrlLabel}</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"
aria-label="포스터 이미지 URL"
aria-label={m.posterImageUrlAria}
value={videoProps.posterImageSrc ?? ""}
onChange={(e) => {
const value = e.target.value;
@@ -131,12 +135,12 @@ export function VideoPropertiesPanel({ videoProps, selectedBlockId, updateBlock
{source === "upload" && (
<div className="space-y-1">
<label className="flex flex-col gap-1 text-xs text-slate-400">
<span> </span>
<span>{m.uploadLabel}</span>
<input
type="file"
accept="video/*"
className="w-full text-[11px] text-slate-300 file:mr-2 file:rounded file:border file:border-slate-700 file:bg-slate-800 file:px-2 file:py-1 file:text-[11px] file:text-slate-100 hover:file:bg-slate-700"
aria-label="비디오 파일 업로드"
aria-label={m.uploadAria}
onChange={async (event) => {
const file = event.target.files?.[0];
if (!file) return;
@@ -175,14 +179,14 @@ export function VideoPropertiesPanel({ videoProps, selectedBlockId, updateBlock
)}
<div className="mt-3 space-y-2 border-t border-slate-800 pt-3 text-xs text-slate-400">
<h4 className="text-[11px] font-semibold text-slate-800 dark:text-slate-200"> </h4>
<h4 className="text-[11px] font-semibold text-slate-800 dark:text-slate-200">{m.styleSectionTitle}</h4>
{/* 카드 배경색 */}
<div className="space-y-1">
<ColorPickerField
label="카드 배경색"
ariaLabelColorInput="비디오 카드 배경색 피커"
ariaLabelHexInput="비디오 카드 배경색 HEX"
label={m.cardBackgroundLabel}
ariaLabelColorInput={m.cardBackgroundPickerAria}
ariaLabelHexInput={m.cardBackgroundHexAria}
value={videoProps.backgroundColorCustom ?? ""}
onChange={(hex) => {
const next = hex && hex.trim().length > 0 ? hex : undefined;
@@ -194,10 +198,10 @@ export function VideoPropertiesPanel({ videoProps, selectedBlockId, updateBlock
{/* 정렬 */}
<label className="flex flex-col gap-1">
<span> </span>
<span>{m.alignLabel}</span>
<select
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-[11px] text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
aria-label="비디오 정렬"
aria-label={m.alignAria}
value={videoProps.align ?? "center"}
onChange={(e) =>
updateBlock(selectedBlockId, {
@@ -205,18 +209,18 @@ export function VideoPropertiesPanel({ videoProps, selectedBlockId, updateBlock
} as any)
}
>
<option value="left"></option>
<option value="center"></option>
<option value="right"></option>
<option value="left">{m.alignOptionLeft}</option>
<option value="center">{m.alignOptionCenter}</option>
<option value="right">{m.alignOptionRight}</option>
</select>
</label>
{/* 너비 모드 */}
<label className="flex flex-col gap-1">
<span> </span>
<span>{m.widthModeLabel}</span>
<select
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-[11px] text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
aria-label="비디오 너비 모드"
aria-label={m.widthModeAria}
value={videoProps.widthMode ?? "auto"}
onChange={(e) =>
updateBlock(selectedBlockId, {
@@ -224,24 +228,24 @@ export function VideoPropertiesPanel({ videoProps, selectedBlockId, updateBlock
} as any)
}
>
<option value="auto"> </option>
<option value="full"> </option>
<option value="fixed"> (px)</option>
<option value="auto">{m.widthModeOptionAuto}</option>
<option value="full">{m.widthModeOptionFull}</option>
<option value="fixed">{m.widthModeOptionFixed}</option>
</select>
</label>
{(videoProps.widthMode ?? "auto") === "fixed" && (
<NumericPropertyControl
label="고정 너비 (px)"
unitLabel="(px)"
label={m.fixedWidthLabel}
unitLabel={m.fixedWidthUnitLabel}
value={videoProps.widthPx ?? 640}
min={160}
max={1920}
step={10}
presets={[
{ id: "sm", label: "작게", value: 480 },
{ id: "md", label: "보통", value: 640 },
{ id: "lg", label: "넓게", value: 960 },
{ id: "sm", label: m.fixedWidthPresetSmall, value: 480 },
{ id: "md", label: m.fixedWidthPresetMedium, value: 640 },
{ id: "lg", label: m.fixedWidthPresetLarge, value: 960 },
]}
onChangeValue={(v) => {
updateBlock(selectedBlockId, {
@@ -253,8 +257,8 @@ export function VideoPropertiesPanel({ videoProps, selectedBlockId, updateBlock
{/* 카드 패딩 */}
<NumericPropertyControl
label="카드 패딩"
unitLabel="(px)"
label={m.cardPaddingLabel}
unitLabel={m.cardPaddingUnitLabel}
value={typeof videoProps.cardPaddingPx === "number" ? videoProps.cardPaddingPx : 0}
min={0}
max={64}
@@ -267,8 +271,8 @@ export function VideoPropertiesPanel({ videoProps, selectedBlockId, updateBlock
{/* 카드 모서리 둥글기 */}
<NumericPropertyControl
label="카드 모서리 둥글기"
unitLabel="(px)"
label={m.cardBorderRadiusLabel}
unitLabel={m.cardBorderRadiusUnitLabel}
value={typeof videoProps.borderRadiusPx === "number" ? videoProps.borderRadiusPx : 0}
min={0}
max={64}
@@ -281,8 +285,8 @@ export function VideoPropertiesPanel({ videoProps, selectedBlockId, updateBlock
{/* 시작 시점 (초) */}
<NumericPropertyControl
label="시작 시점 (초)"
unitLabel="(초)"
label={m.startTimeLabel}
unitLabel={m.startTimeUnitLabel}
value={typeof videoProps.startTimeSec === "number" ? videoProps.startTimeSec : 0}
min={0}
max={600}
@@ -295,8 +299,8 @@ export function VideoPropertiesPanel({ videoProps, selectedBlockId, updateBlock
{/* 종료 시점 (초) */}
<NumericPropertyControl
label="종료 시점 (초)"
unitLabel="(초)"
label={m.endTimeLabel}
unitLabel={m.endTimeUnitLabel}
value={typeof videoProps.endTimeSec === "number" ? videoProps.endTimeSec : 0}
min={0}
max={600}
@@ -309,10 +313,10 @@ export function VideoPropertiesPanel({ videoProps, selectedBlockId, updateBlock
{/* 화면 비율 */}
<label className="flex flex-col gap-1">
<span> </span>
<span>{m.aspectRatioLabel}</span>
<select
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-[11px] text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
aria-label="화면 비율"
aria-label={m.aspectRatioAria}
value={videoProps.aspectRatio ?? "16:9"}
onChange={(e) =>
updateBlock(selectedBlockId, {
@@ -339,7 +343,7 @@ export function VideoPropertiesPanel({ videoProps, selectedBlockId, updateBlock
} as any)
}
/>
<span className="text-[11px]"> </span>
<span className="text-[11px]">{m.autoplayLabel}</span>
</label>
<label className="flex items-center gap-1">
<input
@@ -352,7 +356,7 @@ export function VideoPropertiesPanel({ videoProps, selectedBlockId, updateBlock
} as any)
}
/>
<span className="text-[11px]"> </span>
<span className="text-[11px]">{m.loopLabel}</span>
</label>
<label className="flex items-center gap-1">
<input
@@ -365,7 +369,7 @@ export function VideoPropertiesPanel({ videoProps, selectedBlockId, updateBlock
} as any)
}
/>
<span className="text-[11px]"></span>
<span className="text-[11px]">{m.mutedLabel}</span>
</label>
<label className="flex items-center gap-1">
<input
@@ -378,7 +382,7 @@ export function VideoPropertiesPanel({ videoProps, selectedBlockId, updateBlock
} as any)
}
/>
<span className="text-[11px]"> </span>
<span className="text-[11px]">{m.controlsLabel}</span>
</label>
</div>
</div>
+4 -8
View File
@@ -1,12 +1,14 @@
"use client";
import type { Block, SectionBlockProps, TextBlockProps, ImageBlockProps } from "@/features/editor/state/editorStore";
import type { EditorBlogTemplateMessages } from "@/features/i18n/messages/editorTemplates";
export function createBlogTemplateBlocks(opts: {
sectionId: string;
createId: () => string;
messages: EditorBlogTemplateMessages;
}): { blocks: Block[]; lastSelectedId: string } {
const { sectionId, createId } = opts;
const { sectionId, createId, messages } = opts;
const columns: SectionBlockProps["columns"] = [
{ id: `${sectionId}_col_1`, span: 4 },
@@ -33,14 +35,8 @@ export function createBlogTemplateBlocks(opts: {
columnId: null,
};
const postDefinitions = [
{ title: "블로그 포스트 1", summary: "첫 번째 포스트 요약을 여기에 입력하세요." },
{ title: "블로그 포스트 2", summary: "두 번째 포스트 요약을 여기에 입력하세요." },
{ title: "블로그 포스트 3", summary: "세 번째 포스트 요약을 여기에 입력하세요." },
];
const blogBlocks: Block[] = columns.flatMap((col, index) => {
const post = postDefinitions[index] ?? postDefinitions[0];
const post = messages.posts[index] ?? messages.posts[0];
const imageId = createId();
const titleId = createId();
+5 -3
View File
@@ -1,12 +1,14 @@
"use client";
import type { Block, SectionBlockProps, TextBlockProps, ButtonBlockProps } from "@/features/editor/state/editorStore";
import type { EditorCtaTemplateMessages } from "@/features/i18n/messages/editorTemplates";
export function createCtaTemplateBlocks(opts: {
sectionId: string;
createId: () => string;
messages: EditorCtaTemplateMessages;
}): { blocks: Block[]; lastSelectedId: string } {
const { sectionId, createId } = opts;
const { sectionId, createId, messages } = opts;
const columns: SectionBlockProps["columns"] = [
{ id: `${sectionId}_col_1`, span: 8 },
@@ -37,7 +39,7 @@ export function createCtaTemplateBlocks(opts: {
const textId = createId();
const textProps: TextBlockProps = {
text: "지금 바로 행동을 유도하는 CTA 텍스트를 입력하세요.\n망설이지 말고 시작하세요!",
text: messages.body,
align: "left",
size: "lg",
} as any;
@@ -51,7 +53,7 @@ export function createCtaTemplateBlocks(opts: {
const buttonId = createId();
const buttonProps: ButtonBlockProps = {
label: "CTA 버튼",
label: messages.buttonLabel,
href: "#",
align: "center",
size: "lg",
+6 -19
View File
@@ -1,12 +1,14 @@
"use client";
import type { Block, SectionBlockProps, TextBlockProps } from "@/features/editor/state/editorStore";
import type { EditorFaqTemplateMessages } from "@/features/i18n/messages/editorTemplates";
export function createFaqTemplateBlocks(opts: {
sectionId: string;
createId: () => string;
messages: EditorFaqTemplateMessages;
}): { blocks: Block[]; lastSelectedId: string } {
const { sectionId, createId } = opts;
const { sectionId, createId, messages } = opts;
const columns: SectionBlockProps["columns"] = [
{ id: `${sectionId}_col_1`, span: 3 },
@@ -38,7 +40,7 @@ export function createFaqTemplateBlocks(opts: {
// Left Column: Title
const titleId = createId();
const titleProps: TextBlockProps = {
text: "FAQ",
text: messages.title,
align: "left",
size: "xl",
bold: true,
@@ -53,7 +55,7 @@ export function createFaqTemplateBlocks(opts: {
const subTitleId = createId();
const subTitleProps: TextBlockProps = {
text: "자주 묻는 질문",
text: messages.subtitle,
align: "left",
size: "sm",
color: "muted",
@@ -68,22 +70,7 @@ export function createFaqTemplateBlocks(opts: {
// Right Column: Q&A List
const faqPairs = [
{
question: "서비스 이용료는 얼마인가요?",
answer: "기본 기능은 무료로 제공되며, 프리미엄 기능은 월 구독료가 발생합니다.",
},
{
question: "환불 정책은 어떻게 되나요?",
answer: "결제 후 7일 이내에 사용 이력이 없는 경우 전액 환불 가능합니다.",
},
{
question: "팀원 초대 기능이 있나요?",
answer: "네, 프로 요금제 이상부터 팀원 초대가 가능합니다.",
},
];
const faqBlocks: Block[] = faqPairs.flatMap((pair) => {
const faqBlocks: Block[] = messages.items.flatMap((pair) => {
const qId = createId();
const aId = createId();
+5 -3
View File
@@ -1,12 +1,14 @@
"use client";
import type { Block, SectionBlockProps, TextBlockProps } from "@/features/editor/state/editorStore";
import type { EditorFeaturesTemplateMessages } from "@/features/i18n/messages/editorTemplates";
export function createFeaturesTemplateBlocks(opts: {
sectionId: string;
createId: () => string;
messages: EditorFeaturesTemplateMessages;
}): { blocks: Block[]; lastSelectedId: string } {
const { sectionId, createId } = opts;
const { sectionId, createId, messages } = opts;
const columns = [
{ id: `${sectionId}_col_1`, span: 4 },
@@ -38,13 +40,13 @@ export function createFeaturesTemplateBlocks(opts: {
const descId = createId();
const titleProps: TextBlockProps = {
text: `Feature ${index + 1} 제목`,
text: `${messages.itemTitlePrefix} ${index + 1}${messages.itemTitleSuffix}`.trim(),
align: "left",
size: "lg",
} as any;
const descProps: TextBlockProps = {
text: "해당 기능을 간단히 설명하는 텍스트입니다.",
text: messages.itemDescription,
align: "left",
size: "sm",
} as any;
+5 -3
View File
@@ -1,12 +1,14 @@
"use client";
import type { Block, SectionBlockProps, TextBlockProps } from "@/features/editor/state/editorStore";
import type { EditorFooterTemplateMessages } from "@/features/i18n/messages/editorTemplates";
export function createFooterTemplateBlocks(opts: {
sectionId: string;
createId: () => string;
messages: EditorFooterTemplateMessages;
}): { blocks: Block[]; lastSelectedId: string } {
const { sectionId, createId } = opts;
const { sectionId, createId, messages } = opts;
const columns: SectionBlockProps["columns"] = [
{ id: `${sectionId}_col_1`, span: 4 },
@@ -54,7 +56,7 @@ export function createFooterTemplateBlocks(opts: {
const descId = createId();
const descProps: TextBlockProps = {
text: "더 나은 웹사이트를 위한 최고의 선택.",
text: messages.description,
align: "left",
size: "sm",
color: "muted",
@@ -70,7 +72,7 @@ export function createFooterTemplateBlocks(opts: {
// Col 2: Links
const linksId = createId();
const linksProps: TextBlockProps = {
text: "서비스 소개\n요금제\n고객지원\n문의하기",
text: messages.linksText,
align: "center",
size: "sm",
color: "muted",
+6 -4
View File
@@ -6,12 +6,14 @@ import type {
TextBlockProps,
ButtonBlockProps,
} from "@/features/editor/state/editorStore";
import type { EditorHeroTemplateMessages } from "@/features/i18n/messages/editorTemplates";
export function createHeroTemplateBlocks(opts: {
sectionId: string;
createId: () => string;
messages: EditorHeroTemplateMessages;
}): { blocks: Block[]; lastSelectedId: string } {
const { sectionId, createId } = opts;
const { sectionId, createId, messages } = opts;
const sectionProps: SectionBlockProps = {
background: "default",
@@ -41,7 +43,7 @@ export function createHeroTemplateBlocks(opts: {
const heroHeadlineId = createId();
const heroHeadlineProps: TextBlockProps = {
text: "Hero 제목을 여기에 입력하세요",
text: messages.headline,
align: "center",
size: "lg",
} as any;
@@ -55,7 +57,7 @@ export function createHeroTemplateBlocks(opts: {
const heroSubId = createId();
const heroSubProps: TextBlockProps = {
text: "제품이나 서비스를 한 문장으로 설명하는 서브텍스트입니다.",
text: messages.subheadline,
align: "center",
size: "base",
} as any;
@@ -69,7 +71,7 @@ export function createHeroTemplateBlocks(opts: {
const heroButtonId = createId();
const heroButtonProps: ButtonBlockProps = {
label: "지금 시작하기",
label: messages.primaryButtonLabel,
href: "#",
align: "center",
size: "md",
+4 -8
View File
@@ -1,12 +1,14 @@
"use client";
import type { Block, SectionBlockProps, TextBlockProps, ImageBlockProps } from "@/features/editor/state/editorStore";
import type { EditorTeamTemplateMessages } from "@/features/i18n/messages/editorTemplates";
export function createTeamTemplateBlocks(opts: {
sectionId: string;
createId: () => string;
messages: EditorTeamTemplateMessages;
}): { blocks: Block[]; lastSelectedId: string } {
const { sectionId, createId } = opts;
const { sectionId, createId, messages } = opts;
const columns: SectionBlockProps["columns"] = [
{ id: `${sectionId}_col_1`, span: 4 },
@@ -33,14 +35,8 @@ export function createTeamTemplateBlocks(opts: {
columnId: null,
};
const memberDefinitions = [
{ name: "홍길동", role: "Product Designer", bio: "사용자 경험을 설계합니다." },
{ name: "김영희", role: "Frontend Engineer", bio: "깔끔한 UI를 구현합니다." },
{ name: "이철수", role: "Backend Engineer", bio: "안정적인 인프라를 책임집니다." },
];
const teamBlocks: Block[] = columns.flatMap((col, index) => {
const m = memberDefinitions[index] ?? memberDefinitions[0];
const m = messages.members[index] ?? messages.members[0];
const imageId = createId();
const nameId = createId();
+14 -3
View File
@@ -1,17 +1,28 @@
import "../styles/globals.css";
import "../styles/builder.css";
import type { ReactNode } from "react";
import { headers } from "next/headers";
import { LocaleProvider } from "@/features/i18n/LocaleProvider";
import { LocaleSwitcher } from "@/features/i18n/LocaleSwitcher";
import { resolveLocaleFromAcceptLanguage, DEFAULT_LOCALE } from "@/features/i18n/locale";
export const metadata = {
title: "Page Builder",
description: "No-code single page builder",
};
export default function RootLayout({ children }: { children: ReactNode }) {
export default async function RootLayout({ children }: { children: ReactNode }) {
const headerList = await headers();
const acceptLanguage = headerList.get("accept-language");
const locale = resolveLocaleFromAcceptLanguage(acceptLanguage ?? undefined) ?? DEFAULT_LOCALE;
return (
<html lang="ko" suppressHydrationWarning>
<html lang={locale} suppressHydrationWarning>
<body className="min-h-screen bg-slate-50 text-slate-900 dark:bg-slate-950 dark:text-slate-50">
{children}
<LocaleProvider initialLocale={locale}>
{children}
<LocaleSwitcher />
</LocaleProvider>
</body>
</html>
);
+18 -12
View File
@@ -4,6 +4,8 @@ import { FormEvent, useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { SunMoon } from "lucide-react";
import { useAppLocale } from "@/features/i18n/LocaleProvider";
import { getAuthMessages } from "@/features/i18n/messages/auth";
// 로그인 페이지 컴포넌트
// - 이메일/비밀번호를 입력받아 /api/auth/login 으로 요청을 전송한다.
@@ -12,6 +14,9 @@ import { SunMoon } from "lucide-react";
export default function LoginPage() {
const router = useRouter();
const locale = useAppLocale();
const { login: t } = getAuthMessages(locale);
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
@@ -67,9 +72,9 @@ export default function LoginPage() {
if (!res.ok) {
try {
const data = await res.json();
setError(data?.message ?? "로그인에 실패했습니다. 다시 시도해 주세요.");
setError(data?.message ?? t.errorLoginFailed);
} catch {
setError("로그인에 실패했습니다. 다시 시도해 주세요.");
setError(t.errorLoginFailed);
}
setLoading(false);
return;
@@ -78,7 +83,7 @@ export default function LoginPage() {
// 성공 시에는 /dashboard 로 이동한다.
router.push("/dashboard");
} catch {
setError("네트워크 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.");
setError(t.errorNetwork);
} finally {
setLoading(false);
}
@@ -88,24 +93,24 @@ export default function LoginPage() {
<main className="min-h-screen flex items-center justify-center bg-slate-100 text-slate-900 dark:bg-slate-950 dark:text-slate-50">
<div className="w-full max-w-sm rounded-lg border border-slate-200 bg-white p-6 shadow-xl dark:border-slate-800 dark:bg-slate-900/70">
<div className="flex items-center justify-between mb-1">
<h1 className="text-lg font-semibold"></h1>
<h1 className="text-lg font-semibold">{t.title}</h1>
<button
type="button"
className="inline-flex items-center gap-1 rounded-full border border-slate-300 bg-white/80 px-2 py-1 text-[11px] font-medium text-slate-700 hover:bg-slate-100 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-200 dark:hover:bg-slate-800"
onClick={handleToggleTheme}
>
<SunMoon className="w-4 h-4" aria-hidden="true" />
<span> </span>
<span>{t.themeToggleLabel}</span>
</button>
</div>
<p className="text-xs text-slate-500 mb-4 dark:text-slate-400"> .</p>
<p className="text-xs text-slate-500 mb-4 dark:text-slate-400">{t.description}</p>
{error && <p className="mb-3 text-xs text-red-300">{error}</p>}
<form onSubmit={handleSubmit} className="space-y-3 text-xs">
<div className="space-y-1">
<label htmlFor="email" className="block text-slate-800 dark:text-slate-200">
{t.emailLabel}
</label>
<input
id="email"
@@ -119,7 +124,7 @@ export default function LoginPage() {
<div className="space-y-1">
<label htmlFor="password" className="block text-slate-800 dark:text-slate-200">
{t.passwordLabel}
</label>
<input
id="password"
@@ -137,17 +142,18 @@ export default function LoginPage() {
disabled={loading}
className="mt-2 w-full inline-flex items-center justify-center gap-1 rounded bg-sky-600 hover:bg-sky-500 disabled:opacity-40 disabled:cursor-not-allowed px-3 py-1 text-xs font-medium"
>
{loading ? "로그인 중..." : "로그인"}
{loading ? t.submitLoading : t.submitIdle}
</button>
</form>
<p className="mt-4 text-[11px] text-slate-500 dark:text-slate-400">
{t.signupPromptPrefix}
{" "}
<Link href="/signup" className="text-sky-300 hover:text-sky-200 underline-offset-2 hover:underline">
{t.signupLinkText}
</Link>
.
{t.signupPromptSuffix && " "}
{t.signupPromptSuffix}
</p>
</div>
</main>
+9 -5
View File
@@ -4,11 +4,15 @@ import type { CSSProperties } from "react";
import { useEffect } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useAppLocale } from "@/features/i18n/LocaleProvider";
import { getPreviewMessages } from "@/features/i18n/messages/preview";
import { useEditorStore } from "@/features/editor/state/editorStore";
import { PublicPageRenderer } from "@/features/editor/components/PublicPageRenderer";
export default function PreviewPage() {
const router = useRouter();
const locale = useAppLocale();
const m = getPreviewMessages(locale);
const blocks = useEditorStore((state) => state.blocks);
const projectConfig = useEditorStore((state) => (state as any).projectConfig);
const replaceBlocks = useEditorStore((state) => state.replaceBlocks);
@@ -164,8 +168,8 @@ export default function PreviewPage() {
<main className="min-h-screen flex flex-col" style={mainStyle}>
<header className="border-b border-slate-200 px-6 py-4 flex items-center justify-between bg-white/80 backdrop-blur dark:border-slate-800 dark:bg-slate-950/80">
<div>
<h1 className="text-xl font-semibold">Page Preview</h1>
<p className="text-xs text-slate-500 dark:text-slate-400"> </p>
<h1 className="text-xl font-semibold">{m.headerTitle}</h1>
<p className="text-xs text-slate-500 dark:text-slate-400">{m.headerDescription}</p>
</div>
<div className="flex items-center gap-2 text-xs">
<button
@@ -175,19 +179,19 @@ export default function PreviewPage() {
void handleExportZip();
}}
>
(ZIP)
{m.exportZipButtonLabel}
</button>
<Link
href="/projects"
className="inline-flex items-center rounded border border-slate-300 bg-white px-3 py-1 text-xs text-slate-800 hover:bg-slate-100 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100 dark:hover:bg-slate-800"
>
{m.navProjects}
</Link>
<Link
href={editorHref}
className="inline-flex items-center rounded border border-slate-300 bg-white px-3 py-1 text-xs text-slate-800 hover:bg-slate-100 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100 dark:hover:bg-slate-800"
>
{m.navBackToEditor}
</Link>
</div>
</header>
+47 -45
View File
@@ -15,6 +15,9 @@ import {
FolderKanban,
SunMoon,
} from "lucide-react";
import { useAppLocale } from "@/features/i18n/LocaleProvider";
import { getDashboardMessages } from "@/features/i18n/messages/dashboard";
import { getProjectsMessages } from "@/features/i18n/messages/projects";
interface ProjectListItem {
id: string;
@@ -27,6 +30,9 @@ interface ProjectListItem {
export default function ProjectsPage() {
const router = useRouter();
const locale = useAppLocale();
const t = getDashboardMessages(locale);
const p = getProjectsMessages(locale);
const [projects, setProjects] = useState<ProjectListItem[]>([]);
const [status, setStatus] = useState<"idle" | "loading" | "error">("idle");
const [errorMessage, setErrorMessage] = useState<string | null>(null);
@@ -49,12 +55,10 @@ export default function ProjectsPage() {
setStatus("error");
if (res.status === 401) {
setErrorMessage("프로젝트 목록을 불러오는 중 인증 오류가 발생했습니다. 다시 로그인해 주세요.");
setErrorMessage(p.errorFetchUnauthorized);
router.push("/login");
} else {
setErrorMessage(
"프로젝트 목록을 불러오는 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.",
);
setErrorMessage(p.errorFetchGeneric);
}
}
return;
@@ -69,7 +73,7 @@ export default function ProjectsPage() {
} catch {
if (!cancelled) {
setStatus("error");
setErrorMessage("프로젝트 목록을 불러오는 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.");
setErrorMessage(p.errorFetchGeneric);
}
}
};
@@ -88,9 +92,7 @@ export default function ProjectsPage() {
const handleDelete = async (slug: string) => {
if (typeof window !== "undefined") {
const confirmed = window.confirm(
"이 프로젝트를 삭제할까요? 삭제 후에는 되돌릴 수 없습니다. (로컬 저장 및 자동저장 데이터도 함께 삭제됩니다.)",
);
const confirmed = window.confirm(p.confirmDeleteSingle);
if (!confirmed) return;
}
@@ -102,7 +104,7 @@ export default function ProjectsPage() {
if (!res.ok) {
setStatus("error");
setErrorMessage("프로젝트 삭제 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.");
setErrorMessage(p.errorDeleteSingle);
return;
}
@@ -118,7 +120,7 @@ export default function ProjectsPage() {
setErrorMessage(null);
} catch {
setStatus("error");
setErrorMessage("프로젝트 삭제 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.");
setErrorMessage(p.errorDeleteSingle);
}
};
@@ -152,9 +154,7 @@ export default function ProjectsPage() {
if (slugs.length === 0) return;
if (typeof window !== "undefined") {
const confirmed = window.confirm(
"선택한 프로젝트를 모두 삭제할까요? 삭제 후에는 되돌릴 수 없습니다. (로컬 저장 및 자동저장 데이터도 함께 삭제됩니다.)",
);
const confirmed = window.confirm(p.confirmDeleteBulk);
if (!confirmed) return;
}
@@ -170,7 +170,7 @@ export default function ProjectsPage() {
if (okSlugs.length === 0) {
setStatus("error");
setErrorMessage("선택한 프로젝트를 삭제하는 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.");
setErrorMessage(p.errorDeleteBulkAllFailed);
return;
}
@@ -186,16 +186,14 @@ export default function ProjectsPage() {
if (okSlugs.length !== slugs.length) {
setStatus("error");
setErrorMessage(
"일부 프로젝트 삭제에 실패했습니다. 페이지를 새로고침한 뒤 목록을 확인해 주세요.",
);
setErrorMessage(p.errorDeleteBulkSomeFailed);
} else {
setStatus("idle");
setErrorMessage(null);
}
} catch {
setStatus("error");
setErrorMessage("선택한 프로젝트를 삭제하는 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.");
setErrorMessage(p.errorDeleteBulkAllFailed);
}
};
@@ -216,8 +214,8 @@ export default function ProjectsPage() {
<main className="min-h-screen flex flex-col bg-slate-100 text-slate-900 dark:bg-slate-950 dark:text-slate-50">
<header className="border-b border-slate-200 px-6 py-4 flex items-center justify-between bg-white/80 backdrop-blur dark:border-slate-800 dark:bg-slate-950/80">
<div>
<h1 className="text-2xl font-bold tracking-tight"> </h1>
<p className="text-sm text-slate-500 dark:text-slate-400"> </p>
<h1 className="text-2xl font-bold tracking-tight">{p.headerTitle}</h1>
<p className="text-sm text-slate-500 dark:text-slate-400">{p.headerDescription}</p>
</div>
<div className="flex items-center gap-3 text-sm">
<nav className="inline-flex items-center gap-1 rounded-full border border-slate-200 bg-white/80 px-1.5 py-1 shadow-sm dark:border-slate-700 dark:bg-slate-900/80">
@@ -226,7 +224,7 @@ export default function ProjectsPage() {
className="inline-flex items-center gap-1 px-3 py-1.5 rounded-full text-sm font-medium text-slate-700 hover:bg-slate-100 hover:text-sky-700 dark:text-slate-200 dark:hover:bg-slate-800 dark:hover:text-sky-200"
>
<LayoutDashboard className="w-4 h-4" aria-hidden="true" />
<span></span>
<span>{t.navDashboard}</span>
</Link>
<Link
href="/projects"
@@ -234,14 +232,14 @@ export default function ProjectsPage() {
className="inline-flex items-center gap-1 px-3 py-1.5 rounded-full text-sm font-semibold bg-sky-600 text-white shadow-sm hover:bg-sky-700"
>
<FolderKanban className="w-4 h-4" aria-hidden="true" />
<span> </span>
<span>{t.navProjects}</span>
</Link>
<Link
href="/projects/submissions"
className="inline-flex items-center gap-1 px-3 py-1.5 rounded-full text-sm font-medium text-emerald-700 hover:bg-emerald-50 hover:text-emerald-800 dark:text-emerald-200 dark:hover:bg-emerald-900/60 dark:hover:text-emerald-100"
>
<ListChecks className="w-4 h-4" aria-hidden="true" />
<span> </span>
<span>{t.navSubmissions}</span>
</Link>
</nav>
<button
@@ -250,7 +248,7 @@ export default function ProjectsPage() {
onClick={handleToggleTheme}
>
<SunMoon className="w-4 h-4" aria-hidden="true" />
<span> </span>
<span>{t.themeToggleLabel}</span>
</button>
<div className="relative">
<button
@@ -260,7 +258,7 @@ export default function ProjectsPage() {
setIsMenuOpen((prev) => !prev);
}}
>
{t.menuLabel}
</button>
{isMenuOpen && (
<div className="absolute right-0 mt-1 w-32 rounded border border-slate-200 bg-white shadow-lg z-20 dark:border-slate-700 dark:bg-slate-900/95">
@@ -272,7 +270,7 @@ export default function ProjectsPage() {
void handleLogout();
}}
>
{t.logoutLabel}
</button>
</div>
)}
@@ -282,7 +280,7 @@ export default function ProjectsPage() {
<section className="flex-1 px-6 py-4 overflow-auto bg-slate-50/60 dark:bg-transparent">
{status === "error" && (
<p className="text-xs text-red-500 mb-3 dark:text-red-300">
{errorMessage ?? "프로젝트 목록을 불러오는 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요."}
{errorMessage ?? p.errorFetchGeneric}
</p>
)}
{projects.length === 0 && status === "idle" && (
@@ -298,7 +296,9 @@ export default function ProjectsPage() {
<span className="inline-flex items-center gap-1 px-2 py-1 rounded-full border bg-slate-100 border-slate-300 text-slate-700 dark:bg-slate-900/70 dark:border-slate-700 dark:text-slate-200">
<ListChecks className="w-3 h-3 text-slate-400 dark:text-slate-500" aria-hidden="true" />
<span>
: <span className="font-semibold">{selectedSlugs.length}</span>
{p.toolbarSelectedPrefix}
<span className="font-semibold">{selectedSlugs.length}</span>
{p.toolbarSelectedSuffix}
</span>
</span>
</div>
@@ -312,14 +312,14 @@ export default function ProjectsPage() {
}}
>
<Trash2 className="w-3 h-3" aria-hidden="true" />
<span> </span>
<span>{p.toolbarDeleteSelected}</span>
</button>
<Link
href="/editor?new=1"
className="inline-flex items-center gap-1 rounded border px-2 py-1 text-xs font-medium border-sky-500 bg-sky-600 text-white hover:bg-sky-700 shadow-sm dark:border-sky-700 dark:bg-sky-950 dark:text-sky-100 dark:hover:bg-sky-900"
>
<FilePlus2 className="w-3 h-3" aria-hidden="true" />
<span> </span>
<span>{p.toolbarCreateNew}</span>
</Link>
</div>
</div>
@@ -330,7 +330,7 @@ export default function ProjectsPage() {
<input
type="checkbox"
className="h-3 w-3 rounded border-slate-700 bg-slate-900 align-middle"
aria-label="전체 선택"
aria-label={p.tableHeaderSelectAllAria}
checked={
pageProjects.length > 0 && pageProjects.every((p) => selectedSlugs.includes(p.slug))
}
@@ -345,12 +345,12 @@ export default function ProjectsPage() {
}}
/>
</th>
<th className="py-2 pr-4"></th>
<th className="py-2 pr-4">(slug)</th>
<th className="py-2 pr-4"></th>
<th className="py-2 pr-4"></th>
<th className="py-2 pr-4"></th>
<th className="py-2 pr-4 text-right"></th>
<th className="py-2 pr-4">{p.tableHeaderTitle}</th>
<th className="py-2 pr-4">{p.tableHeaderSlug}</th>
<th className="py-2 pr-4">{p.tableHeaderStatus}</th>
<th className="py-2 pr-4">{p.tableHeaderCreatedAt}</th>
<th className="py-2 pr-4">{p.tableHeaderUpdatedAt}</th>
<th className="py-2 pr-4 text-right">{p.tableHeaderActions}</th>
</tr>
</thead>
<tbody>
@@ -365,7 +365,7 @@ export default function ProjectsPage() {
<input
type="checkbox"
className="h-3 w-3 rounded border-slate-700 bg-slate-900 align-middle"
aria-label={`${project.title} 선택`}
aria-label={`${project.title}${p.tableRowSelectAriaSuffix}`}
checked={checked}
onChange={(e) => {
if (e.target.checked) {
@@ -394,21 +394,21 @@ export default function ProjectsPage() {
className="inline-flex items-center gap-1 text-sky-600 hover:text-sky-700 underline-offset-2 hover:underline dark:text-sky-300 dark:hover:text-sky-200"
>
<Pencil className="w-3 h-3" aria-hidden="true" />
<span></span>
<span>{p.actionEdit}</span>
</Link>
<Link
href={`/preview?slug=${encodeURIComponent(project.slug)}`}
className="inline-flex items-center gap-1 text-slate-600 hover:text-slate-800 underline-offset-2 hover:underline dark:text-slate-300 dark:hover:text-slate-100"
>
<Eye className="w-3 h-3" aria-hidden="true" />
<span></span>
<span>{p.actionPreview}</span>
</Link>
<Link
href={`/projects/${encodeURIComponent(project.slug)}/submissions`}
className="inline-flex items-center gap-1 text-emerald-600 hover:text-emerald-700 underline-offset-2 hover:underline dark:text-emerald-300 dark:hover:text-emerald-100"
>
<ListChecks className="w-3 h-3" aria-hidden="true" />
<span> </span>
<span>{p.actionViewSubmissions}</span>
</Link>
<button
type="button"
@@ -418,7 +418,7 @@ export default function ProjectsPage() {
}}
>
<Trash2 className="w-3 h-3" aria-hidden="true" />
<span></span>
<span>{p.actionDelete}</span>
</button>
</div>
</td>
@@ -431,7 +431,9 @@ export default function ProjectsPage() {
<div className="mt-3 flex items-center justify-between text-[11px] text-slate-600 dark:text-slate-300">
<div className="inline-flex items-center gap-2">
<span className="px-2 py-1 rounded-full bg-slate-100 border border-slate-300 text-slate-700 dark:bg-slate-900/70 dark:border-slate-700 dark:text-slate-200">
{totalCount} {" "}
{p.pagerTotalPrefix}
{totalCount}
{p.pagerTotalCountSuffix}
<span className="font-semibold">
{totalCount === 0 ? 0 : startIndex + 1}{Math.min(endIndex, totalCount)}
</span>
@@ -465,7 +467,7 @@ export default function ProjectsPage() {
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
>
<ChevronLeft className="w-3 h-3" aria-hidden="true" />
<span></span>
<span>{p.pagerPrevLabel}</span>
</button>
<button
type="button"
@@ -473,7 +475,7 @@ export default function ProjectsPage() {
className="inline-flex items-center gap-1 px-2 py-1 rounded border border-slate-300 text-slate-700 bg-white disabled:opacity-40 disabled:cursor-not-allowed hover:bg-slate-100 dark:border-slate-700 dark:text-slate-300 dark:bg-slate-900 dark:hover:bg-slate-800"
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
>
<span></span>
<span>{p.pagerNextLabel}</span>
<ChevronRight className="w-3 h-3" aria-hidden="true" />
</button>
</div>
+30 -24
View File
@@ -4,6 +4,9 @@ import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { FolderKanban, LayoutDashboard, ListChecks, SunMoon } from "lucide-react";
import { useAppLocale } from "@/features/i18n/LocaleProvider";
import { getDashboardMessages } from "@/features/i18n/messages/dashboard";
import { getSubmissionsMessages } from "@/features/i18n/messages/submissions";
interface SubmissionItem {
id: string;
@@ -17,6 +20,9 @@ type PageStatus = "idle" | "loading" | "error";
export default function AllProjectSubmissionsPage() {
const router = useRouter();
const locale = useAppLocale();
const t = getDashboardMessages(locale);
const m = getSubmissionsMessages(locale);
const [submissions, setSubmissions] = useState<SubmissionItem[]>([]);
const [status, setStatus] = useState<PageStatus>("idle");
const [errorMessage, setErrorMessage] = useState<string | null>(null);
@@ -37,13 +43,13 @@ export default function AllProjectSubmissionsPage() {
if (res.status === 401) {
setStatus("error");
setErrorMessage("폼 제출 내역을 조회하려면 로그인이 필요합니다. 다시 로그인해 주세요.");
setErrorMessage(m.errorUnauthorized);
router.push("/login");
return;
}
setStatus("error");
setErrorMessage("폼 제출 내역을 불러오는 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.");
setErrorMessage(m.errorGeneric);
return;
}
@@ -57,7 +63,7 @@ export default function AllProjectSubmissionsPage() {
} catch {
if (!cancelled) {
setStatus("error");
setErrorMessage("폼 제출 내역을 불러오는 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.");
setErrorMessage(m.errorGeneric);
}
}
};
@@ -92,13 +98,13 @@ export default function AllProjectSubmissionsPage() {
});
if (!res.ok) {
setErrorMessage("로그아웃 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.");
setErrorMessage(m.errorLogout);
return;
}
router.push("/login");
} catch {
setErrorMessage("로그아웃 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.");
setErrorMessage(m.errorLogout);
}
};
@@ -106,8 +112,8 @@ export default function AllProjectSubmissionsPage() {
<main className="min-h-screen flex flex-col bg-slate-100 text-slate-900 dark:bg-slate-950 dark:text-slate-50">
<header className="border-b border-slate-200 px-6 py-4 flex items-center justify-between bg-white/80 backdrop-blur dark:border-slate-800 dark:bg-slate-950/80">
<div>
<h1 className="text-2xl font-bold tracking-tight"> </h1>
<p className="text-sm text-slate-500 dark:text-slate-400"> .</p>
<h1 className="text-2xl font-bold tracking-tight">{m.headerTitle}</h1>
<p className="text-sm text-slate-500 dark:text-slate-400">{m.headerDescription}</p>
</div>
<div className="flex items-center gap-3 text-sm">
<nav className="inline-flex items-center gap-1 rounded-full border border-slate-200 bg-white/80 px-1.5 py-1 shadow-sm dark:border-slate-700 dark:bg-slate-900/80">
@@ -116,14 +122,14 @@ export default function AllProjectSubmissionsPage() {
className="inline-flex items-center gap-1 px-3 py-1.5 rounded-full text-sm font-medium text-slate-700 hover:bg-slate-100 hover:text-sky-700 dark:text-slate-200 dark:hover:bg-slate-800 dark:hover:text-sky-200"
>
<LayoutDashboard className="w-4 h-4" aria-hidden="true" />
<span></span>
<span>{t.navDashboard}</span>
</Link>
<Link
href="/projects"
className="inline-flex items-center gap-1 px-3 py-1.5 rounded-full text-sm font-medium text-slate-700 hover:bg-slate-100 hover:text-sky-700 dark:text-slate-200 dark:hover:bg-slate-800 dark:hover:text-sky-200"
>
<FolderKanban className="w-4 h-4" aria-hidden="true" />
<span> </span>
<span>{t.navProjects}</span>
</Link>
<Link
href="/projects/submissions"
@@ -131,7 +137,7 @@ export default function AllProjectSubmissionsPage() {
className="inline-flex items-center gap-1 px-3 py-1.5 rounded-full text-sm font-semibold bg-emerald-600 text-white shadow-sm hover:bg-emerald-700"
>
<ListChecks className="w-4 h-4" aria-hidden="true" />
<span> </span>
<span>{t.navSubmissions}</span>
</Link>
</nav>
<button
@@ -151,7 +157,7 @@ export default function AllProjectSubmissionsPage() {
}}
>
<SunMoon className="w-4 h-4" aria-hidden="true" />
<span> </span>
<span>{t.themeToggleLabel}</span>
</button>
<div className="relative">
<button
@@ -161,7 +167,7 @@ export default function AllProjectSubmissionsPage() {
setIsMenuOpen((prev) => !prev);
}}
>
{t.menuLabel}
</button>
{isMenuOpen && (
<div className="absolute right-0 mt-1 w-32 rounded border border-slate-200 bg-white shadow-lg z-20 dark:border-slate-700 dark:bg-slate-900/95">
@@ -173,7 +179,7 @@ export default function AllProjectSubmissionsPage() {
void handleLogout();
}}
>
{t.logoutLabel}
</button>
</div>
)}
@@ -187,25 +193,25 @@ export default function AllProjectSubmissionsPage() {
)}
{status === "loading" && (
<p className="text-xs text-slate-500 mb-3 dark:text-slate-400"> ...</p>
<p className="text-xs text-slate-500 mb-3 dark:text-slate-400">{m.loadingText}</p>
)}
{submissions.length === 0 && status === "idle" && !errorMessage && (
<p className="text-xs text-slate-500 dark:text-slate-400"> .</p>
{status === "idle" && !errorMessage && submissions.length === 0 && (
<p className="text-xs text-slate-500 dark:text-slate-400">{m.emptyText}</p>
)}
{submissions.length > 0 && (
<table className="w-full text-xs text-left border-collapse mt-2">
<thead>
<tr className="border-b border-slate-200 text-slate-600 dark:border-slate-800 dark:text-slate-400">
<th className="py-2 pr-4"> </th>
<th className="py-2 pr-4"></th>
<th className="py-2 pr-4">Slug</th>
<th className="py-2 pr-4"></th>
<th className="py-2 pr-4"></th>
<th className="py-2 pr-4"></th>
<th className="py-2 pr-4"></th>
<th className="py-2 pr-4"> </th>
<th className="py-2 pr-4">{m.tableHeaderCreatedAt}</th>
<th className="py-2 pr-4">{m.tableHeaderProject}</th>
<th className="py-2 pr-4">{m.tableHeaderSlug}</th>
<th className="py-2 pr-4">{m.tableHeaderName}</th>
<th className="py-2 pr-4">{m.tableHeaderEmail}</th>
<th className="py-2 pr-4">{m.tableHeaderPhone}</th>
<th className="py-2 pr-4">{m.tableHeaderBirthdate}</th>
<th className="py-2 pr-4">{m.tableHeaderOtherFields}</th>
</tr>
</thead>
<tbody>
+37 -22
View File
@@ -3,8 +3,10 @@
import { FormEvent, useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { useAppLocale } from "@/features/i18n/LocaleProvider";
import { getAuthMessages } from "@/features/i18n/messages/auth";
type PasswordStrengthLabel = "약함" | "보통" | "강함";
type PasswordStrengthLabel = "weak" | "medium" | "strong";
function getPasswordStrengthLabel(password: string): PasswordStrengthLabel | null {
if (!password || password.length < 4) {
@@ -19,19 +21,22 @@ function getPasswordStrengthLabel(password: string): PasswordStrengthLabel | nul
if (password.length >= 12) score += 1;
if (password.length < 8 || score <= 2) {
return "약함";
return "weak";
}
if (score === 3) {
return "보통";
return "medium";
}
return "강함";
return "strong";
}
export default function SignupPage() {
const router = useRouter();
const locale = useAppLocale();
const { signup: t } = getAuthMessages(locale);
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [passwordConfirm, setPasswordConfirm] = useState("");
@@ -64,7 +69,7 @@ export default function SignupPage() {
setError(null);
if (password !== passwordConfirm) {
setError("비밀번호와 비밀번호 확인이 일치하지 않습니다.");
setError(t.mismatchError);
return;
}
@@ -80,9 +85,9 @@ export default function SignupPage() {
if (!res.ok) {
try {
const data = await res.json();
setError(data?.message ?? "회원가입에 실패했습니다. 다시 시도해 주세요.");
setError(data?.message ?? t.signupFailedFallbackError);
} catch {
setError("회원가입에 실패했습니다. 다시 시도해 주세요.");
setError(t.signupFailedFallbackError);
}
setLoading(false);
return;
@@ -90,26 +95,35 @@ export default function SignupPage() {
router.push("/dashboard");
} catch {
setError("네트워크 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.");
setError(t.errorNetwork);
} finally {
setLoading(false);
}
};
const strengthLabel = getPasswordStrengthLabel(password);
let strengthText: string | null = null;
if (strengthLabel) {
strengthText =
strengthLabel === "weak"
? t.passwordStrengthWeak
: strengthLabel === "medium"
? t.passwordStrengthMedium
: t.passwordStrengthStrong;
}
return (
<main className="min-h-screen flex items-center justify-center bg-slate-100 text-slate-900 dark:bg-slate-950 dark:text-slate-50">
<div className="w-full max-w-sm rounded-lg border border-slate-200 bg-white p-6 shadow-xl dark:border-slate-800 dark:bg-slate-900/70">
<h1 className="text-lg font-semibold mb-1"></h1>
<p className="text-xs text-slate-500 mb-4 dark:text-slate-400"> .</p>
<h1 className="text-lg font-semibold mb-1">{t.title}</h1>
<p className="text-xs text-slate-500 mb-4 dark:text-slate-400">{t.description}</p>
{error && <p className="mb-3 text-xs text-red-300">{error}</p>}
<form onSubmit={handleSubmit} className="space-y-3 text-xs">
<div className="space-y-1">
<label htmlFor="email" className="block text-slate-800 dark:text-slate-200">
{t.emailLabel}
</label>
<input
id="email"
@@ -123,7 +137,7 @@ export default function SignupPage() {
<div className="space-y-1">
<label htmlFor="password" className="block text-slate-800 dark:text-slate-200">
{t.passwordLabel}
</label>
<input
id="password"
@@ -143,9 +157,9 @@ export default function SignupPage() {
data-testid="password-strength-bar"
className={
"h-full transition-all " +
(strengthLabel === "약함"
(strengthLabel === "weak"
? "w-1/3 bg-red-500"
: strengthLabel === "보통"
: strengthLabel === "medium"
? "w-2/3 bg-amber-500"
: "w-full bg-emerald-600")
}
@@ -154,21 +168,21 @@ export default function SignupPage() {
<p
className={
"text-[11px] " +
(strengthLabel === "약함"
(strengthLabel === "weak"
? "text-red-500 dark:text-red-300"
: strengthLabel === "보통"
: strengthLabel === "medium"
? "text-amber-500 dark:text-amber-300"
: "text-emerald-600 dark:text-emerald-300")
}
>
: {strengthLabel}
{t.passwordStrengthPrefix} {strengthText}
</p>
</div>
)}
<div className="space-y-1">
<label htmlFor="passwordConfirm" className="block text-slate-800 dark:text-slate-200">
{t.passwordConfirmLabel}
</label>
<input
id="passwordConfirm"
@@ -186,17 +200,18 @@ export default function SignupPage() {
disabled={loading}
className="mt-2 w-full inline-flex items-center justify-center gap-1 rounded bg-sky-600 hover:bg-sky-500 disabled:opacity-40 disabled:cursor-not-allowed px-3 py-1 text-xs font-medium"
>
{loading ? "회원가입 중..." : "회원가입"}
{loading ? t.submitLoading : t.submitIdle}
</button>
</form>
<p className="mt-4 text-[11px] text-slate-500 dark:text-slate-400">
{t.loginPromptPrefix}
{" "}
<Link href="/login" className="text-sky-300 hover:text-sky-200 underline-offset-2 hover:underline">
{t.loginLinkText}
</Link>
.
{t.loginPromptSuffix && " "}
{t.loginPromptSuffix}
</p>
</div>
</main>
@@ -1,5 +1,7 @@
import type { ChangeEvent } from "react";
import { useState } from "react";
import { useAppLocale } from "@/features/i18n/LocaleProvider";
import { getEditorColorPickerFieldMessages } from "@/features/i18n/messages/editorColorPickerField";
export type ColorPaletteItem = {
// 팔레트 식별자
@@ -75,6 +77,9 @@ export function ColorPickerField({
}: ColorPickerFieldProps) {
const [open, setOpen] = useState(false);
const locale = useAppLocale();
const m = getEditorColorPickerFieldMessages(locale);
const colorInputValue = value && value.startsWith("#") ? value : "#ffffff";
// 컬러 인풋 변경 시 HEX 문자열을 onChange로 전달한다.
const handleColorInputChange = (event: ChangeEvent<HTMLInputElement>) => {
@@ -122,7 +127,7 @@ export function ColorPickerField({
<input
className="w-28 flex-none 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"
aria-label={ariaLabelHexInput}
placeholder="예: #ff0000"
placeholder={m.hexPlaceholder}
value={value}
onChange={handleHexInputChange}
/>
@@ -139,7 +144,9 @@ export function ColorPickerField({
style={{ backgroundColor: selectedPalette?.color ?? value ?? "#ffffff" }}
/>
<span className="truncate text-slate-700 dark:text-slate-100">
{selectedPalette?.label ?? "색상 팔레트"}
{selectedPalette
? m.paletteLabels[selectedPalette.id] ?? selectedPalette.label
: m.dropdownLabelDefault}
</span>
</span>
<span className="text-slate-400 dark:text-slate-500 text-[10px]"></span>
@@ -166,7 +173,7 @@ export function ColorPickerField({
className="inline-block h-3 w-3 rounded-full border border-slate-300 dark:border-slate-700"
style={{ backgroundColor: item.color }}
/>
<span>{item.label}</span>
<span>{m.paletteLabels[item.id] ?? item.label}</span>
</span>
</button>
))}
@@ -39,6 +39,8 @@ import {
computeFormRadioPublicTokens,
computeFormControllerPublicTokens,
} from "@/features/editor/utils/formHelpers";
import { useAppLocale } from "@/features/i18n/LocaleProvider";
import { getPublicPageRendererMessages } from "@/features/i18n/messages/publicPageRenderer";
// 섹션 레이아웃/스타일 토큰을 실제 Tailwind 클래스 조합으로 변환하는 유틸
// (에디터/퍼블릭 렌더러에서 동일한 규칙을 재사용하기 위해 export 한다.)
@@ -101,6 +103,8 @@ const convertPxStringToEm = (value?: string | null) => {
};
export function PublicPageRenderer({ blocks, projectSlug }: PublicPageRendererProps) {
const locale = useAppLocale();
const m = getPublicPageRendererMessages(locale);
const [formStatus, setFormStatus] = useState<"idle" | "submitting" | "success" | "error">("idle");
const [formMessage, setFormMessage] = useState<string>("");
@@ -186,7 +190,7 @@ export function PublicPageRenderer({ blocks, projectSlug }: PublicPageRendererPr
setFormStatus("error");
const bulletLines = missingRequiredLabels.map((label) => `- ${label}`).join("\n");
const message = `다음 필수 항목을 입력해 주세요:\n${bulletLines}`;
const message = `${m.requiredFieldsPrefix}\n${bulletLines}`;
setFormMessage(message);
return;
@@ -257,15 +261,15 @@ export function PublicPageRenderer({ blocks, projectSlug }: PublicPageRendererPr
if (res.ok) {
setFormStatus("success");
setFormMessage(props.successMessage ?? "성공적으로 전송되었습니다.");
setFormMessage(props.successMessage ?? m.submitSuccessDefault);
} else {
setFormStatus("error");
setFormMessage(props.errorMessage ?? "전송 중 오류가 발생했습니다.");
setFormMessage(props.errorMessage ?? m.submitErrorDefault);
}
} catch (error) {
console.error("[PublicPageRenderer] form submit error", error);
setFormStatus("error");
setFormMessage(props.errorMessage ?? "전송 중 오류가 발생했습니다.");
setFormMessage(props.errorMessage ?? m.submitErrorDefault);
}
};
@@ -541,7 +545,7 @@ export function PublicPageRenderer({ blocks, projectSlug }: PublicPageRendererPr
// eslint-disable-next-line @next/next/no-img-element
<img
src={props.groupLabelImageUrl}
alt={props.groupLabel || "폼 그룹 타이틀"}
alt={props.groupLabel || m.checkboxGroupLabelImageAltFallback}
className="inline-block max-w-full h-auto"
style={tokens.groupTextStyle}
/>
@@ -571,7 +575,7 @@ export function PublicPageRenderer({ blocks, projectSlug }: PublicPageRendererPr
// eslint-disable-next-line @next/next/no-img-element
<img
src={opt.labelImageUrl}
alt={opt.label || props.groupLabel || "체크박스 옵션"}
alt={opt.label || props.groupLabel || m.checkboxOptionImageAltFallback}
className="inline-block max-w-full h-auto"
style={tokens.optionTextStyle}
/>
@@ -634,7 +638,7 @@ export function PublicPageRenderer({ blocks, projectSlug }: PublicPageRendererPr
// eslint-disable-next-line @next/next/no-img-element
<img
src={props.groupLabelImageUrl}
alt={props.groupLabel || "폼 그룹 타이틀"}
alt={props.groupLabel || m.checkboxGroupLabelImageAltFallback}
className="inline-block max-w-full h-auto"
style={tokens.groupTextStyle}
/>
@@ -663,7 +667,7 @@ export function PublicPageRenderer({ blocks, projectSlug }: PublicPageRendererPr
// eslint-disable-next-line @next/next/no-img-element
<img
src={opt.labelImageUrl}
alt={opt.label || props.groupLabel || "라디오 옵션"}
alt={opt.label || props.groupLabel || m.radioOptionImageAltFallback}
className="inline-block max-w-full h-auto"
style={tokens.optionTextStyle}
/>
+21 -5
View File
@@ -9,6 +9,8 @@ import { createCtaTemplateBlocks } from "@/app/editor/templates/ctaTemplate";
import { createFaqTemplateBlocks } from "@/app/editor/templates/faqTemplate";
import { createPricingTemplateBlocks } from "@/app/editor/templates/pricingTemplate";
import { createTestimonialsTemplateBlocks } from "@/app/editor/templates/testimonialsTemplate";
import { getEditorTemplatesMessages } from "@/features/i18n/messages/editorTemplates";
import { DEFAULT_LOCALE } from "@/features/i18n/locale";
// 블록 타입 정의: 텍스트/버튼/이미지/비디오/섹션/구분선/리스트/폼 블록/폼 요소 블록
export type BlockType =
@@ -914,9 +916,11 @@ const createEditorState = (set: any, get: any): EditorState => {
}
}
const tpl = getEditorTemplatesMessages(DEFAULT_LOCALE);
const { blocks: templateBlocks, lastSelectedId } = createHeroTemplateBlocks({
sectionId,
createId,
messages: tpl.hero,
});
pushHistoryImpl(set, get);
@@ -937,11 +941,6 @@ const createEditorState = (set: any, get: any): EditorState => {
});
},
// 리스트 아이템 선택 상태를 업데이트한다.
selectListItem: (itemId) => {
set({ selectedListItemId: itemId ?? null });
},
// Features 템플릿 섹션 추가: 3컬럼(4/4/4) 섹션과 각 컬럼의 제목/설명 텍스트를 생성한다
addFeaturesTemplateSection: () => {
const { selectedBlockId, blocks } = get();
@@ -956,9 +955,11 @@ const createEditorState = (set: any, get: any): EditorState => {
}
}
const tpl = getEditorTemplatesMessages(DEFAULT_LOCALE);
const { blocks: templateBlocks, lastSelectedId } = createFeaturesTemplateBlocks({
sectionId,
createId,
messages: tpl.features,
});
pushHistoryImpl(set, get);
@@ -993,9 +994,11 @@ const createEditorState = (set: any, get: any): EditorState => {
}
}
const tpl = getEditorTemplatesMessages(DEFAULT_LOCALE);
const { blocks: templateBlocks, lastSelectedId } = createBlogTemplateBlocks({
sectionId,
createId,
messages: tpl.blog,
});
pushHistoryImpl(set, get);
@@ -1030,9 +1033,11 @@ const createEditorState = (set: any, get: any): EditorState => {
}
}
const tpl = getEditorTemplatesMessages(DEFAULT_LOCALE);
const { blocks: templateBlocks, lastSelectedId } = createTeamTemplateBlocks({
sectionId,
createId,
messages: tpl.team,
});
pushHistoryImpl(set, get);
@@ -1067,9 +1072,11 @@ const createEditorState = (set: any, get: any): EditorState => {
}
}
const tpl = getEditorTemplatesMessages(DEFAULT_LOCALE);
const { blocks: templateBlocks, lastSelectedId } = createFooterTemplateBlocks({
sectionId,
createId,
messages: tpl.footer,
});
pushHistoryImpl(set, get);
@@ -1104,9 +1111,11 @@ const createEditorState = (set: any, get: any): EditorState => {
}
}
const tpl = getEditorTemplatesMessages(DEFAULT_LOCALE);
const { blocks: templateBlocks, lastSelectedId } = createCtaTemplateBlocks({
sectionId,
createId,
messages: tpl.cta,
});
pushHistoryImpl(set, get);
@@ -1141,9 +1150,11 @@ const createEditorState = (set: any, get: any): EditorState => {
}
}
const tpl = getEditorTemplatesMessages(DEFAULT_LOCALE);
const { blocks: templateBlocks, lastSelectedId } = createFaqTemplateBlocks({
sectionId,
createId,
messages: tpl.faq,
});
pushHistoryImpl(set, get);
@@ -1733,6 +1744,11 @@ const createEditorState = (set: any, get: any): EditorState => {
}));
},
// 현재 선택된 리스트 아이템 ID 를 상태에 저장한다.
selectListItem: (itemId) => {
set({ selectedListItemId: itemId });
},
// 선택된 리스트 아이템을 들여쓰기한다.
indentSelectedListItem: (blockId) => {
const { blocks, selectedListItemId } = get();
+39
View File
@@ -0,0 +1,39 @@
"use client";
import type { ReactNode } from "react";
import { createContext, useContext, useState } from "react";
import type { AppLocale } from "./locale";
import { DEFAULT_LOCALE } from "./locale";
type LocaleContextValue = {
locale: AppLocale;
setLocale: (locale: AppLocale) => void;
};
const LocaleContext = createContext<LocaleContextValue>({
locale: DEFAULT_LOCALE,
setLocale: () => {
// no-op default
},
});
export function LocaleProvider({
initialLocale,
children,
}: {
initialLocale: AppLocale;
children: ReactNode;
}) {
const [locale, setLocale] = useState<AppLocale>(initialLocale);
return <LocaleContext.Provider value={{ locale, setLocale }}>{children}</LocaleContext.Provider>;
}
export function useAppLocale(): AppLocale {
return useContext(LocaleContext).locale;
}
export function useLocaleActions() {
const { setLocale } = useContext(LocaleContext);
return { setLocale };
}
+30
View File
@@ -0,0 +1,30 @@
"use client";
import { useAppLocale, useLocaleActions } from "./LocaleProvider";
import type { AppLocale } from "./locale";
export function LocaleSwitcher() {
const locale = useAppLocale();
const { setLocale } = useLocaleActions();
const handleToggle = () => {
const nextLocale: AppLocale = locale === "en" ? "ko" : "en";
setLocale(nextLocale);
};
const labelText = locale === "en" ? "EN" : "KO";
return (
<div className="fixed bottom-4 right-4 z-50 text-xs">
<button
type="button"
aria-label="Toggle locale"
className="inline-flex items-center justify-center rounded-full border border-slate-300 bg-white/80 px-2 py-1 text-slate-700 shadow-sm backdrop-blur dark:border-slate-700 dark:bg-slate-900/80 dark:text-slate-100"
onClick={handleToggle}
>
{labelText}
</button>
</div>
);
}
+22
View File
@@ -0,0 +1,22 @@
export const SUPPORTED_LOCALES = ["en", "ko"] as const;
export type AppLocale = (typeof SUPPORTED_LOCALES)[number];
// 기본 로케일은 항상 en
export const DEFAULT_LOCALE: AppLocale = "en";
// Accept-Language 헤더 문자열에서 앱 로케일(en/ko)을 결정한다.
// - ko 가 포함되어 있으면 ko
// - 그렇지 않으면 기본(en)
export function resolveLocaleFromAcceptLanguage(header: string | null | undefined): AppLocale {
if (!header) {
return DEFAULT_LOCALE;
}
const value = header.toLowerCase();
if (value.includes("ko")) {
return "ko";
}
return DEFAULT_LOCALE;
}
+120
View File
@@ -0,0 +1,120 @@
import type { AppLocale } from "../locale";
import { DEFAULT_LOCALE } from "../locale";
export type LoginMessages = {
title: string;
description: string;
emailLabel: string;
passwordLabel: string;
submitIdle: string;
submitLoading: string;
themeToggleLabel: string;
errorLoginFailed: string;
errorNetwork: string;
signupPromptPrefix: string;
signupLinkText: string;
signupPromptSuffix: string;
};
export type SignupMessages = {
title: string;
description: string;
emailLabel: string;
passwordLabel: string;
passwordConfirmLabel: string;
submitIdle: string;
submitLoading: string;
mismatchError: string;
signupFailedFallbackError: string;
errorNetwork: string;
passwordStrengthPrefix: string;
passwordStrengthWeak: string;
passwordStrengthMedium: string;
passwordStrengthStrong: string;
loginPromptPrefix: string;
loginLinkText: string;
loginPromptSuffix: string;
};
export type AuthMessages = {
login: LoginMessages;
signup: SignupMessages;
};
const AUTH_MESSAGES: Record<AppLocale, AuthMessages> = {
en: {
login: {
title: "Log in",
description: "Sign in to manage and save your projects.",
emailLabel: "Email",
passwordLabel: "Password",
submitIdle: "Log in",
submitLoading: "Logging in...",
themeToggleLabel: "Toggle theme",
errorLoginFailed: "Failed to log in. Please try again.",
errorNetwork: "A network error occurred. Please try again later.",
signupPromptPrefix: "Don't have an account yet?",
signupLinkText: "Sign up",
signupPromptSuffix: "",
},
signup: {
title: "Sign up",
description: "Create a new account to save and manage your projects.",
emailLabel: "Email",
passwordLabel: "Password",
passwordConfirmLabel: "Confirm password",
submitIdle: "Sign up",
submitLoading: "Signing up...",
mismatchError: "Password and confirmation do not match.",
signupFailedFallbackError: "Failed to sign up. Please try again.",
errorNetwork: "A network error occurred. Please try again later.",
passwordStrengthPrefix: "Password strength:",
passwordStrengthWeak: "Weak",
passwordStrengthMedium: "Medium",
passwordStrengthStrong: "Strong",
loginPromptPrefix: "Already have an account?",
loginLinkText: "Log in",
loginPromptSuffix: "",
},
},
ko: {
login: {
title: "로그인",
description: "프로젝트를 관리하려면 먼저 계정으로 로그인하세요.",
emailLabel: "이메일",
passwordLabel: "비밀번호",
submitIdle: "로그인",
submitLoading: "로그인 중...",
themeToggleLabel: "테마 전환",
errorLoginFailed: "로그인에 실패했습니다. 다시 시도해 주세요.",
errorNetwork: "네트워크 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.",
signupPromptPrefix: "아직 계정이 없다면",
signupLinkText: "회원가입",
signupPromptSuffix: "을 진행해 주세요.",
},
signup: {
title: "회원가입",
description: "새 계정을 생성한 뒤 프로젝트를 저장하고 관리할 수 있습니다.",
emailLabel: "이메일",
passwordLabel: "비밀번호",
passwordConfirmLabel: "비밀번호 확인",
submitIdle: "회원가입",
submitLoading: "회원가입 중...",
mismatchError: "비밀번호와 비밀번호 확인이 일치하지 않습니다.",
signupFailedFallbackError: "회원가입에 실패했습니다. 다시 시도해 주세요.",
errorNetwork: "네트워크 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.",
passwordStrengthPrefix: "비밀번호 난이도:",
passwordStrengthWeak: "약함",
passwordStrengthMedium: "보통",
passwordStrengthStrong: "강함",
loginPromptPrefix: "이미 계정이 있다면",
loginLinkText: "로그인",
loginPromptSuffix: "으로 이동해 주세요.",
},
},
};
export function getAuthMessages(locale: AppLocale | null | undefined): AuthMessages {
const key = (locale ?? DEFAULT_LOCALE) as AppLocale;
return AUTH_MESSAGES[key] ?? AUTH_MESSAGES[DEFAULT_LOCALE];
}
+103
View File
@@ -0,0 +1,103 @@
import type { AppLocale } from "../locale";
import { DEFAULT_LOCALE } from "../locale";
// 대시보드/프로젝트 목록/전체 제출 내역 상단 GNB 및 헤더용 i18n 메시지
// - 로그인/회원가입과 동일하게 en(기본), ko 를 지원한다.
export type DashboardMessages = {
// 메인 대시보드 헤더 제목/설명
title: string;
description: string;
// 상단 GNB 탭 레이블
navDashboard: string;
navProjects: string;
navSubmissions: string;
// 헤더 오른쪽 액션 버튼/메뉴
themeToggleLabel: string;
menuLabel: string;
logoutLabel: string;
summaryTotalProjectsLabel: string;
summaryTotalSubmissionsLabel: string;
summaryTodaySubmissionsLabel: string;
summaryLast7DaysSubmissionsLabel: string;
chartTitle: string;
chartSubtitle: string;
chartEmptyLabel: string;
sectionProjectsTitle: string;
sectionProjectsEmpty: string;
projectTotalSubmissionsPrefix: string;
projectTotalSubmissionsSuffix: string;
projectLatestSubmissionPrefix: string;
projectLatestSubmissionEmpty: string;
projectViewSubmissionsLink: string;
projectOpenPublicPageLink: string;
errorUnauthorized: string;
errorGeneric: string;
loadingText: string;
};
const DASHBOARD_MESSAGES: Record<AppLocale, DashboardMessages> = {
en: {
title: "Dashboard",
description: "A quick overview of your projects and form submissions.",
navDashboard: "Dashboard",
navProjects: "Projects",
navSubmissions: "All submissions",
themeToggleLabel: "Toggle theme",
menuLabel: "Menu",
logoutLabel: "Log out",
summaryTotalProjectsLabel: "Projects",
summaryTotalSubmissionsLabel: "Total form submissions",
summaryTodaySubmissionsLabel: "Submissions today",
summaryLast7DaysSubmissionsLabel: "Submissions in last 7 days",
chartTitle: "Recent submissions (daily)",
chartSubtitle: "Only dates with recent activity are shown.",
chartEmptyLabel: "No daily submission data yet.",
sectionProjectsTitle: "Submissions by project",
sectionProjectsEmpty: "No projects created yet or no submissions have been received.",
projectTotalSubmissionsPrefix: "Total ",
projectTotalSubmissionsSuffix: " submissions",
projectLatestSubmissionPrefix: "Last submission:",
projectLatestSubmissionEmpty: "No submissions yet",
projectViewSubmissionsLink: "View form submissions",
projectOpenPublicPageLink: "Open public page",
errorUnauthorized: "You need to be signed in to view the dashboard. Please log in again.",
errorGeneric: "An error occurred while loading the dashboard. Please try again later.",
loadingText: "Loading dashboard data...",
},
ko: {
title: "대시보드",
description: "프로젝트와 폼 제출 현황을 한눈에 확인할 수 있는 요약 화면입니다.",
navDashboard: "대시보드",
navProjects: "프로젝트 목록",
navSubmissions: "전체 제출 내역",
themeToggleLabel: "테마 전환",
menuLabel: "메뉴",
logoutLabel: "로그아웃",
summaryTotalProjectsLabel: "프로젝트 수",
summaryTotalSubmissionsLabel: "전체 폼 제출 수",
summaryTodaySubmissionsLabel: "오늘 제출 수",
summaryLast7DaysSubmissionsLabel: "최근 7일 제출 수",
chartTitle: "최근 제출 추이 (일별)",
chartSubtitle: "최근 활동이 있는 날짜만 표시됩니다.",
chartEmptyLabel: "아직 일별 제출 내역이 없습니다.",
sectionProjectsTitle: "프로젝트별 제출 현황",
sectionProjectsEmpty: "아직 생성된 프로젝트가 없거나 제출 내역이 없습니다.",
projectTotalSubmissionsPrefix: "총 제출 ",
projectTotalSubmissionsSuffix: "건",
projectLatestSubmissionPrefix: "최근 제출:",
projectLatestSubmissionEmpty: "제출 내역 없음",
projectViewSubmissionsLink: "폼 제출 내역 보기",
projectOpenPublicPageLink: "퍼블릭 페이지 열기",
errorUnauthorized:
"대시보드를 보려면 로그인이 필요합니다. 다시 로그인해 주세요.",
errorGeneric:
"대시보드 데이터를 불러오는 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.",
loadingText: "대시보드 데이터를 불러오는 중입니다...",
},
};
export function getDashboardMessages(locale: AppLocale | null | undefined): DashboardMessages {
const key = (locale ?? DEFAULT_LOCALE) as AppLocale;
return DASHBOARD_MESSAGES[key] ?? DASHBOARD_MESSAGES[DEFAULT_LOCALE];
}
+153
View File
@@ -0,0 +1,153 @@
import type { AppLocale } from "../locale";
import { DEFAULT_LOCALE } from "../locale";
export type EditorMessages = {
headerTitle: string;
headerDescription: string;
navProjects: string;
navPreview: string;
undoLabel: string;
redoLabel: string;
menuLabel: string;
menuProjectSaveLoad: string;
menuJsonImportExport: string;
menuExportZip: string;
menuClearCanvas: string;
menuDeleteProject: string;
confirmClearCanvas: string;
confirmDeleteProject: string;
modalProjectTitle: string;
modalProjectTitleLabel: string;
modalProjectSlugLabel: string;
modalSaveButton: string;
modalLoadButton: string;
projectMessageSlugRequired: string;
projectMessageSaveFailed: string;
projectMessageSaveError: string;
projectMessageSaveSuccessPrefix: string;
projectMessageLoadSlugRequired: string;
projectMessageLoadFailed: string;
projectMessageLoadLocalSuccessPrefix: string;
projectMessageLoadServerSuccessPrefix: string;
projectMessageInvalidFormat: string;
projectMessageLoadError: string;
projectMessageDeleteSlugMissing: string;
projectMessageDeleteFailed: string;
projectMessageDeleteSuccess: string;
projectMessageDeleteError: string;
jsonModalTitle: string;
jsonModalSubtitle: string;
jsonExportButton: string;
jsonClearCanvasButton: string;
jsonEditorStateLabel: string;
jsonImportLabel: string;
jsonApplyButton: string;
listItemToolbarMoveUpLabel: string;
listItemToolbarMoveDownLabel: string;
listItemToolbarIndentLabel: string;
listItemToolbarOutdentLabel: string;
};
const EDITOR_MESSAGES: Record<AppLocale, EditorMessages> = {
en: {
headerTitle: "Page Editor",
headerDescription: "Build and edit your landing pages with a visual editor.",
navProjects: "Projects",
navPreview: "Open preview",
undoLabel: "Undo",
redoLabel: "Redo",
menuLabel: "Menu",
menuProjectSaveLoad: "Save / load project",
menuJsonImportExport: "JSON export / import",
menuExportZip: "Export as page files (ZIP)",
menuClearCanvas: "Clear canvas",
menuDeleteProject: "Delete project",
confirmClearCanvas:
"Clear all blocks on the canvas? This can only be undone with the undo history.",
confirmDeleteProject:
"Delete the current project? This cannot be undone. Local and autosave data will also be removed.",
modalProjectTitle: "Save / load project",
modalProjectTitleLabel: "Project title",
modalProjectSlugLabel: "Project address (e.g. my-landing)",
modalSaveButton: "Save (local + server)",
modalLoadButton: "Load",
projectMessageSlugRequired: "Please enter a project address.",
projectMessageSaveFailed: "Failed to save the project.",
projectMessageSaveError: "An error occurred while saving the project.",
projectMessageSaveSuccessPrefix: "Project saved: ",
projectMessageLoadSlugRequired: "Please enter an address to load.",
projectMessageLoadFailed: "Failed to load the project.",
projectMessageLoadLocalSuccessPrefix: "Loaded project from local: ",
projectMessageLoadServerSuccessPrefix: "Loaded project from server: ",
projectMessageInvalidFormat: "Project data format is invalid.",
projectMessageLoadError: "An error occurred while loading the project.",
projectMessageDeleteSlugMissing: "There is no project address to delete.",
projectMessageDeleteFailed: "Failed to delete the project.",
projectMessageDeleteSuccess: "Project deleted.",
projectMessageDeleteError: "An error occurred while deleting the project.",
jsonModalTitle: "JSON Export / Import",
jsonModalSubtitle: "* Import or export the current editor state as JSON.",
jsonExportButton: "Export JSON",
jsonClearCanvasButton: "Clear canvas",
jsonEditorStateLabel: "Editor state JSON",
jsonImportLabel: "Import from JSON",
jsonApplyButton: "Apply JSON",
listItemToolbarMoveUpLabel: "Move item up",
listItemToolbarMoveDownLabel: "Move item down",
listItemToolbarIndentLabel: "Indent item",
listItemToolbarOutdentLabel: "Outdent item",
},
ko: {
headerTitle: "페이지 에디터",
headerDescription: "랜딩 페이지를 시각적으로 구성할 수 있는 에디터입니다.",
navProjects: "프로젝트 목록",
navPreview: "프리뷰 열기",
undoLabel: "실행 취소",
redoLabel: "다시 실행",
menuLabel: "메뉴",
menuProjectSaveLoad: "프로젝트 저장/불러오기",
menuJsonImportExport: "JSON 내보내기/불러오기",
menuExportZip: "페이지 파일로 내보내기 (ZIP)",
menuClearCanvas: "캔버스 초기화",
menuDeleteProject: "프로젝트 삭제",
confirmClearCanvas:
"캔버스를 모두 초기화할까요? 이 작업은 실행 취소 히스토리로만 되돌릴 수 있습니다.",
confirmDeleteProject:
"현재 프로젝트를 삭제할까요? 삭제 후에는 되돌릴 수 없습니다. (로컬 저장 및 자동저장 데이터도 함께 삭제됩니다.)",
modalProjectTitle: "프로젝트 저장 / 불러오기",
modalProjectTitleLabel: "프로젝트 제목",
modalProjectSlugLabel: "프로젝트 주소 (예: my-landing)",
modalSaveButton: "저장 (로컬 + 서버)",
modalLoadButton: "불러오기",
projectMessageSlugRequired: "프로젝트 주소를 입력해 주세요.",
projectMessageSaveFailed: "프로젝트 저장에 실패했습니다.",
projectMessageSaveError: "프로젝트 저장 중 오류가 발생했습니다.",
projectMessageSaveSuccessPrefix: "프로젝트가 저장되었습니다: ",
projectMessageLoadSlugRequired: "불러올 주소를 입력해 주세요.",
projectMessageLoadFailed: "프로젝트를 불러오지 못했습니다.",
projectMessageLoadLocalSuccessPrefix: "로컬에서 프로젝트를 불러왔습니다: ",
projectMessageLoadServerSuccessPrefix: "프로젝트를 불러왔습니다: ",
projectMessageInvalidFormat: "프로젝트 데이터 형식이 올바르지 않습니다.",
projectMessageLoadError: "프로젝트 불러오기 중 오류가 발생했습니다.",
projectMessageDeleteSlugMissing: "삭제할 프로젝트 주소가 없습니다.",
projectMessageDeleteFailed: "프로젝트 삭제에 실패했습니다.",
projectMessageDeleteSuccess: "프로젝트가 삭제되었습니다.",
projectMessageDeleteError: "프로젝트 삭제 중 오류가 발생했습니다.",
jsonModalTitle: "JSON Export / Import",
jsonModalSubtitle: "* 에디터 내용을 가져오거나 내보낼 수 있습니다.",
jsonExportButton: "JSON 내보내기",
jsonClearCanvasButton: "캔버스 초기화",
jsonEditorStateLabel: "에디터 상태 JSON",
jsonImportLabel: "JSON에서 불러오기",
jsonApplyButton: "JSON 적용하기",
listItemToolbarMoveUpLabel: "아이템 위로 이동",
listItemToolbarMoveDownLabel: "아이템 아래로 이동",
listItemToolbarIndentLabel: "아이템 들여쓰기",
listItemToolbarOutdentLabel: "아이템 내어쓰기",
},
};
export function getEditorMessages(locale: AppLocale | null | undefined): EditorMessages {
const key = (locale ?? DEFAULT_LOCALE) as AppLocale;
return EDITOR_MESSAGES[key] ?? EDITOR_MESSAGES[DEFAULT_LOCALE];
}
@@ -0,0 +1,286 @@
import type { AppLocale } from "../locale";
import { DEFAULT_LOCALE } from "../locale";
export type EditorButtonPanelMessages = {
buttonTextLabel: string;
buttonTextAria: string;
imageSectionTitle: string;
imageSourceLabel: string;
imageSourceAria: string;
imageSourceOptionNone: string;
imageSourceOptionUrl: string;
imageSourceOptionUpload: string;
imageUrlLabel: string;
imageUrlAria: string;
imageUploadLabel: string;
imageUploadAria: string;
imageAltLabel: string;
imageAltAria: string;
imagePlacementLabel: string;
imagePlacementAria: string;
imagePlacementOptionLeft: string;
imagePlacementOptionRight: string;
imagePlacementOptionTop: string;
imagePlacementOptionBottom: string;
paddingXLabel: string;
paddingYLabel: string;
linkLabel: string;
linkAria: string;
alignLabel: string;
alignAria: string;
alignOptionLeft: string;
alignOptionCenter: string;
alignOptionRight: string;
textColorLabel: string;
textColorPickerAria: string;
textColorHexAria: string;
styleLabel: string;
styleAria: string;
styleOptionSolid: string;
styleOptionOutline: string;
styleOptionGhost: string;
fillColorLabel: string;
fillColorPickerAria: string;
fillColorHexAria: string;
strokeColorLabel: string;
strokeColorPickerAria: string;
strokeColorHexAria: string;
borderRadiusLabel: string;
borderRadiusPresetNone: string;
borderRadiusPresetSmall: string;
borderRadiusPresetMedium: string;
borderRadiusPresetLarge: string;
borderRadiusPresetFull: string;
widthModeLabel: string;
widthModeAria: string;
widthModeOptionAuto: string;
widthModeOptionFull: string;
widthModeOptionFixed: string;
fixedWidthLabel: string;
fixedWidthUnitLabel: string;
fixedWidthPresetSmall: string;
fixedWidthPresetMedium: string;
fixedWidthPresetLarge: string;
fontSizeLabel: string;
fontSizeUnitLabel: string;
lineHeightLabel: string;
lineHeightPresetTight: string;
lineHeightPresetNormal: string;
lineHeightPresetRelaxed: string;
letterSpacingLabel: string;
letterSpacingUnitLabel: string;
letterSpacingPresetTighter: string;
letterSpacingPresetTight: string;
letterSpacingPresetNormal: string;
letterSpacingPresetWide: string;
letterSpacingPresetWider: string;
};
const EDITOR_BUTTON_PANEL_MESSAGES: Record<AppLocale, EditorButtonPanelMessages> = {
en: {
buttonTextLabel: "Button text",
buttonTextAria: "Button text",
imageSectionTitle: "Button image",
imageSourceLabel: "Button image source",
imageSourceAria: "Button image source",
imageSourceOptionNone: "None",
imageSourceOptionUrl: "URL",
imageSourceOptionUpload: "File upload",
imageUrlLabel: "Button image URL",
imageUrlAria: "Button image URL",
imageUploadLabel: "Button image file upload",
imageUploadAria: "Button image file upload",
imageAltLabel: "Button image alt text",
imageAltAria: "Button image alt text",
imagePlacementLabel: "Button image placement",
imagePlacementAria: "Button image placement",
imagePlacementOptionLeft: "Text left",
imagePlacementOptionRight: "Text right",
imagePlacementOptionTop: "Text top",
imagePlacementOptionBottom: "Text bottom",
paddingXLabel: "Horizontal padding (px)",
paddingYLabel: "Vertical padding (px)",
linkLabel: "Button link",
linkAria: "Button link",
alignLabel: "Alignment",
alignAria: "Button alignment",
alignOptionLeft: "Left",
alignOptionCenter: "Center",
alignOptionRight: "Right",
textColorLabel: "Text color",
textColorPickerAria: "Button text color picker",
textColorHexAria: "Button text color HEX",
styleLabel: "Style",
styleAria: "Button style",
styleOptionSolid: "Fill",
styleOptionOutline: "Outline",
styleOptionGhost: "Ghost",
fillColorLabel: "Fill color",
fillColorPickerAria: "Button fill color picker",
fillColorHexAria: "Button fill color HEX",
strokeColorLabel: "Border color",
strokeColorPickerAria: "Button border color picker",
strokeColorHexAria: "Button border color HEX",
borderRadiusLabel: "Border radius",
borderRadiusPresetNone: "None",
borderRadiusPresetSmall: "Small",
borderRadiusPresetMedium: "Medium",
borderRadiusPresetLarge: "Large",
borderRadiusPresetFull: "Full",
widthModeLabel: "Button width mode",
widthModeAria: "Button width mode",
widthModeOptionAuto: "Auto",
widthModeOptionFull: "Full width",
widthModeOptionFixed: "Fixed",
fixedWidthLabel: "Button fixed width",
fixedWidthUnitLabel: "(px)",
fixedWidthPresetSmall: "Small",
fixedWidthPresetMedium: "Medium",
fixedWidthPresetLarge: "Wide",
fontSizeLabel: "Button font size",
fontSizeUnitLabel: "(px)",
lineHeightLabel: "Line height",
lineHeightPresetTight: "Tight",
lineHeightPresetNormal: "Normal",
lineHeightPresetRelaxed: "Relaxed",
letterSpacingLabel: "Letter spacing",
letterSpacingUnitLabel: "(px)",
letterSpacingPresetTighter: "Very tight",
letterSpacingPresetTight: "Tight",
letterSpacingPresetNormal: "Normal",
letterSpacingPresetWide: "Wide",
letterSpacingPresetWider: "Very wide",
},
ko: {
buttonTextLabel: "버튼 텍스트",
buttonTextAria: "버튼 텍스트",
imageSectionTitle: "버튼 이미지",
imageSourceLabel: "버튼 이미지 소스",
imageSourceAria: "버튼 이미지 소스",
imageSourceOptionNone: "사용 안 함",
imageSourceOptionUrl: "URL",
imageSourceOptionUpload: "파일 업로드",
imageUrlLabel: "버튼 이미지 URL",
imageUrlAria: "버튼 이미지 URL",
imageUploadLabel: "버튼 이미지 파일 업로드",
imageUploadAria: "버튼 이미지 파일 업로드",
imageAltLabel: "버튼 이미지 대체 텍스트",
imageAltAria: "버튼 이미지 대체 텍스트",
imagePlacementLabel: "버튼 이미지 위치",
imagePlacementAria: "버튼 이미지 위치",
imagePlacementOptionLeft: "텍스트 왼쪽",
imagePlacementOptionRight: "텍스트 오른쪽",
imagePlacementOptionTop: "텍스트 위쪽",
imagePlacementOptionBottom: "텍스트 아래쪽",
paddingXLabel: "가로 패딩 (px)",
paddingYLabel: "세로 패딩 (px)",
linkLabel: "버튼 링크",
linkAria: "버튼 링크",
alignLabel: "정렬",
alignAria: "버튼 정렬",
alignOptionLeft: "왼쪽",
alignOptionCenter: "가운데",
alignOptionRight: "오른쪽",
textColorLabel: "텍스트 색상",
textColorPickerAria: "버튼 텍스트 색상 피커",
textColorHexAria: "버튼 텍스트 색상 HEX",
styleLabel: "스타일",
styleAria: "버튼 스타일",
styleOptionSolid: "채움",
styleOptionOutline: "외곽선",
styleOptionGhost: "고스트",
fillColorLabel: "채움 색상",
fillColorPickerAria: "버튼 채움 색상 피커",
fillColorHexAria: "버튼 채움 색상 HEX",
strokeColorLabel: "외곽선 색상",
strokeColorPickerAria: "버튼 외곽선 색상 피커",
strokeColorHexAria: "버튼 외곽선 색상 HEX",
borderRadiusLabel: "모서리 둥글기",
borderRadiusPresetNone: "없음",
borderRadiusPresetSmall: "작게",
borderRadiusPresetMedium: "보통",
borderRadiusPresetLarge: "크게",
borderRadiusPresetFull: "완전 둥글게",
widthModeLabel: "버튼 너비 모드",
widthModeAria: "버튼 너비 모드",
widthModeOptionAuto: "자동",
widthModeOptionFull: "전체 폭",
widthModeOptionFixed: "고정 값",
fixedWidthLabel: "버튼 고정 너비",
fixedWidthUnitLabel: "(px)",
fixedWidthPresetSmall: "작게",
fixedWidthPresetMedium: "보통",
fixedWidthPresetLarge: "넓게",
fontSizeLabel: "버튼 크기",
fontSizeUnitLabel: "(px)",
lineHeightLabel: "줄 간격",
lineHeightPresetTight: "좁게",
lineHeightPresetNormal: "보통",
lineHeightPresetRelaxed: "넓게",
letterSpacingLabel: "글자 간격",
letterSpacingUnitLabel: "(px)",
letterSpacingPresetTighter: "아주 좁게",
letterSpacingPresetTight: "좁게",
letterSpacingPresetNormal: "보통",
letterSpacingPresetWide: "넓게",
letterSpacingPresetWider: "아주 넓게",
},
};
export function getEditorButtonPanelMessages(
locale: AppLocale | null | undefined,
): EditorButtonPanelMessages {
const key = (locale ?? DEFAULT_LOCALE) as AppLocale;
return EDITOR_BUTTON_PANEL_MESSAGES[key] ?? EDITOR_BUTTON_PANEL_MESSAGES[DEFAULT_LOCALE];
}
@@ -0,0 +1,40 @@
import type { AppLocale } from "../locale";
import { DEFAULT_LOCALE } from "../locale";
export type EditorCanvasMessages = {
emptyStateHint: string;
previewFallbackTextBlock: string;
previewFallbackButtonBlock: string;
previewFallbackImageBlock: string;
previewFallbackListBlock: string;
previewFallbackDividerBlock: string;
previewFallbackSectionBlock: string;
};
const EDITOR_CANVAS_MESSAGES: Record<AppLocale, EditorCanvasMessages> = {
en: {
emptyStateHint: 'Click the "Text" button on the left to add your first block.',
previewFallbackTextBlock: "Text block",
previewFallbackButtonBlock: "Button block",
previewFallbackImageBlock: "Image block",
previewFallbackListBlock: "List block",
previewFallbackDividerBlock: "Divider block",
previewFallbackSectionBlock: "Section block",
},
ko: {
emptyStateHint: '왼쪽에서 "텍스트" 버튼을 눌러 블록을 추가해 보세요.',
previewFallbackTextBlock: "텍스트 블록",
previewFallbackButtonBlock: "버튼 블록",
previewFallbackImageBlock: "이미지 블록",
previewFallbackListBlock: "리스트 블록",
previewFallbackDividerBlock: "구분선 블록",
previewFallbackSectionBlock: "섹션 블록",
},
};
export function getEditorCanvasMessages(
locale: AppLocale | null | undefined,
): EditorCanvasMessages {
const key = (locale ?? DEFAULT_LOCALE) as AppLocale;
return EDITOR_CANVAS_MESSAGES[key] ?? EDITOR_CANVAS_MESSAGES[DEFAULT_LOCALE];
}
@@ -0,0 +1,70 @@
import type { AppLocale } from "../locale";
import { DEFAULT_LOCALE } from "../locale";
export type EditorColorPickerFieldMessages = {
dropdownLabelDefault: string;
hexPlaceholder: string;
paletteLabels: Record<string, string>;
};
const EDITOR_COLOR_PICKER_FIELD_MESSAGES: Record<AppLocale, EditorColorPickerFieldMessages> = {
en: {
dropdownLabelDefault: "Color palette",
hexPlaceholder: "e.g. #ff0000",
paletteLabels: {
default: "None",
transparent: "Transparent",
muted: "Muted",
strong: "Strong highlight",
neutral: "Neutral",
accent: "Accent",
"accent-deep": "Accent (deep)",
"accent-soft": "Accent (soft)",
danger: "Danger",
"danger-deep": "Danger (strong)",
success: "Success",
"success-soft": "Success (soft)",
warning: "Warning",
info: "Info",
purple: "Purple",
"purple-soft": "Purple (soft)",
pink: "Pink",
"pink-soft": "Pink (soft)",
dark: "Dark text",
"dark-muted": "Dark muted",
},
},
ko: {
dropdownLabelDefault: "색상 팔레트",
hexPlaceholder: "예: #ff0000",
paletteLabels: {
default: "없음",
transparent: "투명",
muted: "연한",
strong: "강조",
neutral: "중립",
accent: "포인트",
"accent-deep": "포인트 진하게",
"accent-soft": "포인트 연하게",
danger: "위험",
"danger-deep": "위험 진하게",
success: "성공",
"success-soft": "성공 연하게",
warning: "경고",
info: "정보",
purple: "보라",
"purple-soft": "연한 보라",
pink: "핑크",
"pink-soft": "연한 핑크",
dark: "어두운 텍스트",
"dark-muted": "어두운 연한",
},
},
};
export function getEditorColorPickerFieldMessages(
locale: AppLocale | null | undefined,
): EditorColorPickerFieldMessages {
const key = (locale ?? DEFAULT_LOCALE) as AppLocale;
return EDITOR_COLOR_PICKER_FIELD_MESSAGES[key] ?? EDITOR_COLOR_PICKER_FIELD_MESSAGES[DEFAULT_LOCALE];
}
@@ -0,0 +1,122 @@
import type { AppLocale } from "../locale";
import { DEFAULT_LOCALE } from "../locale";
export type EditorDividerPropertiesPanelMessages = {
alignLabel: string;
alignAria: string;
alignOptionLeft: string;
alignOptionCenter: string;
alignOptionRight: string;
thicknessLabel: string;
thicknessAria: string;
thicknessOptionThin: string;
thicknessOptionMedium: string;
styleSectionTitle: string;
lengthModeLabel: string;
lengthModeAria: string;
lengthModeOptionAuto: string;
lengthModeOptionFull: string;
lengthModeOptionFixed: string;
fixedLengthLabel: string;
fixedLengthUnitLabel: string;
fixedLengthPresetShort: string;
fixedLengthPresetNormal: string;
fixedLengthPresetLong: string;
colorLabel: string;
colorPickerAria: string;
colorHexAria: string;
marginLabel: string;
marginUnitLabel: string;
marginPresetSmall: string;
marginPresetNormal: string;
marginPresetLarge: string;
};
const EDITOR_DIVIDER_PROPERTIES_PANEL_MESSAGES: Record<AppLocale, EditorDividerPropertiesPanelMessages> = {
en: {
alignLabel: "Alignment",
alignAria: "Divider alignment",
alignOptionLeft: "Left",
alignOptionCenter: "Center",
alignOptionRight: "Right",
thicknessLabel: "Thickness",
thicknessAria: "Divider thickness",
thicknessOptionThin: "Thin",
thicknessOptionMedium: "Normal",
styleSectionTitle: "Divider style",
lengthModeLabel: "Length mode",
lengthModeAria: "Divider length mode",
lengthModeOptionAuto: "Fit content",
lengthModeOptionFull: "Full width",
lengthModeOptionFixed: "Fixed length (px)",
fixedLengthLabel: "Fixed length (px)",
fixedLengthUnitLabel: "(px)",
fixedLengthPresetShort: "Short",
fixedLengthPresetNormal: "Normal",
fixedLengthPresetLong: "Long",
colorLabel: "Line color",
colorPickerAria: "Divider color picker",
colorHexAria: "Divider color HEX",
marginLabel: "Vertical margin",
marginUnitLabel: "(px)",
marginPresetSmall: "Small",
marginPresetNormal: "Normal",
marginPresetLarge: "Large",
},
ko: {
alignLabel: "정렬",
alignAria: "구분선 정렬",
alignOptionLeft: "왼쪽",
alignOptionCenter: "가운데",
alignOptionRight: "오른쪽",
thicknessLabel: "두께",
thicknessAria: "구분선 두께",
thicknessOptionThin: "얇게",
thicknessOptionMedium: "보통",
styleSectionTitle: "구분선 스타일",
lengthModeLabel: "길이 모드",
lengthModeAria: "구분선 길이 모드",
lengthModeOptionAuto: "내용에 맞춤",
lengthModeOptionFull: "전체 폭",
lengthModeOptionFixed: "고정 길이 (px)",
fixedLengthLabel: "고정 길이 (px)",
fixedLengthUnitLabel: "(px)",
fixedLengthPresetShort: "짧게",
fixedLengthPresetNormal: "보통",
fixedLengthPresetLong: "길게",
colorLabel: "선 색상",
colorPickerAria: "구분선 색상 피커",
colorHexAria: "구분선 색상 HEX",
marginLabel: "위/아래 여백",
marginUnitLabel: "(px)",
marginPresetSmall: "작게",
marginPresetNormal: "보통",
marginPresetLarge: "크게",
},
};
export function getEditorDividerPropertiesPanelMessages(
locale: AppLocale | null | undefined,
): EditorDividerPropertiesPanelMessages {
const key = (locale ?? DEFAULT_LOCALE) as AppLocale;
return EDITOR_DIVIDER_PROPERTIES_PANEL_MESSAGES[key] ?? EDITOR_DIVIDER_PROPERTIES_PANEL_MESSAGES[DEFAULT_LOCALE];
}
@@ -0,0 +1,298 @@
import type { AppLocale } from "../locale";
import { DEFAULT_LOCALE } from "../locale";
export type EditorFormCheckboxPanelMessages = {
sectionTitle: string;
groupTitleTypeLabel: string;
groupTitleTypeOptionText: string;
groupTitleTypeOptionImage: string;
groupTitleDisplayLabel: string;
groupTitleDisplayOptionVisible: string;
groupTitleDisplayOptionHidden: string;
layoutLabel: string;
layoutOptionStacked: string;
layoutOptionInline: string;
optionLayoutLabel: string;
optionLayoutOptionStacked: string;
optionLayoutOptionInline: string;
labelGapLabel: string;
groupTitleLabel: string;
submitKeyLabel: string;
optionsLabel: string;
addOptionButtonLabel: string;
optionLabelPlaceholder: string;
optionValuePlaceholder: string;
optionImageSourceLabel: string;
optionImageSourceOptionUrl: string;
optionImageSourceOptionUpload: string;
optionImageUrlPlaceholder: string;
optionImageUploadLabel: string;
optionImageUploadAria: string;
groupTitleImageSourceLabel: string;
groupTitleImageSourceOptionUrl: string;
groupTitleImageSourceOptionUpload: string;
groupTitleImageUrlLabel: string;
groupTitleImageUploadLabel: string;
groupTitleImageUploadAria: string;
requiredNoticeText: string;
styleSectionTitle: string;
textSizeLabel: string;
textSizeUnitLabel: string;
lineHeightLabel: string;
lineHeightUnitLabel: string;
letterSpacingLabel: string;
letterSpacingUnitLabel: string;
widthModeLabel: string;
widthModeOptionAuto: string;
widthModeOptionFull: string;
widthModeOptionFixed: string;
fixedWidthLabel: string;
fixedWidthUnitLabel: string;
paddingXLabel: string;
paddingXUnitLabel: string;
paddingYLabel: string;
paddingYUnitLabel: string;
optionGapLabel: string;
optionGapUnitLabel: string;
textColorLabel: string;
textColorPickerAria: string;
textColorHexAria: string;
fillColorLabel: string;
fillColorPickerAria: string;
fillColorHexAria: string;
strokeColorLabel: string;
strokeColorPickerAria: string;
strokeColorHexAria: string;
borderRadiusLabel: string;
borderRadiusPresetNone: string;
borderRadiusPresetSmall: string;
borderRadiusPresetMedium: string;
borderRadiusPresetLarge: string;
borderRadiusPresetFull: string;
};
const EDITOR_FORM_CHECKBOX_PANEL_MESSAGES: Record<AppLocale, EditorFormCheckboxPanelMessages> = {
en: {
sectionTitle: "Checkbox field",
groupTitleTypeLabel: "Group title type",
groupTitleTypeOptionText: "Text",
groupTitleTypeOptionImage: "Image",
groupTitleDisplayLabel: "Group title display mode",
groupTitleDisplayOptionVisible: "Visible (default)",
groupTitleDisplayOptionHidden: "Hidden",
layoutLabel: "Layout",
layoutOptionStacked: "Vertical (default)",
layoutOptionInline: "Horizontal (inline)",
optionLayoutLabel: "Option layout",
optionLayoutOptionStacked: "Vertical (default)",
optionLayoutOptionInline: "Horizontal (inline)",
labelGapLabel: "Label/field gap (px)",
groupTitleLabel: "Group title",
submitKeyLabel: "Submit key",
optionsLabel: "Options",
addOptionButtonLabel: "Add option",
optionLabelPlaceholder: "Label",
optionValuePlaceholder: "Value (value)",
optionImageSourceLabel: "Option image source",
optionImageSourceOptionUrl: "URL",
optionImageSourceOptionUpload: "File upload",
optionImageUrlPlaceholder: "Option image URL (optional)",
optionImageUploadLabel: "Option image file upload",
optionImageUploadAria: "Option image file upload",
groupTitleImageSourceLabel: "Group title image source",
groupTitleImageSourceOptionUrl: "URL",
groupTitleImageSourceOptionUpload: "File upload",
groupTitleImageUrlLabel: "Group title image URL",
groupTitleImageUploadLabel: "Group title image file upload",
groupTitleImageUploadAria: "Group title image file upload",
requiredNoticeText: "Required state is configured in the form controller.",
styleSectionTitle: "Field style",
textSizeLabel: "Checkbox text size (px)",
textSizeUnitLabel: "(px)",
lineHeightLabel: "Checkbox line height (px)",
lineHeightUnitLabel: "(px)",
letterSpacingLabel: "Checkbox letter spacing (px)",
letterSpacingUnitLabel: "(px)",
widthModeLabel: "Field width",
widthModeOptionAuto: "Auto",
widthModeOptionFull: "Full width",
widthModeOptionFixed: "Fixed value",
fixedWidthLabel: "Field fixed width",
fixedWidthUnitLabel: "(px)",
paddingXLabel: "Checkbox horizontal padding (px)",
paddingXUnitLabel: "(px)",
paddingYLabel: "Checkbox vertical padding (px)",
paddingYUnitLabel: "(px)",
optionGapLabel: "Checkbox option gap (px)",
optionGapUnitLabel: "(px)",
textColorLabel: "Text color",
textColorPickerAria: "Checkbox text color picker",
textColorHexAria: "Checkbox text color HEX",
fillColorLabel: "Background color",
fillColorPickerAria: "Checkbox background color picker",
fillColorHexAria: "Checkbox background color HEX",
strokeColorLabel: "Border color",
strokeColorPickerAria: "Checkbox border color picker",
strokeColorHexAria: "Checkbox border color HEX",
borderRadiusLabel: "Field border radius",
borderRadiusPresetNone: "None",
borderRadiusPresetSmall: "Small",
borderRadiusPresetMedium: "Medium",
borderRadiusPresetLarge: "Large",
borderRadiusPresetFull: "Fully rounded",
},
ko: {
sectionTitle: "체크박스 필드",
groupTitleTypeLabel: "그룹 타이틀 타입",
groupTitleTypeOptionText: "텍스트",
groupTitleTypeOptionImage: "이미지",
groupTitleDisplayLabel: "그룹 타이틀 표시 방식",
groupTitleDisplayOptionVisible: "표시 (기본)",
groupTitleDisplayOptionHidden: "숨김",
layoutLabel: "레이아웃",
layoutOptionStacked: "세로 (기본)",
layoutOptionInline: "가로 (인라인)",
optionLayoutLabel: "옵션 레이아웃",
optionLayoutOptionStacked: "세로 (기본)",
optionLayoutOptionInline: "가로 (인라인)",
labelGapLabel: "라벨/필드 간격 (px)",
groupTitleLabel: "그룹 타이틀",
submitKeyLabel: "전송 키",
optionsLabel: "옵션",
addOptionButtonLabel: "옵션 추가",
optionLabelPlaceholder: "라벨",
optionValuePlaceholder: "값(value)",
optionImageSourceLabel: "옵션 이미지 소스",
optionImageSourceOptionUrl: "URL",
optionImageSourceOptionUpload: "파일 업로드",
optionImageUrlPlaceholder: "옵션 이미지 URL (선택)",
optionImageUploadLabel: "옵션 이미지 파일 업로드",
optionImageUploadAria: "옵션 이미지 파일 업로드",
groupTitleImageSourceLabel: "그룹 타이틀 이미지 소스",
groupTitleImageSourceOptionUrl: "URL",
groupTitleImageSourceOptionUpload: "파일 업로드",
groupTitleImageUrlLabel: "그룹 타이틀 이미지 URL",
groupTitleImageUploadLabel: "그룹 타이틀 이미지 파일 업로드",
groupTitleImageUploadAria: "그룹 타이틀 이미지 파일 업로드",
requiredNoticeText: "필수 여부는 폼 컨트롤러에서 설정합니다.",
styleSectionTitle: "필드 스타일",
textSizeLabel: "체크박스 텍스트 크기 (px)",
textSizeUnitLabel: "(px)",
lineHeightLabel: "체크박스 줄간격 (px)",
lineHeightUnitLabel: "(px)",
letterSpacingLabel: "체크박스 자간 (px)",
letterSpacingUnitLabel: "(px)",
widthModeLabel: "필드 너비",
widthModeOptionAuto: "자동",
widthModeOptionFull: "전체 폭",
widthModeOptionFixed: "고정 값",
fixedWidthLabel: "필드 고정 너비",
fixedWidthUnitLabel: "(px)",
paddingXLabel: "체크박스 가로 패딩 (px)",
paddingXUnitLabel: "(px)",
paddingYLabel: "체크박스 세로 패딩 (px)",
paddingYUnitLabel: "(px)",
optionGapLabel: "체크박스 옵션 간격 (px)",
optionGapUnitLabel: "(px)",
textColorLabel: "텍스트 색상",
textColorPickerAria: "체크박스 텍스트 색상 피커",
textColorHexAria: "체크박스 텍스트 색상 HEX",
fillColorLabel: "배경 색상",
fillColorPickerAria: "체크박스 배경 색상 피커",
fillColorHexAria: "체크박스 배경 색상 HEX",
strokeColorLabel: "테두리 색상",
strokeColorPickerAria: "체크박스 테두리 색상 피커",
strokeColorHexAria: "체크박스 테두리 색상 HEX",
borderRadiusLabel: "필드 모서리 둥글기",
borderRadiusPresetNone: "없음",
borderRadiusPresetSmall: "작게",
borderRadiusPresetMedium: "보통",
borderRadiusPresetLarge: "크게",
borderRadiusPresetFull: "완전 둥글게",
},
};
export function getEditorFormCheckboxPanelMessages(
locale: AppLocale | null | undefined,
): EditorFormCheckboxPanelMessages {
const key = (locale ?? DEFAULT_LOCALE) as AppLocale;
return EDITOR_FORM_CHECKBOX_PANEL_MESSAGES[key] ?? EDITOR_FORM_CHECKBOX_PANEL_MESSAGES[DEFAULT_LOCALE];
}
@@ -0,0 +1,205 @@
import type { AppLocale } from "../locale";
import { DEFAULT_LOCALE } from "../locale";
export type EditorFormControllerPanelMessages = {
introTextPrefix: string;
introTextSuffix: string;
submitTargetLabel: string;
submitTargetOptionInternal: string;
submitTargetOptionWebhook: string;
webhookUrlLabel: string;
webhookUrlPlaceholder: string;
payloadFormatLabel: string;
payloadFormatOptionForm: string;
payloadFormatOptionJson: string;
httpMethodLabel: string;
authTokenLabel: string;
authTokenPlaceholder: string;
extraParamsLabel: string;
extraParamsPlaceholder: string;
layoutSectionTitle: string;
formWidthModeLabel: string;
formWidthModeOptionAuto: string;
formWidthModeOptionFull: string;
formWidthModeOptionFixed: string;
formFixedWidthLabel: string;
marginYLabel: string;
marginYPresetNone: string;
marginYPresetCompact: string;
marginYPresetNormal: string;
marginYPresetSpacious: string;
messagesSectionTitle: string;
successMessageLabel: string;
successMessagePlaceholder: string;
errorMessageLabel: string;
errorMessagePlaceholder: string;
controllerSectionTitle: string;
fieldMappingLegend: string;
fieldMappingAriaLabel: string;
requiredLabel: string;
submitButtonLabel: string;
submitButtonAriaLabel: string;
sheetsGuideButtonLabel: string;
sheetsGuideHeading: string;
sheetsGuideCloseAriaLabel: string;
sheetsGuideIntro: string;
sheetsGuideStep1: string;
sheetsGuideStep2: string;
sheetsGuideStep3: string;
sheetsGuideExampleCodeLabel: string;
};
const EDITOR_FORM_CONTROLLER_PANEL_MESSAGES: Record<AppLocale, EditorFormControllerPanelMessages> = {
en: {
introTextPrefix:
"This form is first submitted to the ",
introTextSuffix:
" endpoint on the public page, then processed internally or forwarded to a webhook depending on the settings.",
submitTargetLabel: "Submit target",
submitTargetOptionInternal: "Internal only (no webhook)",
submitTargetOptionWebhook: "Forward to external webhook / Google Sheets",
webhookUrlLabel: "Webhook URL (e.g. Google Apps Script web app URL)",
webhookUrlPlaceholder: "e.g. https://script.google.com/macros/s/.../exec",
payloadFormatLabel: "Payload format",
payloadFormatOptionForm: "Form data (x-www-form-urlencoded)",
payloadFormatOptionJson: "JSON (application/json)",
httpMethodLabel: "HTTP method",
authTokenLabel: "Authorization token (optional)",
authTokenPlaceholder: "e.g. Bearer xxxxxx",
extraParamsLabel: "Additional parameters (key=value lines, separated by line breaks)",
extraParamsPlaceholder: "e.g.\nsource=landing\nformId=contact-hero",
layoutSectionTitle: "Form layout",
formWidthModeLabel: "Form width mode",
formWidthModeOptionAuto: "Auto",
formWidthModeOptionFull: "Full width",
formWidthModeOptionFixed: "Fixed value",
formFixedWidthLabel: "Form fixed width (px)",
marginYLabel: "Form top/bottom margin (px)",
marginYPresetNone: "None",
marginYPresetCompact: "Compact",
marginYPresetNormal: "Normal",
marginYPresetSpacious: "Spacious",
messagesSectionTitle: "Form messages",
successMessageLabel: "Success message",
successMessagePlaceholder: "e.g. Your message has been sent successfully.",
errorMessageLabel: "Error message",
errorMessagePlaceholder: "e.g. An error occurred while submitting.",
controllerSectionTitle: "Form controller",
fieldMappingLegend: "Form field mapping",
fieldMappingAriaLabel: "Form field mapping",
requiredLabel: "Required",
submitButtonLabel: "Submit button",
submitButtonAriaLabel: "Submit button",
sheetsGuideButtonLabel: "Google Sheets integration guide",
sheetsGuideHeading: "Google Sheets integration guide",
sheetsGuideCloseAriaLabel: "Close Google Sheets integration guide",
sheetsGuideIntro:
"You must enter the Google Apps Script web app URL, not the spreadsheet URL itself. Follow the steps below to connect Google Sheets without writing code.",
sheetsGuideStep1:
"In Google Sheets, create a new spreadsheet and add headers like name, email, message in the first row.",
sheetsGuideStep2:
"From the menu, open \"Extensions → Apps Script\" and paste the example code below.",
sheetsGuideStep3:
"From \"Deploy → New deployment\", deploy as a web app and paste the issued web app URL (…/exec) into the Webhook URL field.",
sheetsGuideExampleCodeLabel: "Example Apps Script code",
},
ko: {
introTextPrefix:
"이 폼은 퍼블릭 페이지에서 ",
introTextSuffix:
" 엔드포인트로 먼저 전송된 뒤, 설정에 따라 내부 처리 또는 Webhook 으로 전달됩니다.",
submitTargetLabel: "전송 대상",
submitTargetOptionInternal: "서버 처리 전용 (Webhook 없이 사용)",
submitTargetOptionWebhook: "외부 Webhook / Google Sheets 로 전달",
webhookUrlLabel: "Webhook URL (예: Google Apps Script 웹 앱 URL)",
webhookUrlPlaceholder: "예: https://script.google.com/macros/s/.../exec",
payloadFormatLabel: "전송 포맷",
payloadFormatOptionForm: "폼 데이터 (x-www-form-urlencoded)",
payloadFormatOptionJson: "JSON (application/json)",
httpMethodLabel: "HTTP 메서드",
authTokenLabel: "Authorization 토큰 (옵션)",
authTokenPlaceholder: "예: Bearer xxxxxx",
extraParamsLabel: "추가 파라미터 (key=value 형식, 줄바꿈으로 구분)",
extraParamsPlaceholder: "예:\nsource=landing\nformId=contact-hero",
layoutSectionTitle: "폼 레이아웃",
formWidthModeLabel: "폼 너비 모드",
formWidthModeOptionAuto: "자동",
formWidthModeOptionFull: "전체 폭",
formWidthModeOptionFixed: "고정 값",
formFixedWidthLabel: "폼 고정 너비 (px)",
marginYLabel: "폼 위/아래 여백 (px)",
marginYPresetNone: "없음",
marginYPresetCompact: "좁게",
marginYPresetNormal: "보통",
marginYPresetSpacious: "넓게",
messagesSectionTitle: "폼 메시지",
successMessageLabel: "성공 메시지",
successMessagePlaceholder: "예: 성공적으로 전송되었습니다.",
errorMessageLabel: "에러 메시지",
errorMessagePlaceholder: "예: 전송 중 오류가 발생했습니다.",
controllerSectionTitle: "폼 컨트롤러",
fieldMappingLegend: "폼 필드 매핑",
fieldMappingAriaLabel: "폼 필드 매핑",
requiredLabel: "필수",
submitButtonLabel: "Submit 버튼",
submitButtonAriaLabel: "Submit 버튼",
sheetsGuideButtonLabel: "Google Sheets 연동 가이드",
sheetsGuideHeading: "Google Sheets 연동 가이드",
sheetsGuideCloseAriaLabel: "Google Sheets 연동 가이드 닫기",
sheetsGuideIntro:
"구글 시트 주소를 그대로 입력하는 것이 아니라, 시트에 연결된 Google Apps Script 웹 앱 URL 을 입력해야 합니다. 아래 순서를 따르면 코드를 잘 모르는 사용자도 연동할 수 있습니다.",
sheetsGuideStep1:
"Google Sheets 에서 새 스프레드시트를 만들고, 1행에 name, email, message 같은 헤더를 추가합니다.",
sheetsGuideStep2:
'시트 메뉴의 "확장 프로그램 → 앱스 스크립트" 를 열고 아래 예제 코드를 붙여넣습니다.',
sheetsGuideStep3:
'"배포 → 새 배포" 에서 웹 앱으로 배포한 뒤, 발급된 웹 앱 URL(…/exec) 을 Webhook URL 칸에 붙여넣습니다.',
sheetsGuideExampleCodeLabel: "예시 Apps Script 코드",
},
};
export function getEditorFormControllerPanelMessages(
locale: AppLocale | null | undefined,
): EditorFormControllerPanelMessages {
const key = (locale ?? DEFAULT_LOCALE) as AppLocale;
return EDITOR_FORM_CONTROLLER_PANEL_MESSAGES[key] ?? EDITOR_FORM_CONTROLLER_PANEL_MESSAGES[DEFAULT_LOCALE];
}
@@ -0,0 +1,266 @@
import type { AppLocale } from "../locale";
import { DEFAULT_LOCALE } from "../locale";
export type EditorFormInputPanelMessages = {
sectionTitle: string;
styleSectionTitle: string;
labelTypeLabel: string;
labelTypeOptionText: string;
labelTypeOptionImage: string;
fieldLabelLabel: string;
labelDisplayLabel: string;
labelDisplayOptionVisible: string;
labelDisplayOptionHidden: string;
labelDisplayOptionFloating: string;
layoutLabel: string;
layoutOptionStacked: string;
layoutOptionInline: string;
labelGapLabel: string;
labelGapUnitLabel: string;
labelImageUrlLabel: string;
labelImageAltLabel: string;
submitKeyLabel: string;
fieldTypeLabel: string;
fieldTypeAria: string;
fieldTypeOptionText: string;
fieldTypeOptionEmail: string;
fieldTypeOptionTextarea: string;
placeholderLabel: string;
placeholderAria: string;
requiredNoticeText: string;
textSizeLabel: string;
textSizeUnitLabel: string;
lineHeightLabel: string;
lineHeightUnitLabel: string;
letterSpacingLabel: string;
letterSpacingUnitLabel: string;
textAlignLabel: string;
textAlignOptionLeft: string;
textAlignOptionCenter: string;
textAlignOptionRight: string;
widthModeLabel: string;
widthModeAria: string;
widthModeOptionAuto: string;
widthModeOptionFull: string;
widthModeOptionFixed: string;
fixedWidthLabel: string;
fixedWidthUnitLabel: string;
paddingXLabel: string;
paddingXUnitLabel: string;
paddingYLabel: string;
paddingYUnitLabel: string;
textColorLabel: string;
textColorPickerAria: string;
textColorHexAria: string;
fillColorLabel: string;
fillColorPickerAria: string;
fillColorHexAria: string;
strokeColorLabel: string;
strokeColorPickerAria: string;
strokeColorHexAria: string;
borderRadiusLabel: string;
borderRadiusPresetNone: string;
borderRadiusPresetSmall: string;
borderRadiusPresetMedium: string;
borderRadiusPresetLarge: string;
borderRadiusPresetFull: string;
};
const EDITOR_FORM_INPUT_PANEL_MESSAGES: Record<AppLocale, EditorFormInputPanelMessages> = {
en: {
sectionTitle: "Input field",
styleSectionTitle: "Field style",
labelTypeLabel: "Label type",
labelTypeOptionText: "Text",
labelTypeOptionImage: "Image",
fieldLabelLabel: "Field label",
labelDisplayLabel: "Label display mode",
labelDisplayOptionVisible: "Visible (default)",
labelDisplayOptionHidden: "Hidden",
labelDisplayOptionFloating: "Floating label",
layoutLabel: "Layout",
layoutOptionStacked: "Vertical (default)",
layoutOptionInline: "Horizontal (inline)",
labelGapLabel: "Label/field gap (px)",
labelGapUnitLabel: "(px)",
labelImageUrlLabel: "Label image URL",
labelImageAltLabel: "Label image alt text",
submitKeyLabel: "Submit key",
fieldTypeLabel: "Field type",
fieldTypeAria: "Field type",
fieldTypeOptionText: "Text",
fieldTypeOptionEmail: "Email",
fieldTypeOptionTextarea: "Multiline text",
placeholderLabel: "Placeholder",
placeholderAria: "Placeholder",
requiredNoticeText: "Required state is configured in the form controller.",
textSizeLabel: "Field text size (px)",
textSizeUnitLabel: "(px)",
lineHeightLabel: "Field line height (px)",
lineHeightUnitLabel: "(px)",
letterSpacingLabel: "Field letter spacing (px)",
letterSpacingUnitLabel: "(px)",
textAlignLabel: "Text alignment",
textAlignOptionLeft: "Left",
textAlignOptionCenter: "Center",
textAlignOptionRight: "Right",
widthModeLabel: "Width",
widthModeAria: "Width",
widthModeOptionAuto: "Auto",
widthModeOptionFull: "Full width",
widthModeOptionFixed: "Fixed value",
fixedWidthLabel: "Field fixed width",
fixedWidthUnitLabel: "(px)",
paddingXLabel: "Field horizontal padding (px)",
paddingXUnitLabel: "(px)",
paddingYLabel: "Field vertical padding (px)",
paddingYUnitLabel: "(px)",
textColorLabel: "Field text color",
textColorPickerAria: "Field text color picker",
textColorHexAria: "Field text color HEX",
fillColorLabel: "Field fill color",
fillColorPickerAria: "Field fill color picker",
fillColorHexAria: "Field fill color HEX",
strokeColorLabel: "Field border color",
strokeColorPickerAria: "Field border color picker",
strokeColorHexAria: "Field border color HEX",
borderRadiusLabel: "Field border radius",
borderRadiusPresetNone: "None",
borderRadiusPresetSmall: "Small",
borderRadiusPresetMedium: "Medium",
borderRadiusPresetLarge: "Large",
borderRadiusPresetFull: "Fully rounded",
},
ko: {
sectionTitle: "입력 필드",
styleSectionTitle: "필드 스타일",
labelTypeLabel: "라벨 타입",
labelTypeOptionText: "텍스트",
labelTypeOptionImage: "이미지",
fieldLabelLabel: "필드 라벨",
labelDisplayLabel: "라벨 표시 방식",
labelDisplayOptionVisible: "표시 (기본)",
labelDisplayOptionHidden: "숨김",
labelDisplayOptionFloating: "플로팅 라벨",
layoutLabel: "레이아웃",
layoutOptionStacked: "세로 (기본)",
layoutOptionInline: "가로 (인라인)",
labelGapLabel: "라벨/필드 간격 (px)",
labelGapUnitLabel: "(px)",
labelImageUrlLabel: "라벨 이미지 URL",
labelImageAltLabel: "라벨 이미지 대체 텍스트",
submitKeyLabel: "전송 키",
fieldTypeLabel: "필드 타입",
fieldTypeAria: "필드 타입",
fieldTypeOptionText: "텍스트",
fieldTypeOptionEmail: "이메일",
fieldTypeOptionTextarea: "긴 텍스트",
placeholderLabel: "Placeholder",
placeholderAria: "Placeholder",
requiredNoticeText: "필수 여부는 폼 컨트롤러에서 설정합니다.",
textSizeLabel: "필드 텍스트 크기 (px)",
textSizeUnitLabel: "(px)",
lineHeightLabel: "필드 줄간격 (px)",
lineHeightUnitLabel: "(px)",
letterSpacingLabel: "필드 자간 (px)",
letterSpacingUnitLabel: "(px)",
textAlignLabel: "텍스트 정렬",
textAlignOptionLeft: "왼쪽",
textAlignOptionCenter: "가운데",
textAlignOptionRight: "오른쪽",
widthModeLabel: "너비",
widthModeAria: "너비",
widthModeOptionAuto: "자동",
widthModeOptionFull: "전체 폭",
widthModeOptionFixed: "고정 값",
fixedWidthLabel: "필드 고정 너비",
fixedWidthUnitLabel: "(px)",
paddingXLabel: "필드 가로 패딩 (px)",
paddingXUnitLabel: "(px)",
paddingYLabel: "필드 세로 패딩 (px)",
paddingYUnitLabel: "(px)",
textColorLabel: "필드 텍스트 색상",
textColorPickerAria: "필드 텍스트 색상 피커",
textColorHexAria: "필드 텍스트 색상 HEX",
fillColorLabel: "필드 채움 색상",
fillColorPickerAria: "필드 채움 색상 피커",
fillColorHexAria: "필드 채움 색상 HEX",
strokeColorLabel: "필드 테두리 색상",
strokeColorPickerAria: "필드 테두리 색상 피커",
strokeColorHexAria: "필드 테두리 색상 HEX",
borderRadiusLabel: "필드 모서리 둥글기",
borderRadiusPresetNone: "없음",
borderRadiusPresetSmall: "작게",
borderRadiusPresetMedium: "보통",
borderRadiusPresetLarge: "크게",
borderRadiusPresetFull: "완전 둥글게",
},
};
export function getEditorFormInputPanelMessages(locale: AppLocale | null | undefined): EditorFormInputPanelMessages {
const key = (locale ?? DEFAULT_LOCALE) as AppLocale;
return EDITOR_FORM_INPUT_PANEL_MESSAGES[key] ?? EDITOR_FORM_INPUT_PANEL_MESSAGES[DEFAULT_LOCALE];
}
@@ -0,0 +1,152 @@
import type { AppLocale } from "../locale";
import { DEFAULT_LOCALE } from "../locale";
export type EditorFormRadioPanelMessages = {
sectionTitle: string;
requiredNoticeText: string;
styleSectionTitle: string;
textSizeLabel: string;
textSizeUnitLabel: string;
lineHeightLabel: string;
lineHeightUnitLabel: string;
letterSpacingLabel: string;
letterSpacingUnitLabel: string;
fixedWidthLabel: string;
fixedWidthUnitLabel: string;
paddingXLabel: string;
paddingXUnitLabel: string;
paddingYLabel: string;
paddingYUnitLabel: string;
optionGapLabel: string;
optionGapUnitLabel: string;
textColorLabel: string;
textColorPickerAria: string;
textColorHexAria: string;
fillColorLabel: string;
fillColorPickerAria: string;
fillColorHexAria: string;
strokeColorLabel: string;
strokeColorPickerAria: string;
strokeColorHexAria: string;
borderRadiusLabel: string;
borderRadiusPresetNone: string;
borderRadiusPresetSmall: string;
borderRadiusPresetMedium: string;
borderRadiusPresetLarge: string;
borderRadiusPresetFull: string;
};
const EDITOR_FORM_RADIO_PANEL_MESSAGES: Record<AppLocale, EditorFormRadioPanelMessages> = {
en: {
sectionTitle: "Radio field",
requiredNoticeText: "Required state is configured in the form controller.",
styleSectionTitle: "Field style",
textSizeLabel: "Radio text size (px)",
textSizeUnitLabel: "(px)",
lineHeightLabel: "Radio line height (px)",
lineHeightUnitLabel: "(px)",
letterSpacingLabel: "Radio letter spacing (px)",
letterSpacingUnitLabel: "(px)",
fixedWidthLabel: "Field fixed width",
fixedWidthUnitLabel: "(px)",
paddingXLabel: "Radio horizontal padding (px)",
paddingXUnitLabel: "(px)",
paddingYLabel: "Radio vertical padding (px)",
paddingYUnitLabel: "(px)",
optionGapLabel: "Radio option gap (px)",
optionGapUnitLabel: "(px)",
textColorLabel: "Text color",
textColorPickerAria: "Radio text color picker",
textColorHexAria: "Radio text color HEX",
fillColorLabel: "Background color",
fillColorPickerAria: "Radio background color picker",
fillColorHexAria: "Radio background color HEX",
strokeColorLabel: "Border color",
strokeColorPickerAria: "Radio border color picker",
strokeColorHexAria: "Radio border color HEX",
borderRadiusLabel: "Field border radius",
borderRadiusPresetNone: "None",
borderRadiusPresetSmall: "Small",
borderRadiusPresetMedium: "Medium",
borderRadiusPresetLarge: "Large",
borderRadiusPresetFull: "Fully rounded",
},
ko: {
sectionTitle: "라디오 필드",
requiredNoticeText: "필수 여부는 폼 컨트롤러에서 설정합니다.",
styleSectionTitle: "필드 스타일",
textSizeLabel: "라디오 텍스트 크기 (px)",
textSizeUnitLabel: "(px)",
lineHeightLabel: "라디오 줄간격 (px)",
lineHeightUnitLabel: "(px)",
letterSpacingLabel: "라디오 자간 (px)",
letterSpacingUnitLabel: "(px)",
fixedWidthLabel: "필드 고정 너비",
fixedWidthUnitLabel: "(px)",
paddingXLabel: "라디오 가로 패딩 (px)",
paddingXUnitLabel: "(px)",
paddingYLabel: "라디오 세로 패딩 (px)",
paddingYUnitLabel: "(px)",
optionGapLabel: "라디오 옵션 간격 (px)",
optionGapUnitLabel: "(px)",
textColorLabel: "텍스트 색상",
textColorPickerAria: "라디오 텍스트 색상 피커",
textColorHexAria: "라디오 텍스트 색상 HEX",
fillColorLabel: "배경 색상",
fillColorPickerAria: "라디오 배경 색상 피커",
fillColorHexAria: "라디오 배경 색상 HEX",
strokeColorLabel: "테두리 색상",
strokeColorPickerAria: "라디오 테두리 색상 피커",
strokeColorHexAria: "라디오 테두리 색상 HEX",
borderRadiusLabel: "필드 모서리 둥글기",
borderRadiusPresetNone: "없음",
borderRadiusPresetSmall: "작게",
borderRadiusPresetMedium: "보통",
borderRadiusPresetLarge: "크게",
borderRadiusPresetFull: "완전 둥글게",
},
};
export function getEditorFormRadioPanelMessages(locale: AppLocale | null | undefined): EditorFormRadioPanelMessages {
const key = (locale ?? DEFAULT_LOCALE) as AppLocale;
return EDITOR_FORM_RADIO_PANEL_MESSAGES[key] ?? EDITOR_FORM_RADIO_PANEL_MESSAGES[DEFAULT_LOCALE];
}
@@ -0,0 +1,226 @@
import type { AppLocale } from "../locale";
import { DEFAULT_LOCALE } from "../locale";
export type EditorFormSelectPanelMessages = {
sectionTitle: string;
labelTypeLabel: string;
labelTypeOptionText: string;
labelTypeOptionImage: string;
labelDisplayLabel: string;
labelDisplayOptionVisible: string;
labelDisplayOptionHidden: string;
layoutLabel: string;
layoutOptionStacked: string;
layoutOptionInline: string;
labelGapLabel: string;
fieldLabelLabel: string;
submitKeyLabel: string;
optionsLabel: string;
addOptionButtonLabel: string;
optionLabelPlaceholder: string;
optionValuePlaceholder: string;
requiredNoticeText: string;
styleSectionTitle: string;
textSizeLabel: string;
textSizeUnitLabel: string;
lineHeightLabel: string;
lineHeightUnitLabel: string;
letterSpacingLabel: string;
letterSpacingUnitLabel: string;
widthModeLabel: string;
widthModeOptionAuto: string;
widthModeOptionFull: string;
widthModeOptionFixed: string;
fixedWidthLabel: string;
fixedWidthUnitLabel: string;
paddingXLabel: string;
paddingXUnitLabel: string;
paddingYLabel: string;
paddingYUnitLabel: string;
textColorLabel: string;
textColorPickerAria: string;
textColorHexAria: string;
fillColorLabel: string;
fillColorPickerAria: string;
fillColorHexAria: string;
strokeColorLabel: string;
strokeColorPickerAria: string;
strokeColorHexAria: string;
borderRadiusLabel: string;
borderRadiusPresetNone: string;
borderRadiusPresetSmall: string;
borderRadiusPresetMedium: string;
borderRadiusPresetLarge: string;
borderRadiusPresetFull: string;
};
const EDITOR_FORM_SELECT_PANEL_MESSAGES: Record<AppLocale, EditorFormSelectPanelMessages> = {
en: {
sectionTitle: "Select field",
labelTypeLabel: "Label type",
labelTypeOptionText: "Text",
labelTypeOptionImage: "Image",
labelDisplayLabel: "Label display mode",
labelDisplayOptionVisible: "Visible (default)",
labelDisplayOptionHidden: "Hidden",
layoutLabel: "Layout",
layoutOptionStacked: "Vertical (default)",
layoutOptionInline: "Horizontal (inline)",
labelGapLabel: "Label/field gap (px)",
fieldLabelLabel: "Field label",
submitKeyLabel: "Submit key",
optionsLabel: "Options",
addOptionButtonLabel: "Add option",
optionLabelPlaceholder: "Label",
optionValuePlaceholder: "Value (value)",
requiredNoticeText: "Required state is configured in the form controller.",
styleSectionTitle: "Field style",
textSizeLabel: "Select text size (px)",
textSizeUnitLabel: "(px)",
lineHeightLabel: "Select line height (px)",
lineHeightUnitLabel: "(px)",
letterSpacingLabel: "Select letter spacing (px)",
letterSpacingUnitLabel: "(px)",
widthModeLabel: "Field width",
widthModeOptionAuto: "Auto",
widthModeOptionFull: "Full width",
widthModeOptionFixed: "Fixed value",
fixedWidthLabel: "Field fixed width",
fixedWidthUnitLabel: "(px)",
paddingXLabel: "Select horizontal padding (px)",
paddingXUnitLabel: "(px)",
paddingYLabel: "Select vertical padding (px)",
paddingYUnitLabel: "(px)",
textColorLabel: "Field text color",
textColorPickerAria: "Select text color picker",
textColorHexAria: "Select text color HEX",
fillColorLabel: "Field fill color",
fillColorPickerAria: "Select fill color picker",
fillColorHexAria: "Select fill color HEX",
strokeColorLabel: "Field border color",
strokeColorPickerAria: "Select border color picker",
strokeColorHexAria: "Select border color HEX",
borderRadiusLabel: "Field border radius",
borderRadiusPresetNone: "None",
borderRadiusPresetSmall: "Small",
borderRadiusPresetMedium: "Medium",
borderRadiusPresetLarge: "Large",
borderRadiusPresetFull: "Fully rounded",
},
ko: {
sectionTitle: "셀렉트 필드",
labelTypeLabel: "라벨 타입",
labelTypeOptionText: "텍스트",
labelTypeOptionImage: "이미지",
labelDisplayLabel: "라벨 표시 방식",
labelDisplayOptionVisible: "표시 (기본)",
labelDisplayOptionHidden: "숨김",
layoutLabel: "레이아웃",
layoutOptionStacked: "세로 (기본)",
layoutOptionInline: "가로 (인라인)",
labelGapLabel: "라벨/필드 간격 (px)",
fieldLabelLabel: "필드 라벨",
submitKeyLabel: "전송 키",
optionsLabel: "옵션",
addOptionButtonLabel: "옵션 추가",
optionLabelPlaceholder: "라벨",
optionValuePlaceholder: "값(value)",
requiredNoticeText: "필수 여부는 폼 컨트롤러에서 설정합니다.",
styleSectionTitle: "필드 스타일",
textSizeLabel: "셀렉트 텍스트 크기 (px)",
textSizeUnitLabel: "(px)",
lineHeightLabel: "셀렉트 줄간격 (px)",
lineHeightUnitLabel: "(px)",
letterSpacingLabel: "셀렉트 자간 (px)",
letterSpacingUnitLabel: "(px)",
widthModeLabel: "필드 너비",
widthModeOptionAuto: "자동",
widthModeOptionFull: "전체 폭",
widthModeOptionFixed: "고정 값",
fixedWidthLabel: "필드 고정 너비",
fixedWidthUnitLabel: "(px)",
paddingXLabel: "셀렉트 가로 패딩 (px)",
paddingXUnitLabel: "(px)",
paddingYLabel: "셀렉트 세로 패딩 (px)",
paddingYUnitLabel: "(px)",
textColorLabel: "필드 텍스트 색상",
textColorPickerAria: "셀렉트 텍스트 색상 피커",
textColorHexAria: "셀렉트 텍스트 색상 HEX",
fillColorLabel: "필드 채움 색상",
fillColorPickerAria: "셀렉트 채움 색상 피커",
fillColorHexAria: "셀렉트 채움 색상 HEX",
strokeColorLabel: "필드 테두리 색상",
strokeColorPickerAria: "셀렉트 테두리 색상 피커",
strokeColorHexAria: "셀렉트 테두리 색상 HEX",
borderRadiusLabel: "필드 모서리 둥글기",
borderRadiusPresetNone: "없음",
borderRadiusPresetSmall: "작게",
borderRadiusPresetMedium: "보통",
borderRadiusPresetLarge: "크게",
borderRadiusPresetFull: "완전 둥글게",
},
};
export function getEditorFormSelectPanelMessages(
locale: AppLocale | null | undefined,
): EditorFormSelectPanelMessages {
const key = (locale ?? DEFAULT_LOCALE) as AppLocale;
return EDITOR_FORM_SELECT_PANEL_MESSAGES[key] ?? EDITOR_FORM_SELECT_PANEL_MESSAGES[DEFAULT_LOCALE];
}
@@ -0,0 +1,148 @@
import type { AppLocale } from "../locale";
import { DEFAULT_LOCALE } from "../locale";
export type EditorImagePanelMessages = {
imageSourceLabel: string;
imageSourceAria: string;
imageSourceOptionUrl: string;
imageSourceOptionUpload: string;
imageUrlLabel: string;
imageUrlAria: string;
imageUploadLabel: string;
imageUploadAria: string;
altLabel: string;
altAria: string;
styleSectionTitle: string;
cardBackgroundLabel: string;
cardBackgroundPickerAria: string;
cardBackgroundHexAria: string;
alignLabel: string;
alignAria: string;
alignOptionLeft: string;
alignOptionCenter: string;
alignOptionRight: string;
widthModeLabel: string;
widthModeAria: string;
widthModeOptionAuto: string;
widthModeOptionFixed: string;
fixedWidthLabel: string;
fixedWidthUnitLabel: string;
fixedWidthPresetSmall: string;
fixedWidthPresetMedium: string;
fixedWidthPresetLarge: string;
borderRadiusLabel: string;
borderRadiusPresetNone: string;
borderRadiusPresetSmall: string;
borderRadiusPresetMedium: string;
borderRadiusPresetLarge: string;
borderRadiusPresetFull: string;
};
const EDITOR_IMAGE_PANEL_MESSAGES: Record<AppLocale, EditorImagePanelMessages> = {
en: {
imageSourceLabel: "Image source",
imageSourceAria: "Image source",
imageSourceOptionUrl: "URL",
imageSourceOptionUpload: "File upload",
imageUrlLabel: "Image URL",
imageUrlAria: "Image URL",
imageUploadLabel: "Image file upload",
imageUploadAria: "Image file upload",
altLabel: "Alt text",
altAria: "Alt text",
styleSectionTitle: "Image style",
cardBackgroundLabel: "Card background color",
cardBackgroundPickerAria: "Image card background color picker",
cardBackgroundHexAria: "Image card background color HEX",
alignLabel: "Alignment",
alignAria: "Image alignment",
alignOptionLeft: "Left",
alignOptionCenter: "Center",
alignOptionRight: "Right",
widthModeLabel: "Width mode",
widthModeAria: "Image width mode",
widthModeOptionAuto: "Fit to content",
widthModeOptionFixed: "Fixed width (px)",
fixedWidthLabel: "Fixed width (px)",
fixedWidthUnitLabel: "(px)",
fixedWidthPresetSmall: "Small",
fixedWidthPresetMedium: "Medium",
fixedWidthPresetLarge: "Wide",
borderRadiusLabel: "Border radius",
borderRadiusPresetNone: "None",
borderRadiusPresetSmall: "Small",
borderRadiusPresetMedium: "Medium",
borderRadiusPresetLarge: "Large",
borderRadiusPresetFull: "Fully rounded",
},
ko: {
imageSourceLabel: "이미지 소스",
imageSourceAria: "이미지 소스",
imageSourceOptionUrl: "URL",
imageSourceOptionUpload: "파일 업로드",
imageUrlLabel: "이미지 URL",
imageUrlAria: "이미지 URL",
imageUploadLabel: "이미지 파일 업로드",
imageUploadAria: "이미지 파일 업로드",
altLabel: "대체 텍스트",
altAria: "대체 텍스트",
styleSectionTitle: "이미지 스타일",
cardBackgroundLabel: "카드 배경색",
cardBackgroundPickerAria: "이미지 카드 배경색 피커",
cardBackgroundHexAria: "이미지 카드 배경색 HEX",
alignLabel: "정렬",
alignAria: "이미지 정렬",
alignOptionLeft: "왼쪽",
alignOptionCenter: "가운데",
alignOptionRight: "오른쪽",
widthModeLabel: "너비 모드",
widthModeAria: "이미지 너비 모드",
widthModeOptionAuto: "내용에 맞춤",
widthModeOptionFixed: "고정 너비 (px)",
fixedWidthLabel: "고정 너비 (px)",
fixedWidthUnitLabel: "(px)",
fixedWidthPresetSmall: "작게",
fixedWidthPresetMedium: "보통",
fixedWidthPresetLarge: "넓게",
borderRadiusLabel: "모서리 둥글기",
borderRadiusPresetNone: "없음",
borderRadiusPresetSmall: "작게",
borderRadiusPresetMedium: "보통",
borderRadiusPresetLarge: "크게",
borderRadiusPresetFull: "완전 둥글게",
},
};
export function getEditorImagePanelMessages(
locale: AppLocale | null | undefined,
): EditorImagePanelMessages {
const key = (locale ?? DEFAULT_LOCALE) as AppLocale;
return EDITOR_IMAGE_PANEL_MESSAGES[key] ?? EDITOR_IMAGE_PANEL_MESSAGES[DEFAULT_LOCALE];
}
@@ -0,0 +1,160 @@
import type { AppLocale } from "../locale";
import { DEFAULT_LOCALE } from "../locale";
export type EditorListPanelMessages = {
listItemsLabel: string;
listItemsAria: string;
alignLabel: string;
alignAria: string;
alignOptionLeft: string;
alignOptionCenter: string;
alignOptionRight: string;
styleSectionTitle: string;
fontSizeLabel: string;
fontSizeUnitLabel: string;
fontSizePresetSmall: string;
fontSizePresetMedium: string;
fontSizePresetLarge: string;
lineHeightLabel: string;
lineHeightPresetTight: string;
lineHeightPresetNormal: string;
lineHeightPresetRelaxed: string;
textColorLabel: string;
textColorPickerAria: string;
textColorHexAria: string;
backgroundColorLabel: string;
backgroundColorPickerAria: string;
backgroundColorHexAria: string;
bulletStyleLabel: string;
bulletStyleAria: string;
bulletStyleOptionDisc: string;
bulletStyleOptionCircle: string;
bulletStyleOptionSquare: string;
bulletStyleOptionDecimal: string;
bulletStyleOptionLowerAlpha: string;
bulletStyleOptionUpperAlpha: string;
bulletStyleOptionLowerRoman: string;
bulletStyleOptionUpperRoman: string;
bulletStyleOptionNone: string;
gapLabel: string;
gapUnitLabel: string;
gapPresetTight: string;
gapPresetNormal: string;
gapPresetRelaxed: string;
};
const EDITOR_LIST_PANEL_MESSAGES: Record<AppLocale, EditorListPanelMessages> = {
en: {
listItemsLabel: "List items (separated by line breaks)",
listItemsAria: "List items",
alignLabel: "Alignment",
alignAria: "List alignment",
alignOptionLeft: "Left",
alignOptionCenter: "Center",
alignOptionRight: "Right",
styleSectionTitle: "List style",
fontSizeLabel: "Font size (px)",
fontSizeUnitLabel: "(px)",
fontSizePresetSmall: "Small",
fontSizePresetMedium: "Medium",
fontSizePresetLarge: "Large",
lineHeightLabel: "Line height",
lineHeightPresetTight: "Tight",
lineHeightPresetNormal: "Normal",
lineHeightPresetRelaxed: "Relaxed",
textColorLabel: "Text color",
textColorPickerAria: "List text color picker",
textColorHexAria: "List text color HEX",
backgroundColorLabel: "Block background color",
backgroundColorPickerAria: "List background color picker",
backgroundColorHexAria: "List background color HEX",
bulletStyleLabel: "Bullet style",
bulletStyleAria: "List bullet style",
bulletStyleOptionDisc: "Default (●)",
bulletStyleOptionCircle: "Circle (○)",
bulletStyleOptionSquare: "Square (■)",
bulletStyleOptionDecimal: "Decimal (1.)",
bulletStyleOptionLowerAlpha: "Lower alpha (a.)",
bulletStyleOptionUpperAlpha: "Upper alpha (A.)",
bulletStyleOptionLowerRoman: "Lower roman (i.)",
bulletStyleOptionUpperRoman: "Upper roman (I.)",
bulletStyleOptionNone: "None",
gapLabel: "Item gap (px)",
gapUnitLabel: "(px)",
gapPresetTight: "Tight",
gapPresetNormal: "Normal",
gapPresetRelaxed: "Relaxed",
},
ko: {
listItemsLabel: "리스트 아이템 (줄바꿈으로 구분)",
listItemsAria: "리스트 아이템들",
alignLabel: "정렬",
alignAria: "리스트 정렬",
alignOptionLeft: "왼쪽",
alignOptionCenter: "가운데",
alignOptionRight: "오른쪽",
styleSectionTitle: "리스트 스타일",
fontSizeLabel: "글자 크기 (px)",
fontSizeUnitLabel: "(px)",
fontSizePresetSmall: "작게",
fontSizePresetMedium: "보통",
fontSizePresetLarge: "크게",
lineHeightLabel: "줄 간격",
lineHeightPresetTight: "좁게",
lineHeightPresetNormal: "보통",
lineHeightPresetRelaxed: "넓게",
textColorLabel: "텍스트 색상",
textColorPickerAria: "리스트 텍스트 색상 피커",
textColorHexAria: "리스트 텍스트 색상 HEX",
backgroundColorLabel: "블록 배경색",
backgroundColorPickerAria: "리스트 배경색 피커",
backgroundColorHexAria: "리스트 배경색 HEX",
bulletStyleLabel: "불릿 스타일",
bulletStyleAria: "리스트 불릿 스타일",
bulletStyleOptionDisc: "기본 (●)",
bulletStyleOptionCircle: "원 (○)",
bulletStyleOptionSquare: "사각 (■)",
bulletStyleOptionDecimal: "숫자 (1.)",
bulletStyleOptionLowerAlpha: "알파벳 소문자 (a.)",
bulletStyleOptionUpperAlpha: "알파벳 대문자 (A.)",
bulletStyleOptionLowerRoman: "로마 숫자 소문자 (i.)",
bulletStyleOptionUpperRoman: "로마 숫자 대문자 (I.)",
bulletStyleOptionNone: "없음",
gapLabel: "아이템 간 여백 (px)",
gapUnitLabel: "(px)",
gapPresetTight: "좁게",
gapPresetNormal: "보통",
gapPresetRelaxed: "넓게",
},
};
export function getEditorListPanelMessages(
locale: AppLocale | null | undefined,
): EditorListPanelMessages {
const key = (locale ?? DEFAULT_LOCALE) as AppLocale;
return EDITOR_LIST_PANEL_MESSAGES[key] ?? EDITOR_LIST_PANEL_MESSAGES[DEFAULT_LOCALE];
}
@@ -0,0 +1,170 @@
import type { AppLocale } from "../locale";
import { DEFAULT_LOCALE } from "../locale";
export type EditorProjectPropertiesPanelMessages = {
sectionTitle: string;
projectTitleLabel: string;
projectTitleAria: string;
projectSlugLabel: string;
projectSlugAria: string;
canvasWidthLabel: string;
canvasWidthUnitLabel: string;
canvasWidthPresetMobile: string;
canvasWidthPresetTablet: string;
canvasWidthPresetDesktop: string;
canvasBgLabel: string;
canvasBgColorAria: string;
canvasBgHexAria: string;
pageBgLabel: string;
pageBgColorAria: string;
pageBgHexAria: string;
seoSectionTitle: string;
seoTitleLabel: string;
seoTitleAria: string;
seoTitlePlaceholderFallback: string;
seoDescriptionLabel: string;
seoDescriptionAria: string;
seoDescriptionPlaceholder: string;
seoOgImageUrlLabel: string;
seoOgImageUrlAria: string;
seoOgImageUrlPlaceholder: string;
seoCanonicalUrlLabel: string;
seoCanonicalUrlAria: string;
seoCanonicalUrlPlaceholder: string;
seoNoIndexAria: string;
seoNoIndexLabel: string;
headHtmlLabel: string;
headHtmlAria: string;
headHtmlPlaceholder: string;
trackingScriptLabel: string;
trackingScriptAria: string;
trackingScriptPlaceholder: string;
};
const EDITOR_PROJECT_PROPERTIES_PANEL_MESSAGES: Record<AppLocale, EditorProjectPropertiesPanelMessages> = {
en: {
sectionTitle: "Project settings",
projectTitleLabel: "Project title",
projectTitleAria: "Project title",
projectSlugLabel: "Project slug",
projectSlugAria: "Project slug",
canvasWidthLabel: "Canvas width",
canvasWidthUnitLabel: "(px)",
canvasWidthPresetMobile: "Mobile (390px)",
canvasWidthPresetTablet: "Tablet (768px)",
canvasWidthPresetDesktop: "Desktop (1200px)",
canvasBgLabel: "Canvas background color",
canvasBgColorAria: "Canvas background color",
canvasBgHexAria: "Canvas background HEX",
pageBgLabel: "Page background color",
pageBgColorAria: "Page background color",
pageBgHexAria: "Page background HEX",
seoSectionTitle: "SEO / meta",
seoTitleLabel: "SEO title",
seoTitleAria: "SEO title",
seoTitlePlaceholderFallback: "Page title",
seoDescriptionLabel: "Meta description",
seoDescriptionAria: "Meta description",
seoDescriptionPlaceholder: "Enter a description for search engines and social sharing.",
seoOgImageUrlLabel: "OG/Twitter image URL",
seoOgImageUrlAria: "OG/Twitter image URL",
seoOgImageUrlPlaceholder: "e.g. https://example.com/og-image.png",
seoCanonicalUrlLabel: "Canonical URL",
seoCanonicalUrlAria: "Canonical URL",
seoCanonicalUrlPlaceholder: "e.g. https://example.com/landing",
seoNoIndexAria: "Hide from search engines (noindex)",
seoNoIndexLabel: "Hide from search engines (noindex)",
headHtmlLabel: "Page head HTML",
headHtmlAria: "Page head HTML",
headHtmlPlaceholder: 'e.g. <meta name="description" content="..." />',
trackingScriptLabel: "Tracking script",
trackingScriptAria: "Tracking script",
trackingScriptPlaceholder: 'e.g. <script>/* GA, Pixel code */</script>',
},
ko: {
sectionTitle: "프로젝트 설정",
projectTitleLabel: "프로젝트 제목",
projectTitleAria: "프로젝트 제목",
projectSlugLabel: "프로젝트 주소 (slug)",
projectSlugAria: "프로젝트 주소 (slug)",
canvasWidthLabel: "캔버스 너비",
canvasWidthUnitLabel: "(px)",
canvasWidthPresetMobile: "모바일 (390px)",
canvasWidthPresetTablet: "태블릿 (768px)",
canvasWidthPresetDesktop: "데스크톱 (1200px)",
canvasBgLabel: "캔버스 배경색",
canvasBgColorAria: "캔버스 배경색",
canvasBgHexAria: "캔버스 배경색 HEX",
pageBgLabel: "페이지 배경색",
pageBgColorAria: "페이지 배경색",
pageBgHexAria: "페이지 배경색 HEX",
seoSectionTitle: "SEO / 메타",
seoTitleLabel: "SEO 타이틀",
seoTitleAria: "SEO 타이틀",
seoTitlePlaceholderFallback: "페이지 제목",
seoDescriptionLabel: "메타 디스크립션",
seoDescriptionAria: "메타 디스크립션",
seoDescriptionPlaceholder: "검색엔진 및 SNS 공유에 노출될 페이지 설명을 입력하세요.",
seoOgImageUrlLabel: "OG/Twitter 이미지 URL",
seoOgImageUrlAria: "OG/Twitter 이미지 URL",
seoOgImageUrlPlaceholder: "예: https://example.com/og-image.png",
seoCanonicalUrlLabel: "Canonical URL",
seoCanonicalUrlAria: "Canonical URL",
seoCanonicalUrlPlaceholder: "예: https://example.com/landing",
seoNoIndexAria: "검색 엔진에 노출하지 않기 (noindex)",
seoNoIndexLabel: "검색 엔진에 노출하지 않기 (noindex)",
headHtmlLabel: "페이지 head HTML",
headHtmlAria: "페이지 head HTML",
headHtmlPlaceholder: '예: <meta name="description" content="..." />',
trackingScriptLabel: "추적 스크립트",
trackingScriptAria: "추적 스크립트",
trackingScriptPlaceholder: '예: <script>/* GA, Pixel 코드 */</script>',
},
};
export function getEditorProjectPropertiesPanelMessages(
locale: AppLocale | null | undefined,
): EditorProjectPropertiesPanelMessages {
const key = (locale ?? DEFAULT_LOCALE) as AppLocale;
return EDITOR_PROJECT_PROPERTIES_PANEL_MESSAGES[key] ?? EDITOR_PROJECT_PROPERTIES_PANEL_MESSAGES[DEFAULT_LOCALE];
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,301 @@
import type { AppLocale } from "../locale";
import { DEFAULT_LOCALE } from "../locale";
export type EditorSectionPanelMessages = {
backgroundColorLabel: string;
backgroundColorPickerAria: string;
backgroundColorHexAria: string;
backgroundImageSourceLabel: string;
backgroundImageSourceAria: string;
backgroundImageSourceOptionNone: string;
backgroundImageSourceOptionUrl: string;
backgroundImageSourceOptionUpload: string;
backgroundImageUrlLabel: string;
backgroundImageUrlAria: string;
backgroundImageUploadLabel: string;
backgroundImageUploadAria: string;
backgroundImagePositionModeLabel: string;
backgroundImagePositionModeAria: string;
backgroundImagePositionModeOptionPreset: string;
backgroundImagePositionModeOptionCustom: string;
backgroundImageSizeLabel: string;
backgroundImageSizeAria: string;
backgroundImagePositionLabel: string;
backgroundImagePositionAria: string;
backgroundImagePositionOptionCenter: string;
backgroundImagePositionOptionTop: string;
backgroundImagePositionOptionBottom: string;
backgroundImagePositionOptionLeft: string;
backgroundImagePositionOptionRight: string;
backgroundImagePositionXLabel: string;
backgroundImagePositionYLabel: string;
backgroundImageRepeatLabel: string;
backgroundImageRepeatAria: string;
backgroundImageRepeatOptionNone: string;
backgroundImageRepeatOptionBoth: string;
backgroundImageRepeatOptionX: string;
backgroundImageRepeatOptionY: string;
backgroundVideoSourceLabel: string;
backgroundVideoSourceAria: string;
backgroundVideoSourceOptionNone: string;
backgroundVideoSourceOptionUrl: string;
backgroundVideoSourceOptionUpload: string;
backgroundVideoUrlLabel: string;
backgroundVideoUrlAria: string;
backgroundVideoUploadLabel: string;
backgroundVideoUploadAria: string;
paddingYLabel: string;
paddingYUnitLabel: string;
paddingYPresetExtraTight: string;
paddingYPresetTight: string;
paddingYPresetNormal: string;
paddingYPresetRelaxed: string;
paddingYPresetExtraRelaxed: string;
layoutSectionTitle: string;
columnLayoutLabel: string;
columnLayoutAria: string;
columnLayoutOptionOneFull: string;
columnLayoutOptionTwoEqual: string;
columnLayoutOptionTwoOneTwo: string;
columnLayoutOptionTwoTwoOne: string;
columnLayoutOptionThreeEqual: string;
columnLayoutOptionCustom: string;
maxWidthLabel: string;
maxWidthUnitLabel: string;
maxWidthPresetExtraNarrow: string;
maxWidthPresetNarrow: string;
maxWidthPresetNormal: string;
maxWidthPresetWide: string;
maxWidthPresetExtraWide: string;
gapXLabel: string;
gapXUnitLabel: string;
gapXPresetZero: string;
gapXPresetTight: string;
gapXPresetNormal: string;
gapXPresetRelaxed: string;
gapXPresetExtraRelaxed: string;
gapXPresetMax: string;
alignItemsLabel: string;
alignItemsAria: string;
alignItemsOptionTop: string;
alignItemsOptionCenter: string;
alignItemsOptionBottom: string;
};
const EDITOR_SECTION_PANEL_MESSAGES: Record<AppLocale, EditorSectionPanelMessages> = {
en: {
backgroundColorLabel: "Background color",
backgroundColorPickerAria: "Section background color picker",
backgroundColorHexAria: "Section background color HEX",
backgroundImageSourceLabel: "Background image source",
backgroundImageSourceAria: "Background image source",
backgroundImageSourceOptionNone: "None",
backgroundImageSourceOptionUrl: "URL",
backgroundImageSourceOptionUpload: "File upload",
backgroundImageUrlLabel: "Background image URL",
backgroundImageUrlAria: "Background image URL",
backgroundImageUploadLabel: "Background image file upload",
backgroundImageUploadAria: "Background image file upload",
backgroundImagePositionModeLabel: "Background image position mode",
backgroundImagePositionModeAria: "Background image position mode",
backgroundImagePositionModeOptionPreset: "Preset",
backgroundImagePositionModeOptionCustom: "Custom (X/Y)",
backgroundImageSizeLabel: "Background image size",
backgroundImageSizeAria: "Background image size",
backgroundImagePositionLabel: "Background image position",
backgroundImagePositionAria: "Background image position",
backgroundImagePositionOptionCenter: "Center",
backgroundImagePositionOptionTop: "Top",
backgroundImagePositionOptionBottom: "Bottom",
backgroundImagePositionOptionLeft: "Left",
backgroundImagePositionOptionRight: "Right",
backgroundImagePositionXLabel: "Background image horizontal position",
backgroundImagePositionYLabel: "Background image vertical position",
backgroundImageRepeatLabel: "Background image repeat",
backgroundImageRepeatAria: "Background image repeat",
backgroundImageRepeatOptionNone: "No repeat",
backgroundImageRepeatOptionBoth: "Repeat (x/y)",
backgroundImageRepeatOptionX: "Repeat horizontally",
backgroundImageRepeatOptionY: "Repeat vertically",
backgroundVideoSourceLabel: "Background video source",
backgroundVideoSourceAria: "Background video source",
backgroundVideoSourceOptionNone: "None",
backgroundVideoSourceOptionUrl: "URL",
backgroundVideoSourceOptionUpload: "File upload",
backgroundVideoUrlLabel: "Background video URL",
backgroundVideoUrlAria: "Background video URL",
backgroundVideoUploadLabel: "Background video file upload",
backgroundVideoUploadAria: "Background video file upload",
paddingYLabel: "Vertical padding",
paddingYUnitLabel: "(px)",
paddingYPresetExtraTight: "Very tight",
paddingYPresetTight: "Tight",
paddingYPresetNormal: "Normal",
paddingYPresetRelaxed: "Wide",
paddingYPresetExtraRelaxed: "Very wide",
layoutSectionTitle: "Section layout",
columnLayoutLabel: "Section column layout",
columnLayoutAria: "Section column layout",
columnLayoutOptionOneFull: "1 column (1/1)",
columnLayoutOptionTwoEqual: "2 columns (1/2 - 1/2)",
columnLayoutOptionTwoOneTwo: "2 columns (1/3 - 2/3)",
columnLayoutOptionTwoTwoOne: "2 columns (2/3 - 1/3)",
columnLayoutOptionThreeEqual: "3 columns (1/3 - 1/3 - 1/3)",
columnLayoutOptionCustom: "Custom",
maxWidthLabel: "Max width (px)",
maxWidthUnitLabel: "(px)",
maxWidthPresetExtraNarrow: "Very narrow",
maxWidthPresetNarrow: "Narrow",
maxWidthPresetNormal: "Normal",
maxWidthPresetWide: "Wide",
maxWidthPresetExtraWide: "Very wide",
gapXLabel: "Column gap (px)",
gapXUnitLabel: "(px)",
gapXPresetZero: "0",
gapXPresetTight: "Tight",
gapXPresetNormal: "Normal",
gapXPresetRelaxed: "Wide",
gapXPresetExtraRelaxed: "Very wide",
gapXPresetMax: "Max",
alignItemsLabel: "Column vertical alignment",
alignItemsAria: "Section column vertical alignment",
alignItemsOptionTop: "Top",
alignItemsOptionCenter: "Center",
alignItemsOptionBottom: "Bottom",
},
ko: {
backgroundColorLabel: "배경 색상",
backgroundColorPickerAria: "섹션 배경 색상 선택",
backgroundColorHexAria: "섹션 배경 색상 HEX 입력",
backgroundImageSourceLabel: "배경 이미지 소스",
backgroundImageSourceAria: "배경 이미지 소스",
backgroundImageSourceOptionNone: "없음",
backgroundImageSourceOptionUrl: "URL",
backgroundImageSourceOptionUpload: "파일 업로드",
backgroundImageUrlLabel: "배경 이미지 URL",
backgroundImageUrlAria: "배경 이미지 URL",
backgroundImageUploadLabel: "배경 이미지 파일 업로드",
backgroundImageUploadAria: "배경 이미지 파일 업로드",
backgroundImagePositionModeLabel: "배경 이미지 위치 모드",
backgroundImagePositionModeAria: "배경 이미지 위치 모드",
backgroundImagePositionModeOptionPreset: "프리셋",
backgroundImagePositionModeOptionCustom: "커스텀 (X/Y)",
backgroundImageSizeLabel: "배경 이미지 크기",
backgroundImageSizeAria: "배경 이미지 크기",
backgroundImagePositionLabel: "배경 이미지 위치",
backgroundImagePositionAria: "배경 이미지 위치",
backgroundImagePositionOptionCenter: "가운데",
backgroundImagePositionOptionTop: "위",
backgroundImagePositionOptionBottom: "아래",
backgroundImagePositionOptionLeft: "왼쪽",
backgroundImagePositionOptionRight: "오른쪽",
backgroundImagePositionXLabel: "배경 이미지 가로 위치",
backgroundImagePositionYLabel: "배경 이미지 세로 위치",
backgroundImageRepeatLabel: "배경 이미지 반복",
backgroundImageRepeatAria: "배경 이미지 반복",
backgroundImageRepeatOptionNone: "반복 없음",
backgroundImageRepeatOptionBoth: "가로/세로 반복",
backgroundImageRepeatOptionX: "가로 반복",
backgroundImageRepeatOptionY: "세로 반복",
backgroundVideoSourceLabel: "배경 비디오 소스",
backgroundVideoSourceAria: "배경 비디오 소스",
backgroundVideoSourceOptionNone: "없음",
backgroundVideoSourceOptionUrl: "URL",
backgroundVideoSourceOptionUpload: "파일 업로드",
backgroundVideoUrlLabel: "배경 비디오 URL",
backgroundVideoUrlAria: "배경 비디오 URL",
backgroundVideoUploadLabel: "배경 비디오 파일 업로드",
backgroundVideoUploadAria: "배경 비디오 파일 업로드",
paddingYLabel: "세로 패딩",
paddingYUnitLabel: "(px)",
paddingYPresetExtraTight: "매우 좁게",
paddingYPresetTight: "좁게",
paddingYPresetNormal: "보통",
paddingYPresetRelaxed: "넓게",
paddingYPresetExtraRelaxed: "아주 넓게",
layoutSectionTitle: "섹션 레이아웃",
columnLayoutLabel: "섹션 컬럼 레이아웃",
columnLayoutAria: "섹션 컬럼 레이아웃",
columnLayoutOptionOneFull: "1열 (1/1)",
columnLayoutOptionTwoEqual: "2열 (1/2 - 1/2)",
columnLayoutOptionTwoOneTwo: "2열 (1/3 - 2/3)",
columnLayoutOptionTwoTwoOne: "2열 (2/3 - 1/3)",
columnLayoutOptionThreeEqual: "3열 (1/3 - 1/3 - 1/3)",
columnLayoutOptionCustom: "직접 조정",
maxWidthLabel: "최대 폭 (px)",
maxWidthUnitLabel: "(px)",
maxWidthPresetExtraNarrow: "매우 좁게",
maxWidthPresetNarrow: "좁게",
maxWidthPresetNormal: "보통",
maxWidthPresetWide: "넓게",
maxWidthPresetExtraWide: "아주 넓게",
gapXLabel: "컬럼 간 간격 (px)",
gapXUnitLabel: "(px)",
gapXPresetZero: "0",
gapXPresetTight: "좁게",
gapXPresetNormal: "보통",
gapXPresetRelaxed: "넓게",
gapXPresetExtraRelaxed: "아주 넓게",
gapXPresetMax: "최대",
alignItemsLabel: "컬럼 세로 정렬",
alignItemsAria: "섹션 컬럼 세로 정렬",
alignItemsOptionTop: "위",
alignItemsOptionCenter: "가운데",
alignItemsOptionBottom: "아래",
},
};
export function getEditorSectionPanelMessages(
locale: AppLocale | null | undefined,
): EditorSectionPanelMessages {
const key = (locale ?? DEFAULT_LOCALE) as AppLocale;
return EDITOR_SECTION_PANEL_MESSAGES[key] ?? EDITOR_SECTION_PANEL_MESSAGES[DEFAULT_LOCALE];
}
+128
View File
@@ -0,0 +1,128 @@
import type { AppLocale } from "../locale";
import { DEFAULT_LOCALE } from "../locale";
export type EditorSidebarMessages = {
blocksTitle: string;
blocksText: string;
blocksButton: string;
blocksImage: string;
blocksVideo: string;
blocksDivider: string;
blocksList: string;
blocksSection: string;
formsTitle: string;
formsController: string;
formsInput: string;
formsSelect: string;
formsRadio: string;
formsCheckbox: string;
templatesTitle: string;
templatesGroupHeroCta: string;
templatesGroupContent: string;
templatesGroupTrust: string;
templatesGroupFooter: string;
templateHeroLabel: string;
templateHeroDescription: string;
templateCtaLabel: string;
templateCtaDescription: string;
templateFeaturesLabel: string;
templateFeaturesDescription: string;
templateFaqLabel: string;
templateFaqDescription: string;
templatePricingLabel: string;
templatePricingDescription: string;
templateBlogLabel: string;
templateBlogDescription: string;
templateTestimonialsLabel: string;
templateTestimonialsDescription: string;
templateTeamLabel: string;
templateTeamDescription: string;
templateFooterLabel: string;
templateFooterDescription: string;
};
const EDITOR_SIDEBAR_MESSAGES: Record<AppLocale, EditorSidebarMessages> = {
en: {
blocksTitle: "Blocks",
blocksText: "Text",
blocksButton: "Button",
blocksImage: "Image",
blocksVideo: "Video",
blocksDivider: "Divider",
blocksList: "List",
blocksSection: "Section",
formsTitle: "Form elements",
formsController: "Form controller",
formsInput: "Input field",
formsSelect: "Select field",
formsRadio: "Radio group",
formsCheckbox: "Checkbox group",
templatesTitle: "Templates",
templatesGroupHeroCta: "Hero · CTA",
templatesGroupContent: "Content sections",
templatesGroupTrust: "Trust & about",
templatesGroupFooter: "Footer & misc",
templateHeroLabel: "Hero template",
templateHeroDescription: "Top-of-page hero section (headline + subtext + button)",
templateCtaLabel: "CTA template",
templateCtaDescription: "Call-to-action (CTA) section",
templateFeaturesLabel: "Features template",
templateFeaturesDescription: "Three-column features section",
templateFaqLabel: "FAQ template",
templateFaqDescription: "Frequently asked questions (FAQ) section",
templatePricingLabel: "Pricing template",
templatePricingDescription: "Pricing plans section",
templateBlogLabel: "Blog template",
templateBlogDescription: "Blog post listing section",
templateTestimonialsLabel: "Testimonials template",
templateTestimonialsDescription: "Customer testimonials section",
templateTeamLabel: "Team template",
templateTeamDescription: "Team members section",
templateFooterLabel: "Footer template",
templateFooterDescription: "Page footer section",
},
ko: {
blocksTitle: "블록",
blocksText: "텍스트",
blocksButton: "버튼",
blocksImage: "이미지",
blocksVideo: "비디오",
blocksDivider: "구분선",
blocksList: "리스트",
blocksSection: "섹션",
formsTitle: "폼 요소",
formsController: "폼 컨트롤러",
formsInput: "입력(Input)",
formsSelect: "셀렉트(Select)",
formsRadio: "단일 선택(Radio)",
formsCheckbox: "다중 선택(Checkbox)",
templatesTitle: "템플릿",
templatesGroupHeroCta: "히어로 · CTA",
templatesGroupContent: "콘텐츠 섹션",
templatesGroupTrust: "신뢰/소개",
templatesGroupFooter: "푸터/기타",
templateHeroLabel: "Hero 템플릿",
templateHeroDescription: "페이지 상단 Hero 섹션 (큰 제목 + 서브텍스트 + 버튼)",
templateCtaLabel: "CTA 템플릿",
templateCtaDescription: "콜투액션(CTA) 섹션",
templateFeaturesLabel: "기능 템플릿",
templateFeaturesDescription: "3컬럼 기능 소개 섹션",
templateFaqLabel: "FAQ 템플릿",
templateFaqDescription: "자주 묻는 질문(FAQ) 섹션",
templatePricingLabel: "상품 템플릿",
templatePricingDescription: "요금제/플랜 소개 섹션",
templateBlogLabel: "블로그 템플릿",
templateBlogDescription: "블로그 포스트 목록 섹션",
templateTestimonialsLabel: "후기 템플릿",
templateTestimonialsDescription: "고객 후기(Testimonials) 섹션",
templateTeamLabel: "Team 템플릿",
templateTeamDescription: "팀 소개 섹션",
templateFooterLabel: "Footer 템플릿",
templateFooterDescription: "페이지 푸터 섹션",
},
};
export function getEditorSidebarMessages(locale: AppLocale | null | undefined): EditorSidebarMessages {
const key = (locale ?? DEFAULT_LOCALE) as AppLocale;
return EDITOR_SIDEBAR_MESSAGES[key] ?? EDITOR_SIDEBAR_MESSAGES[DEFAULT_LOCALE];
}
@@ -0,0 +1,291 @@
import type { AppLocale } from "../locale";
import { DEFAULT_LOCALE } from "../locale";
export type EditorHeroTemplateMessages = {
headline: string;
subheadline: string;
primaryButtonLabel: string;
};
export type EditorFeaturesTemplateMessages = {
itemTitlePrefix: string;
itemTitleSuffix: string;
itemDescription: string;
};
export type EditorCtaTemplateMessages = {
body: string;
buttonLabel: string;
};
export type EditorFaqTemplateMessages = {
title: string;
subtitle: string;
items: Array<{
question: string;
answer: string;
}>;
};
export type EditorPricingTemplateMessages = {
plans: Array<{
name: string;
price: string;
popular?: boolean;
}>;
featuresText: string;
primaryButtonLabel: string;
secondaryButtonLabel: string;
};
export type EditorTeamTemplateMessages = {
members: Array<{
name: string;
role: string;
bio: string;
}>;
};
export type EditorTestimonialsTemplateMessages = {
items: Array<{
body: string;
author: string;
}>;
};
export type EditorBlogTemplateMessages = {
posts: Array<{
title: string;
summary: string;
}>;
};
export type EditorFooterTemplateMessages = {
description: string;
linksText: string;
};
export type EditorTemplatesMessages = {
hero: EditorHeroTemplateMessages;
features: EditorFeaturesTemplateMessages;
cta: EditorCtaTemplateMessages;
faq: EditorFaqTemplateMessages;
pricing: EditorPricingTemplateMessages;
team: EditorTeamTemplateMessages;
testimonials: EditorTestimonialsTemplateMessages;
blog: EditorBlogTemplateMessages;
footer: EditorFooterTemplateMessages;
};
const EDITOR_TEMPLATES_MESSAGES: Record<AppLocale, EditorTemplatesMessages> = {
en: {
hero: {
headline: "Your hero headline goes here",
subheadline: "Describe your product or service in a single, clear sentence.",
primaryButtonLabel: "Get started now",
},
features: {
itemTitlePrefix: "Feature",
itemTitleSuffix: "",
itemDescription: "A short description of this feature.",
},
cta: {
body:
"Write a compelling CTA message here.\nDon't hesitate to get started!",
buttonLabel: "Call to action",
},
faq: {
title: "FAQ",
subtitle: "Frequently asked questions",
items: [
{
question: "How much does the service cost?",
answer:
"The core features are free, and premium features are available with a monthly subscription.",
},
{
question: "What is your refund policy?",
answer:
"If there is no usage within 7 days after purchase, you can request a full refund.",
},
{
question: "Can I invite teammates?",
answer:
"Yes, team invitations are available on Pro plans and above.",
},
],
},
pricing: {
plans: [
{ name: "Basic", price: "$9/month" },
{ name: "Pro", price: "$29/month", popular: true },
{ name: "Enterprise", price: "Contact us" },
],
featuresText: "• All core features\n• Email support\n• 1GB storage",
primaryButtonLabel: "Start now",
secondaryButtonLabel: "Choose plan",
},
team: {
members: [
{
name: "Alex Kim",
role: "Product Designer",
bio: "Designs thoughtful user experiences.",
},
{
name: "Jamie Lee",
role: "Frontend Engineer",
bio: "Builds clean, accessible UIs.",
},
{
name: "Chris Park",
role: "Backend Engineer",
bio: "Keeps the infrastructure fast and reliable.",
},
],
},
testimonials: {
items: [
{
body: "This builder dramatically reduced the time we spend creating landing pages.",
author: "Alex Kim",
},
{
body: "Even without strong design skills, we can still launch clean, modern pages.",
author: "Jamie Lee",
},
{
body: "Our whole team is happy with this builder.",
author: "Chris Park",
},
],
},
blog: {
posts: [
{
title: "Blog post 1",
summary: "Write a short summary for your first post here.",
},
{
title: "Blog post 2",
summary: "Write a short summary for your second post here.",
},
{
title: "Blog post 3",
summary: "Write a short summary for your third post here.",
},
],
},
footer: {
description: "The best choice for building better websites.",
linksText: "Product\nPricing\nSupport\nContact",
},
},
ko: {
hero: {
headline: "Hero 제목을 여기에 입력하세요",
subheadline: "제품이나 서비스를 한 문장으로 설명하는 서브텍스트입니다.",
primaryButtonLabel: "지금 시작하기",
},
features: {
itemTitlePrefix: "Feature",
itemTitleSuffix: " 제목",
itemDescription: "해당 기능을 간단히 설명하는 텍스트입니다.",
},
cta: {
body:
"지금 바로 행동을 유도하는 CTA 텍스트를 입력하세요.\n망설이지 말고 시작하세요!",
buttonLabel: "CTA 버튼",
},
faq: {
title: "FAQ",
subtitle: "자주 묻는 질문",
items: [
{
question: "서비스 이용료는 얼마인가요?",
answer: "기본 기능은 무료로 제공되며, 프리미엄 기능은 월 구독료가 발생합니다.",
},
{
question: "환불 정책은 어떻게 되나요?",
answer: "결제 후 7일 이내에 사용 이력이 없는 경우 전액 환불 가능합니다.",
},
{
question: "팀원 초대 기능이 있나요?",
answer: "네, 프로 요금제 이상부터 팀원 초대가 가능합니다.",
},
],
},
pricing: {
plans: [
{ name: "Basic", price: "₩9,900/월" },
{ name: "Pro", price: "₩29,900/월", popular: true },
{ name: "Enterprise", price: "문의" },
],
featuresText: "• 모든 기본 기능\n• 이메일 지원\n• 1GB 스토리지",
primaryButtonLabel: "시작하기",
secondaryButtonLabel: "선택하기",
},
team: {
members: [
{
name: "홍길동",
role: "Product Designer",
bio: "사용자 경험을 설계합니다.",
},
{
name: "김영희",
role: "Frontend Engineer",
bio: "깔끔한 UI를 구현합니다.",
},
{
name: "이철수",
role: "Backend Engineer",
bio: "안정적인 인프라를 책임집니다.",
},
],
},
testimonials: {
items: [
{
body: "이 서비스 덕분에 랜딩 페이지 제작 시간이 크게 줄었습니다.",
author: "홍길동",
},
{
body: "디자인을 잘 못해도 깔끔한 페이지를 만들 수 있어요.",
author: "김영희",
},
{
body: "팀 전체가 만족하는 빌더입니다.",
author: "이철수",
},
],
},
blog: {
posts: [
{
title: "블로그 포스트 1",
summary: "첫 번째 포스트 요약을 여기에 입력하세요.",
},
{
title: "블로그 포스트 2",
summary: "두 번째 포스트 요약을 여기에 입력하세요.",
},
{
title: "블로그 포스트 3",
summary: "세 번째 포스트 요약을 여기에 입력하세요.",
},
],
},
footer: {
description: "더 나은 웹사이트를 위한 최고의 선택.",
linksText: "서비스 소개\n요금제\n고객지원\n문의하기",
},
},
};
export function getEditorTemplatesMessages(
locale: AppLocale | null | undefined,
): EditorTemplatesMessages {
const key = (locale ?? DEFAULT_LOCALE) as AppLocale;
return EDITOR_TEMPLATES_MESSAGES[key] ?? EDITOR_TEMPLATES_MESSAGES[DEFAULT_LOCALE];
}
@@ -0,0 +1,160 @@
import type { AppLocale } from "../locale";
import { DEFAULT_LOCALE } from "../locale";
export type EditorTextPanelMessages = {
selectedTextLabel: string;
selectedTextAria: string;
alignLabel: string;
alignAria: string;
alignOptionLeft: string;
alignOptionCenter: string;
alignOptionRight: string;
textStyleLabel: string;
underlineLabel: string;
strikeLabel: string;
italicLabel: string;
fontSizeLabel: string;
fontSizeUnitLabel: string;
letterSpacingLabel: string;
letterSpacingPresetAria: string;
letterSpacingSliderAria: string;
letterSpacingInputAria: string;
letterSpacingPresetTighter: string;
letterSpacingPresetTight: string;
letterSpacingPresetNormal: string;
letterSpacingPresetWide: string;
letterSpacingPresetWider: string;
lineHeightLabel: string;
lineHeightPresetTight: string;
lineHeightPresetSnug: string;
lineHeightPresetNormal: string;
lineHeightPresetRelaxed: string;
lineHeightPresetLoose: string;
fontWeightLabel: string;
fontWeightPresetNormal: string;
fontWeightPresetMedium: string;
fontWeightPresetSemibold: string;
fontWeightPresetBold: string;
textColorLabel: string;
textColorPickerAria: string;
textColorHexAria: string;
backgroundColorLabel: string;
backgroundColorPickerAria: string;
backgroundColorHexAria: string;
maxWidthLabel: string;
maxWidthPresetAria: string;
maxWidthSliderAria: string;
maxWidthCustomAria: string;
maxWidthPlaceholder: string;
maxWidthPresetNone: string;
maxWidthPresetProse: string;
maxWidthPresetNarrow: string;
};
const EDITOR_TEXT_PANEL_MESSAGES: Record<AppLocale, EditorTextPanelMessages> = {
en: {
selectedTextLabel: "Selected text block content",
selectedTextAria: "Selected text block content",
alignLabel: "Alignment",
alignAria: "Alignment",
alignOptionLeft: "Left",
alignOptionCenter: "Center",
alignOptionRight: "Right",
textStyleLabel: "Text style",
underlineLabel: "Underline",
strikeLabel: "Strikethrough",
italicLabel: "Italic",
fontSizeLabel: "Font size",
fontSizeUnitLabel: "(px)",
letterSpacingLabel: "Letter spacing",
letterSpacingPresetAria: "Letter spacing preset",
letterSpacingSliderAria: "Letter spacing slider",
letterSpacingInputAria: "Letter spacing custom (px)",
letterSpacingPresetTighter: "Very tight",
letterSpacingPresetTight: "Tight",
letterSpacingPresetNormal: "Normal",
letterSpacingPresetWide: "Wide",
letterSpacingPresetWider: "Very wide",
lineHeightLabel: "Line height",
lineHeightPresetTight: "Tight",
lineHeightPresetSnug: "Slightly tight",
lineHeightPresetNormal: "Normal",
lineHeightPresetRelaxed: "Relaxed",
lineHeightPresetLoose: "Loose",
fontWeightLabel: "Weight",
fontWeightPresetNormal: "Regular",
fontWeightPresetMedium: "Medium",
fontWeightPresetSemibold: "Semibold",
fontWeightPresetBold: "Bold",
textColorLabel: "Text color",
textColorPickerAria: "Text color picker",
textColorHexAria: "Text color HEX",
backgroundColorLabel: "Block background color",
backgroundColorPickerAria: "Text block background color picker",
backgroundColorHexAria: "Text block background color HEX",
maxWidthLabel: "Max width",
maxWidthPresetAria: "Max width preset",
maxWidthSliderAria: "Max width slider (ch)",
maxWidthCustomAria: "Max width custom",
maxWidthPlaceholder: "e.g. 600px, 40rem, 80%, 60ch",
maxWidthPresetNone: "No limit",
maxWidthPresetProse: "Body width (60ch)",
maxWidthPresetNarrow: "Narrow (40ch)",
},
ko: {
selectedTextLabel: "선택한 텍스트 블록 내용",
selectedTextAria: "선택한 텍스트 블록 내용",
alignLabel: "정렬",
alignAria: "정렬",
alignOptionLeft: "왼쪽",
alignOptionCenter: "가운데",
alignOptionRight: "오른쪽",
textStyleLabel: "텍스트 스타일",
underlineLabel: "밑줄",
strikeLabel: "가운데줄",
italicLabel: "이탤릭",
fontSizeLabel: "글자 크기",
fontSizeUnitLabel: "(px)",
letterSpacingLabel: "글자 간격",
letterSpacingPresetAria: "글자 간격 프리셋",
letterSpacingSliderAria: "글자 간격 슬라이더",
letterSpacingInputAria: "글자 간격 커스텀 (px)",
letterSpacingPresetTighter: "아주 좁게",
letterSpacingPresetTight: "좁게",
letterSpacingPresetNormal: "보통",
letterSpacingPresetWide: "넓게",
letterSpacingPresetWider: "아주 넓게",
lineHeightLabel: "줄 간격",
lineHeightPresetTight: "좁게",
lineHeightPresetSnug: "약간 좁게",
lineHeightPresetNormal: "보통",
lineHeightPresetRelaxed: "넓게",
lineHeightPresetLoose: "아주 넓게",
fontWeightLabel: "굵기",
fontWeightPresetNormal: "보통",
fontWeightPresetMedium: "중간",
fontWeightPresetSemibold: "세미볼드",
fontWeightPresetBold: "볼드",
textColorLabel: "텍스트 색상",
textColorPickerAria: "텍스트 색상 피커",
textColorHexAria: "텍스트 색상 HEX",
backgroundColorLabel: "블록 배경색",
backgroundColorPickerAria: "텍스트 블록 배경색 피커",
backgroundColorHexAria: "텍스트 블록 배경색 HEX",
maxWidthLabel: "최대 너비",
maxWidthPresetAria: "최대 너비 프리셋",
maxWidthSliderAria: "최대 너비 슬라이더 (ch 단위)",
maxWidthCustomAria: "최대 너비 커스텀",
maxWidthPlaceholder: "예: 600px, 40rem, 80%, 60ch",
maxWidthPresetNone: "제한 없음",
maxWidthPresetProse: "본문 폭 (60ch)",
maxWidthPresetNarrow: "좁게 (40ch)",
},
};
export function getEditorTextPanelMessages(
locale: AppLocale | null | undefined,
): EditorTextPanelMessages {
const key = (locale ?? DEFAULT_LOCALE) as AppLocale;
return EDITOR_TEXT_PANEL_MESSAGES[key] ?? EDITOR_TEXT_PANEL_MESSAGES[DEFAULT_LOCALE];
}
@@ -0,0 +1,215 @@
import type { AppLocale } from "../locale";
import { DEFAULT_LOCALE } from "../locale";
export type EditorVideoPanelMessages = {
sourceLabel: string;
sourceAria: string;
sourceOptionUrl: string;
sourceOptionUpload: string;
urlLabel: string;
urlAria: string;
titleLabel: string;
titleAria: string;
ariaLabelLabel: string;
ariaLabelAria: string;
captionTextLabel: string;
captionTextAria: string;
posterImageUrlLabel: string;
posterImageUrlAria: string;
uploadLabel: string;
uploadAria: string;
styleSectionTitle: string;
cardBackgroundLabel: string;
cardBackgroundPickerAria: string;
cardBackgroundHexAria: string;
alignLabel: string;
alignAria: string;
alignOptionLeft: string;
alignOptionCenter: string;
alignOptionRight: string;
widthModeLabel: string;
widthModeAria: string;
widthModeOptionAuto: string;
widthModeOptionFull: string;
widthModeOptionFixed: string;
fixedWidthLabel: string;
fixedWidthUnitLabel: string;
fixedWidthPresetSmall: string;
fixedWidthPresetMedium: string;
fixedWidthPresetLarge: string;
cardPaddingLabel: string;
cardPaddingUnitLabel: string;
cardBorderRadiusLabel: string;
cardBorderRadiusUnitLabel: string;
startTimeLabel: string;
startTimeUnitLabel: string;
endTimeLabel: string;
endTimeUnitLabel: string;
aspectRatioLabel: string;
aspectRatioAria: string;
autoplayLabel: string;
loopLabel: string;
mutedLabel: string;
controlsLabel: string;
};
const EDITOR_VIDEO_PANEL_MESSAGES: Record<AppLocale, EditorVideoPanelMessages> = {
en: {
sourceLabel: "Video source",
sourceAria: "Video source",
sourceOptionUrl: "URL",
sourceOptionUpload: "File upload",
urlLabel: "Video URL",
urlAria: "Video URL",
titleLabel: "Video title",
titleAria: "Video title",
ariaLabelLabel: "Video aria-label",
ariaLabelAria: "Video aria-label",
captionTextLabel: "Video caption text",
captionTextAria: "Video caption text",
posterImageUrlLabel: "Poster image URL",
posterImageUrlAria: "Poster image URL",
uploadLabel: "Video file upload",
uploadAria: "Video file upload",
styleSectionTitle: "Video style",
cardBackgroundLabel: "Card background color",
cardBackgroundPickerAria: "Video card background color picker",
cardBackgroundHexAria: "Video card background color HEX",
alignLabel: "Video alignment",
alignAria: "Video alignment",
alignOptionLeft: "Left",
alignOptionCenter: "Center",
alignOptionRight: "Right",
widthModeLabel: "Video width mode",
widthModeAria: "Video width mode",
widthModeOptionAuto: "Fit to content",
widthModeOptionFull: "Full width",
widthModeOptionFixed: "Fixed width (px)",
fixedWidthLabel: "Fixed width (px)",
fixedWidthUnitLabel: "(px)",
fixedWidthPresetSmall: "Small",
fixedWidthPresetMedium: "Medium",
fixedWidthPresetLarge: "Wide",
cardPaddingLabel: "Card padding",
cardPaddingUnitLabel: "(px)",
cardBorderRadiusLabel: "Card border radius",
cardBorderRadiusUnitLabel: "(px)",
startTimeLabel: "Start time (sec)",
startTimeUnitLabel: "(sec)",
endTimeLabel: "End time (sec)",
endTimeUnitLabel: "(sec)",
aspectRatioLabel: "Video aspect ratio",
aspectRatioAria: "Video aspect ratio",
autoplayLabel: "Autoplay",
loopLabel: "Loop",
mutedLabel: "Muted",
controlsLabel: "Show controls",
},
ko: {
sourceLabel: "비디오 소스",
sourceAria: "비디오 소스",
sourceOptionUrl: "URL",
sourceOptionUpload: "파일 업로드",
urlLabel: "비디오 URL",
urlAria: "비디오 URL",
titleLabel: "비디오 제목",
titleAria: "비디오 제목",
ariaLabelLabel: "비디오 aria-label",
ariaLabelAria: "비디오 aria-label",
captionTextLabel: "비디오 캡션 텍스트",
captionTextAria: "비디오 캡션 텍스트",
posterImageUrlLabel: "포스터 이미지 URL",
posterImageUrlAria: "포스터 이미지 URL",
uploadLabel: "비디오 파일 업로드",
uploadAria: "비디오 파일 업로드",
styleSectionTitle: "비디오 스타일",
cardBackgroundLabel: "카드 배경색",
cardBackgroundPickerAria: "비디오 카드 배경색 피커",
cardBackgroundHexAria: "비디오 카드 배경색 HEX",
alignLabel: "비디오 정렬",
alignAria: "비디오 정렬",
alignOptionLeft: "왼쪽",
alignOptionCenter: "가운데",
alignOptionRight: "오른쪽",
widthModeLabel: "비디오 너비 모드",
widthModeAria: "비디오 너비 모드",
widthModeOptionAuto: "내용에 맞춤",
widthModeOptionFull: "가로 전체",
widthModeOptionFixed: "고정 너비 (px)",
fixedWidthLabel: "고정 너비 (px)",
fixedWidthUnitLabel: "(px)",
fixedWidthPresetSmall: "작게",
fixedWidthPresetMedium: "보통",
fixedWidthPresetLarge: "넓게",
cardPaddingLabel: "카드 패딩",
cardPaddingUnitLabel: "(px)",
cardBorderRadiusLabel: "카드 모서리 둥글기",
cardBorderRadiusUnitLabel: "(px)",
startTimeLabel: "시작 시점 (초)",
startTimeUnitLabel: "(초)",
endTimeLabel: "종료 시점 (초)",
endTimeUnitLabel: "(초)",
aspectRatioLabel: "화면 비율",
aspectRatioAria: "화면 비율",
autoplayLabel: "자동 재생",
loopLabel: "반복 재생",
mutedLabel: "음소거",
controlsLabel: "재생 컨트롤 표시",
},
};
export function getEditorVideoPanelMessages(locale: AppLocale | null | undefined): EditorVideoPanelMessages {
const key = (locale ?? DEFAULT_LOCALE) as AppLocale;
return EDITOR_VIDEO_PANEL_MESSAGES[key] ?? EDITOR_VIDEO_PANEL_MESSAGES[DEFAULT_LOCALE];
}
+32
View File
@@ -0,0 +1,32 @@
import type { AppLocale } from "../locale";
import { DEFAULT_LOCALE } from "../locale";
export type PreviewMessages = {
headerTitle: string;
headerDescription: string;
exportZipButtonLabel: string;
navProjects: string;
navBackToEditor: string;
};
const PREVIEW_MESSAGES: Record<AppLocale, PreviewMessages> = {
en: {
headerTitle: "Page Preview",
headerDescription: "Preview of the page built with the visual editor.",
exportZipButtonLabel: "Export as page files (ZIP)",
navProjects: "Projects",
navBackToEditor: "Back to editor",
},
ko: {
headerTitle: "Page Preview",
headerDescription: "빌더로 만든 페이지를 미리 보는 화면",
exportZipButtonLabel: "페이지 파일로 내보내기 (ZIP)",
navProjects: "프로젝트 목록",
navBackToEditor: "에디터로 돌아가기",
},
};
export function getPreviewMessages(locale: AppLocale | null | undefined): PreviewMessages {
const key = (locale ?? DEFAULT_LOCALE) as AppLocale;
return PREVIEW_MESSAGES[key] ?? PREVIEW_MESSAGES[DEFAULT_LOCALE];
}
+122
View File
@@ -0,0 +1,122 @@
import type { AppLocale } from "../locale";
import { DEFAULT_LOCALE } from "../locale";
export type ProjectsMessages = {
headerTitle: string;
headerDescription: string;
errorFetchUnauthorized: string;
errorFetchGeneric: string;
errorDeleteSingle: string;
errorDeleteBulkAllFailed: string;
errorDeleteBulkSomeFailed: string;
confirmDeleteSingle: string;
confirmDeleteBulk: string;
emptyListMessage: string;
toolbarSelectedPrefix: string;
toolbarSelectedSuffix: string;
toolbarDeleteSelected: string;
toolbarCreateNew: string;
tableHeaderTitle: string;
tableHeaderSlug: string;
tableHeaderStatus: string;
tableHeaderCreatedAt: string;
tableHeaderUpdatedAt: string;
tableHeaderActions: string;
tableHeaderSelectAllAria: string;
tableRowSelectAriaSuffix: string;
actionEdit: string;
actionPreview: string;
actionViewSubmissions: string;
actionDelete: string;
pagerTotalPrefix: string;
pagerTotalCountSuffix: string;
pagerPrevLabel: string;
pagerNextLabel: string;
};
const PROJECTS_MESSAGES: Record<AppLocale, ProjectsMessages> = {
en: {
headerTitle: "Projects",
headerDescription: "See all saved projects at a glance.",
errorFetchUnauthorized:
"You need to be signed in to view your projects. Please log in again.",
errorFetchGeneric:
"An error occurred while loading your projects. Please try again later.",
errorDeleteSingle:
"An error occurred while deleting the project. Please try again later.",
errorDeleteBulkAllFailed:
"An error occurred while deleting the selected projects. Please try again later.",
errorDeleteBulkSomeFailed:
"Failed to delete some projects. Please refresh and review the list.",
confirmDeleteSingle:
"Delete this project? This cannot be undone and any local/auto-saved data will also be removed.",
confirmDeleteBulk:
"Delete all selected projects? This cannot be undone and any local/auto-saved data will also be removed.",
emptyListMessage: "No projects saved yet. Save a project from the editor to see it here.",
toolbarSelectedPrefix: "Selected projects: ",
toolbarSelectedSuffix: "",
toolbarDeleteSelected: "Delete selected",
toolbarCreateNew: "Create new project",
tableHeaderTitle: "Title",
tableHeaderSlug: "Address (slug)",
tableHeaderStatus: "Status",
tableHeaderCreatedAt: "Created at",
tableHeaderUpdatedAt: "Updated at",
tableHeaderActions: "Actions",
tableHeaderSelectAllAria: "Select all projects on current page",
tableRowSelectAriaSuffix: " (select)",
actionEdit: "Edit",
actionPreview: "Preview",
actionViewSubmissions: "Form submissions",
actionDelete: "Delete",
pagerTotalPrefix: "Total ",
pagerTotalCountSuffix: " items, ",
pagerPrevLabel: "Previous",
pagerNextLabel: "Next",
},
ko: {
headerTitle: "프로젝트 목록",
headerDescription: "저장된 프로젝트들을 한 눈에 볼 수 있는 목록",
errorFetchUnauthorized:
"프로젝트 목록을 불러오는 중 인증 오류가 발생했습니다. 다시 로그인해 주세요.",
errorFetchGeneric:
"프로젝트 목록을 불러오는 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.",
errorDeleteSingle:
"프로젝트 삭제 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.",
errorDeleteBulkAllFailed:
"선택한 프로젝트를 삭제하는 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.",
errorDeleteBulkSomeFailed:
"일부 프로젝트 삭제에 실패했습니다. 페이지를 새로고침한 뒤 목록을 확인해 주세요.",
confirmDeleteSingle:
"이 프로젝트를 삭제할까요? 삭제 후에는 되돌릴 수 없습니다. (로컬 저장 및 자동저장 데이터도 함께 삭제됩니다.)",
confirmDeleteBulk:
"선택한 프로젝트를 모두 삭제할까요? 삭제 후에는 되돌릴 수 없습니다. (로컬 저장 및 자동저장 데이터도 함께 삭제됩니다.)",
emptyListMessage:
"아직 저장된 프로젝트가 없습니다. 에디터에서 프로젝트를 저장해 보세요.",
toolbarSelectedPrefix: "선택된 프로젝트: ",
toolbarSelectedSuffix: "개",
toolbarDeleteSelected: "선택 삭제",
toolbarCreateNew: "새 프로젝트 만들기",
tableHeaderTitle: "제목",
tableHeaderSlug: "주소(slug)",
tableHeaderStatus: "상태",
tableHeaderCreatedAt: "생성일",
tableHeaderUpdatedAt: "수정일",
tableHeaderActions: "액션",
tableHeaderSelectAllAria: "전체 선택",
tableRowSelectAriaSuffix: " 선택",
actionEdit: "편집",
actionPreview: "미리보기",
actionViewSubmissions: "폼 제출 내역",
actionDelete: "삭제",
pagerTotalPrefix: "전체 ",
pagerTotalCountSuffix: "개 중 ",
pagerPrevLabel: "이전",
pagerNextLabel: "다음",
},
};
export function getProjectsMessages(locale: AppLocale | null | undefined): ProjectsMessages {
const key = (locale ?? DEFAULT_LOCALE) as AppLocale;
return PROJECTS_MESSAGES[key] ?? PROJECTS_MESSAGES[DEFAULT_LOCALE];
}
@@ -0,0 +1,40 @@
import type { AppLocale } from "../locale";
import { DEFAULT_LOCALE } from "../locale";
export type PublicPageRendererMessages = {
requiredFieldsPrefix: string;
submitSuccessDefault: string;
submitErrorDefault: string;
checkboxGroupLabelImageAltFallback: string;
checkboxOptionImageAltFallback: string;
radioOptionImageAltFallback: string;
};
const PUBLIC_PAGE_RENDERER_MESSAGES: Record<AppLocale, PublicPageRendererMessages> = {
en: {
requiredFieldsPrefix: "Please fill in the following required fields:",
submitSuccessDefault: "Submitted successfully.",
submitErrorDefault: "An error occurred while submitting.",
checkboxGroupLabelImageAltFallback: "Form group title",
checkboxOptionImageAltFallback: "Checkbox option",
radioOptionImageAltFallback: "Radio option",
},
ko: {
requiredFieldsPrefix: "다음 필수 항목을 입력해 주세요:",
submitSuccessDefault: "성공적으로 전송되었습니다.",
submitErrorDefault: "전송 중 오류가 발생했습니다.",
checkboxGroupLabelImageAltFallback: "폼 그룹 타이틀",
checkboxOptionImageAltFallback: "체크박스 옵션",
radioOptionImageAltFallback: "라디오 옵션",
},
};
export function getPublicPageRendererMessages(
locale: AppLocale | null | undefined,
): PublicPageRendererMessages {
const key = (locale ?? DEFAULT_LOCALE) as AppLocale;
return PUBLIC_PAGE_RENDERER_MESSAGES[key] ?? PUBLIC_PAGE_RENDERER_MESSAGES[DEFAULT_LOCALE];
}
+70
View File
@@ -0,0 +1,70 @@
import type { AppLocale } from "../locale";
import { DEFAULT_LOCALE } from "../locale";
export type SubmissionsMessages = {
headerTitle: string;
headerDescription: string;
errorUnauthorized: string;
errorGeneric: string;
errorLogout: string;
loadingText: string;
emptyText: string;
tableHeaderCreatedAt: string;
tableHeaderProject: string;
tableHeaderSlug: string;
tableHeaderName: string;
tableHeaderEmail: string;
tableHeaderPhone: string;
tableHeaderBirthdate: string;
tableHeaderOtherFields: string;
};
const SUBMISSIONS_MESSAGES: Record<AppLocale, SubmissionsMessages> = {
en: {
headerTitle: "All form submissions",
headerDescription:
"View form submissions across all projects for the current signed-in account.",
errorUnauthorized:
"You need to be signed in to view form submissions. Please log in again.",
errorGeneric:
"An error occurred while loading form submissions. Please try again later.",
errorLogout:
"An error occurred while logging out. Please try again later.",
loadingText: "Loading form submissions...",
emptyText: "No form submissions have been collected yet.",
tableHeaderCreatedAt: "Submitted at",
tableHeaderProject: "Project",
tableHeaderSlug: "Slug",
tableHeaderName: "Name",
tableHeaderEmail: "Email",
tableHeaderPhone: "Phone",
tableHeaderBirthdate: "Birthdate",
tableHeaderOtherFields: "Other fields",
},
ko: {
headerTitle: "전체 폼 제출 내역",
headerDescription:
"로그인한 계정의 모든 프로젝트에 대한 폼 제출 데이터를 확인할 수 있습니다.",
errorUnauthorized:
"폼 제출 내역을 조회하려면 로그인이 필요합니다. 다시 로그인해 주세요.",
errorGeneric:
"폼 제출 내역을 불러오는 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.",
errorLogout:
"로그아웃 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.",
loadingText: "폼 제출 내역을 불러오는 중입니다...",
emptyText: "아직 수집된 폼 제출 내역이 없습니다.",
tableHeaderCreatedAt: "제출 시각",
tableHeaderProject: "프로젝트",
tableHeaderSlug: "Slug",
tableHeaderName: "이름",
tableHeaderEmail: "이메일",
tableHeaderPhone: "전화번호",
tableHeaderBirthdate: "생년월일",
tableHeaderOtherFields: "기타 필드",
},
};
export function getSubmissionsMessages(locale: AppLocale | null | undefined): SubmissionsMessages {
const key = (locale ?? DEFAULT_LOCALE) as AppLocale;
return SUBMISSIONS_MESSAGES[key] ?? SUBMISSIONS_MESSAGES[DEFAULT_LOCALE];
}
+55 -123
View File
@@ -8,6 +8,8 @@ import { createHeroTemplateBlocks } from "@/app/editor/templates/heroTemplate";
import { createFeaturesTemplateBlocks } from "@/app/editor/templates/featuresTemplate";
import { createCtaTemplateBlocks } from "@/app/editor/templates/ctaTemplate";
import { createFooterTemplateBlocks } from "@/app/editor/templates/footerTemplate";
import { getEditorTemplatesMessages } from "@/features/i18n/messages/editorTemplates";
import { DEFAULT_LOCALE } from "@/features/i18n/locale";
import { buildStaticHtml } from "@/app/api/export/route";
const BASE_URL = "http://localhost";
@@ -2013,13 +2015,12 @@ describe("/api/export", () => {
expect(html).toContain("Pro");
});
describe("템플릿 섹션 정적 export", () => {
function createIdFactory() {
let i = 0;
return () => `tpl_${++i}`;
}
function createIdFactory() {
let i = 0;
return () => `tpl_${++i}`;
}
async function exportTemplateHtml(blocks: Block[]): Promise<string> {
async function exportTemplateHtml(blocks: Block[]): Promise<string> {
const projectConfig: ProjectConfig = {
title: "템플릿 export 테스트",
slug: "template-export-test",
@@ -2050,148 +2051,80 @@ describe("/api/export", () => {
return html;
}
it("Hero 템플릿 섹션은 export HTML 에 기본 헤드라인/서브텍스트/버튼 텍스트를 포함해야 한다", async () => {
it("Hero 템플릿 섹션은 export HTML 에 헤드라인/서브텍스트/버튼 텍스트를 포함해야 한다", async () => {
const sectionId = "hero_section_export";
const { blocks } = createHeroTemplateBlocks({ sectionId, createId: createIdFactory() });
const tpl = getEditorTemplatesMessages(DEFAULT_LOCALE);
const { blocks } = createHeroTemplateBlocks({
sectionId,
createId: createIdFactory(),
messages: tpl.hero,
});
const html = await exportTemplateHtml(blocks as Block[]);
expect(html).toContain("Hero 제목을 여기에 입력하세요");
expect(html).toContain("제품이나 서비스를 한 문장으로 설명하는 서브텍스트입니다.");
expect(html).toContain("지금 시작하기");
expect(html).toContain("Your hero headline goes here");
expect(html).toContain(
"Describe your product or service in a single, clear sentence.",
);
expect(html).toContain("Get started now");
});
it("Features 템플릿 섹션은 export HTML 에 3개의 Feature 제목과 설명 텍스트를 포함해야 한다", async () => {
const sectionId = "features_section_export";
const { blocks } = createFeaturesTemplateBlocks({ sectionId, createId: createIdFactory() });
const tpl = getEditorTemplatesMessages(DEFAULT_LOCALE);
const { blocks } = createFeaturesTemplateBlocks({
sectionId,
createId: createIdFactory(),
messages: tpl.features,
});
const html = await exportTemplateHtml(blocks as Block[]);
expect(html).toContain("Feature 1 제목");
expect(html).toContain("Feature 2 제목");
expect(html).toContain("Feature 3 제목");
expect(html).toContain("Feature 1");
expect(html).toContain("Feature 2");
expect(html).toContain("Feature 3");
const descText = "해당 기능을 간단히 설명하는 텍스트입니다.";
const occurrences = html.split(descText).length - 1;
expect(occurrences).toBeGreaterThanOrEqual(3);
const descText = "A short description of this feature.";
const count = html.split(descText).length - 1;
expect(count).toBeGreaterThanOrEqual(3);
});
it("CTA 템플릿 섹션은 export HTML 에 CTA 본문 텍스트와 버튼 라벨을 포함해야 한다", async () => {
it("CTA 템플릿 섹션은 export HTML 에 CTA 텍스트와 버튼 라벨을 포함해야 한다", async () => {
const sectionId = "cta_section_export";
const { blocks } = createCtaTemplateBlocks({ sectionId, createId: createIdFactory() });
const tpl = getEditorTemplatesMessages(DEFAULT_LOCALE);
const { blocks } = createCtaTemplateBlocks({
sectionId,
createId: createIdFactory(),
messages: tpl.cta,
});
const html = await exportTemplateHtml(blocks as Block[]);
expect(html).toContain("지금 바로 행동을 유도하는 CTA 텍스트를 입력하세요.");
expect(html).toContain("CTA 버튼");
expect(html).toContain("Write a compelling CTA message here.");
// 작은따옴표는 HTML 이스케이프되어 Don&#39;t 형태로 렌더된다.
expect(html).toContain("Don&#39;t hesitate to get started!");
expect(html).toContain("Call to action");
});
it("Footer 템플릿 섹션은 export HTML 의 마지막 섹션으로 렌더되고 푸터 텍스트를 포함해야 한다", async () => {
it("Footer 템플릿 섹션은 export HTML 에 푸터 설명과 링크 텍스트를 포함해야 한다", async () => {
const sectionId = "footer_section_export";
const { blocks } = createFooterTemplateBlocks({ sectionId, createId: createIdFactory() });
const tpl = getEditorTemplatesMessages(DEFAULT_LOCALE);
const { blocks } = createFooterTemplateBlocks({
sectionId,
createId: createIdFactory(),
messages: tpl.footer,
});
const html = await exportTemplateHtml(blocks as Block[]);
expect(html).toContain("MyLanding");
expect(html).toContain("더 나은 웹사이트를 위한 최고의 선택.");
expect(html).toContain("서비스 소개");
expect(html).toContain("문의하기");
expect(html).toContain(" 2025 MyLanding.");
const lastSectionIndex = html.lastIndexOf("<section");
expect(lastSectionIndex).toBeGreaterThan(-1);
const lastSectionHtml = html.slice(lastSectionIndex);
expect(lastSectionHtml).toContain(" 2025 MyLanding.");
expect(html).toContain("The best choice for building better websites.");
expect(html).toContain("Product");
expect(html).toContain("Pricing");
expect(html).toContain("Support");
expect(html).toContain("Contact");
});
it("루트 버튼 내비게이션 링크는 Export HTML 에서 동일한 라벨과 href 로 렌더되어야 한다", async () => {
const blocks: Block[] = [
{
id: "nav_btn_1",
type: "button",
props: {
label: "요금제",
href: "/pricing",
align: "left",
},
} as any,
];
const projectConfig: ProjectConfig = {
title: "내비게이션 링크 테스트",
slug: "nav-link-test",
canvasPreset: "full",
};
const html = buildStaticHtml(blocks, projectConfig);
expect(html).toContain('<a href="/pricing"');
expect(html).toContain(">요금제</a>");
});
});
describe("buildStaticHtml", () => {
it("text 블록은 pb 텍스트 스타일 클래스를 포함해야 한다", async () => {
const blocks: Block[] = [
{
id: "text_styled",
type: "text",
props: {
text: "스타일 테스트",
align: "center",
size: "base",
fontSizeMode: "scale",
fontSizeScale: "lg",
lineHeightMode: "scale",
lineHeightScale: "relaxed",
fontWeightMode: "scale",
fontWeightScale: "semibold",
colorMode: "palette",
colorPalette: "strong",
underline: true,
italic: true,
},
} as any,
];
const projectConfig: ProjectConfig = {
title: "텍스트 스타일 테스트",
slug: "text-style-test",
canvasPreset: "full",
};
const payload = { blocks, projectConfig };
const { POST: handleExport } = await import("@/app/api/export/route");
const res = await handleExport(
new Request(`${BASE_URL}/api/export`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
}),
);
expect(res.status).toBe(200);
const arrayBuffer = await res.arrayBuffer();
const zip = await JSZip.loadAsync(arrayBuffer);
const indexEntry = zip.file("index.html");
expect(indexEntry).toBeTruthy();
const html = await indexEntry!.async("string");
expect(html).toContain("스타일 테스트");
expect(html).toContain("pb-text-center");
expect(html).toContain("pb-text-lg");
expect(html).toContain("pb-leading-relaxed");
expect(html).toContain("pb-font-semibold");
expect(html).toContain("pb-text-color-strong");
expect(html).toContain("pb-underline");
expect(html).toContain("pb-italic");
});
it("기본 텍스트 블록은 프리뷰처럼 흰 글자와 줄바꿈 유지 스타일을 사용해야 한다", async () => {
it("기본 텍스트 블록은 프리뷰처럼 흰 글자와 줄바꿈 유지 스타일을 사용해야 한다", async () => {
const blocks: Block[] = [
{
id: "text_default",
@@ -3244,4 +3177,3 @@ describe("/api/export", () => {
expect(css).toContain(".pb-section-column");
});
});
});
+8 -8
View File
@@ -16,7 +16,7 @@ test("비로그인 사용자가 /projects 에 접근하면 /login 으로 리다
await page.goto("/projects");
// 최종적으로 로그인 페이지의 헤더가 보여야 한다.
await expect(page.getByRole("heading", { name: "로그인" })).toBeVisible();
await expect(page.getByRole("heading", { name: "Log in" })).toBeVisible();
});
test("비로그인 사용자가 /editor 에 접근하면 /login 으로 리다이렉트되어야 한다", async ({ page }) => {
@@ -31,7 +31,7 @@ test("비로그인 사용자가 /editor 에 접근하면 /login 으로 리다이
await page.goto("/editor");
await expect(page.getByRole("heading", { name: "로그인" })).toBeVisible();
await expect(page.getByRole("heading", { name: "Log in" })).toBeVisible();
});
test("비로그인 사용자가 /preview 에 접근하면 /login 으로 리다이렉트되어야 한다", async ({ page }) => {
@@ -45,7 +45,7 @@ test("비로그인 사용자가 /preview 에 접근하면 /login 으로 리다
await page.goto("/preview");
await expect(page.getByRole("heading", { name: "로그인" })).toBeVisible();
await expect(page.getByRole("heading", { name: "Log in" })).toBeVisible();
});
test("회원가입 후에는 /dashboard, /editor, /preview 에 정상 접근할 수 있어야 한다", async ({ page }) => {
@@ -94,14 +94,14 @@ test("회원가입 후에는 /dashboard, /editor, /preview 에 정상 접근할
// 1) 회원가입
await page.goto("/signup");
await page.getByLabel("이메일").fill(email);
await page.getByLabel("비밀번호", { exact: true }).fill(password);
await page.getByLabel("비밀번호 확인").fill(password);
await page.getByRole("button", { name: "회원가입" }).click();
await page.getByLabel("Email").fill(email);
await page.getByLabel("Password", { exact: true }).fill(password);
await page.getByLabel("Confirm password").fill(password);
await page.getByRole("button", { name: "Sign up" }).click();
// /dashboard 로 이동했는지, 헤더가 보이는지 확인
await expect(page).toHaveURL(/\/dashboard/);
await expect(page.getByRole("heading", { name: "대시보드" })).toBeVisible();
await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible();
// 2) /editor 접근 확인 – 로그인 페이지로 리다이렉트되면 안 된다.
await page.goto("/editor");
+8 -8
View File
@@ -16,7 +16,7 @@ test("비로그인 사용자가 /dashboard 에 접근하면 /login 으로 리다
await page.goto("/dashboard");
await expect(page.getByRole("heading", { name: "로그인" })).toBeVisible();
await expect(page.getByRole("heading", { name: "Log in" })).toBeVisible();
});
test("로그인 사용자가 /dashboard 에 접근하면 요약 지표와 프로젝트 카드 리스트, 일별 그래프를 볼 수 있어야 한다", async ({ page }) => {
@@ -61,19 +61,19 @@ test("로그인 사용자가 /dashboard 에 접근하면 요약 지표와 프로
await page.goto("/dashboard");
// 대시보드 헤더가 보여야 한다.
await expect(page.getByRole("heading", { name: "대시보드" })).toBeVisible();
await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible();
// 상단 헤더 내비게이션이 보여야 한다.
const dashboardLink = page.getByRole("link", { name: "대시보드" });
const dashboardLink = page.getByRole("link", { name: "Dashboard" });
await expect(dashboardLink).toBeVisible();
const projectsLink = page.getByRole("link", { name: "프로젝트 목록" });
const projectsLink = page.getByRole("link", { name: "Projects" });
await expect(projectsLink).toBeVisible();
const submissionsLink = page.getByRole("link", { name: "전체 제출 내역" });
const submissionsLink = page.getByRole("link", { name: "All submissions" });
await expect(submissionsLink).toBeVisible();
const menuButton = page.getByRole("button", { name: "메뉴" });
const menuButton = page.getByRole("button", { name: "Menu" });
await expect(menuButton).toBeVisible();
// 상단 요약 위젯의 지표들이 기대 값으로 렌더되어야 한다.
@@ -112,7 +112,7 @@ test("로그인 사용자가 /dashboard 에 접근하면 요약 지표와 프로
// 메뉴 버튼을 클릭하면 로그아웃 항목이 보여야 한다.
await menuButton.click();
await expect(page.getByRole("button", { name: "로그아웃" })).toBeVisible();
await expect(page.getByRole("button", { name: "Log out" })).toBeVisible();
});
test("로그인 사용자가 / 에 접속하면 대시보드로 진입해야 한다", async ({ page }) => {
@@ -136,5 +136,5 @@ test("로그인 사용자가 / 에 접속하면 대시보드로 진입해야 한
await page.goto("/");
await expect(page).toHaveURL(/\/dashboard/);
await expect(page.getByRole("heading", { name: "대시보드" })).toBeVisible();
await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible();
});
+34 -30
View File
@@ -23,7 +23,7 @@ test("/editor 페이지가 존재하고 Page Editor 헤더를 보여줘야 한
test("텍스트 버튼을 누르면 캔버스에 '새 텍스트' 블록이 보여야 한다", async ({ page }) => {
await page.goto("/editor");
await page.getByRole("button", { name: "텍스트" }).first().click();
await page.getByRole("button", { name: "Text" }).first().click();
const canvas = page.getByTestId("editor-canvas");
await expect(canvas.getByText("새 텍스트")).toBeVisible();
@@ -255,22 +255,22 @@ test("에디터 상태를 JSON으로 내보내고 다시 불러오면 동일한
await sizeSelect.selectOption("lg");
// JSON 내보내기/불러오기 모달을 연다.
await page.getByRole("button", { name: "메뉴 ▼" }).click();
await page.getByRole("button", { name: "JSON 내보내기/불러오기" }).click();
await page.getByRole("button", { name: "Menu" }).click();
await page.getByRole("button", { name: "JSON export / import" }).click();
// 현재 상태를 JSON으로 내보낸다.
await page.getByRole("button", { name: "JSON 내보내기" }).click();
const exportArea = page.getByRole("textbox", { name: "에디터 상태 JSON" });
await page.getByRole("button", { name: "Export JSON" }).click();
const exportArea = page.getByRole("textbox", { name: "Editor state JSON" });
const exportedJson = await exportArea.inputValue();
// 캔버스를 초기화한다.
await page.getByRole("button", { name: "캔버스 초기화" }).click();
await page.getByRole("button", { name: "Clear canvas" }).click();
await expect(canvas.getByTestId("editor-block")).toHaveCount(0);
// JSON을 다시 입력하고 불러온다.
const importArea = page.getByRole("textbox", { name: "JSON에서 불러오기" });
const importArea = page.getByRole("textbox", { name: "Import from JSON" });
await importArea.fill(exportedJson);
await page.getByRole("button", { name: "JSON 적용하기" }).click();
await page.getByRole("button", { name: "Apply JSON" }).click();
// 복원된 캔버스에 두 블록이 모두 존재해야 한다.
const restoredBlocks = canvas.getByTestId("editor-block");
@@ -510,9 +510,9 @@ test("에디터 메뉴에서 페이지 파일로 내보내기 (ZIP)를 클릭하
await page.getByRole("button", { name: "텍스트" }).first().click();
// 헤더 메뉴를 연다.
await page.getByRole("button", { name: "메뉴 ▼" }).click();
await page.getByRole("button", { name: "Menu" }).click();
const exportButton = page.getByRole("button", { name: "페이지 파일로 내보내기 (ZIP)" });
const exportButton = page.getByRole("button", { name: "Export as page files (ZIP)" });
await expect(exportButton).toBeVisible();
const [download] = await Promise.all([
@@ -573,10 +573,10 @@ test("Hero 템플릿 버튼을 클릭하면 섹션과 기본 텍스트/버튼
await expect(blocks.first()).toBeVisible();
// Hero 헤드라인 텍스트가 포함된 블록이 보여야 한다.
await expect(canvas.getByText("Hero 제목을 여기에 입력하세요")).toBeVisible();
await expect(canvas.getByText("Your hero headline goes here")).toBeVisible();
// CTA 버튼도 렌더되어 있어야 한다.
const ctaButton = canvas.getByRole("button", { name: "지금 시작하기" });
const ctaButton = canvas.getByRole("button", { name: "Get started now" });
await expect(ctaButton).toBeVisible();
});
@@ -588,9 +588,9 @@ test("Features 템플릿 버튼을 클릭하면 3개의 Feature 항목(제목/
await page.getByRole("button", { name: "기능 템플릿" }).click();
// Feature 제목들이 렌더되어야 한다.
await expect(canvas.getByText("Feature 1 제목")).toBeVisible();
await expect(canvas.getByText("Feature 2 제목")).toBeVisible();
await expect(canvas.getByText("Feature 3 제목")).toBeVisible();
await expect(canvas.getByText("Feature 1")).toBeVisible();
await expect(canvas.getByText("Feature 2")).toBeVisible();
await expect(canvas.getByText("Feature 3")).toBeVisible();
});
test("CTA 템플릿 버튼을 클릭하면 CTA 텍스트와 버튼이 포함된 섹션이 캔버스에 생성되어야 한다", async ({ page }) => {
@@ -600,8 +600,8 @@ test("CTA 템플릿 버튼을 클릭하면 CTA 텍스트와 버튼이 포함된
await page.getByRole("button", { name: "CTA 템플릿" }).click();
await expect(canvas.getByText("지금 바로 행동을 유도하는 CTA 텍스트를 입력하세요.")).toBeVisible();
const ctaButton = canvas.getByRole("button", { name: "CTA 버튼" });
await expect(canvas.getByText("Write a compelling CTA message here.")).toBeVisible();
const ctaButton = canvas.getByRole("button", { name: "Call to action" });
await expect(ctaButton).toBeVisible();
});
@@ -613,10 +613,14 @@ test("FAQ 템플릿 버튼을 클릭하면 질문/답변 텍스트가 세 쌍
await page.getByRole("button", { name: "FAQ 템플릿" }).click();
// 기본 FAQ 질문/답변 텍스트들이 렌더되어야 한다.
await expect(canvas.getByText("서비스 이용료는 얼마인가요?")).toBeVisible();
await expect(canvas.getByText("기본 기능은 무료로 제공되며, 프리미엄 기능은 월 구독료가 발생합니다.")).toBeVisible();
await expect(canvas.getByText("환불 정책은 어떻게 되나요?")).toBeVisible();
await expect(canvas.getByText("팀원 초대 기능이 있나요?")).toBeVisible();
await expect(canvas.getByText("How much does the service cost?")).toBeVisible();
await expect(
canvas.getByText(
"The core features are free, and premium features are available with a monthly subscription.",
),
).toBeVisible();
await expect(canvas.getByText("What is your refund policy?")).toBeVisible();
await expect(canvas.getByText("Can I invite teammates?")).toBeVisible();
});
test("Pricing 템플릿 버튼을 클릭하면 3개의 요금제 카드가 생성되어야 한다", async ({ page }) => {
@@ -627,11 +631,11 @@ test("Pricing 템플릿 버튼을 클릭하면 3개의 요금제 카드가 생
await page.getByRole("button", { name: "상품 템플릿" }).click();
await expect(canvas.getByText("Basic")).toBeVisible();
await expect(canvas.getByText("₩9,900/월")).toBeVisible();
await expect(canvas.getByText("$9/month")).toBeVisible();
await expect(canvas.getByText("Pro")).toBeVisible();
await expect(canvas.getByText("29,900/월")).toBeVisible();
await expect(canvas.getByText("$29/month")).toBeVisible();
await expect(canvas.getByText("Enterprise")).toBeVisible();
await expect(canvas.getByText("문의")).toBeVisible();
await expect(canvas.getByText("Contact us")).toBeVisible();
});
test("Testimonials 템플릿 버튼을 클릭하면 3개의 후기 카드가 생성되어야 한다", async ({ page }) => {
@@ -743,8 +747,8 @@ test("헤더의 실행 취소/다시 실행 버튼으로 블록 추가를 Undo/R
await page.getByRole("button", { name: "텍스트" }).first().click();
await expect(canvas.getByTestId("editor-block")).toHaveCount(1);
const undoButton = page.getByRole("button", { name: "실행 취소" });
const redoButton = page.getByRole("button", { name: "다시 실행" });
const undoButton = page.getByRole("button", { name: "Undo" });
const redoButton = page.getByRole("button", { name: "Redo" });
await undoButton.click();
await expect(canvas.getByTestId("editor-block")).toHaveCount(0);
@@ -909,10 +913,10 @@ test("리스트 아이템을 선택하면 패널에서 아이템 위/아래/들
await firstItem.click({ force: true });
// 리스트 아이템 행에 위치한 조작 버튼들을 사용할 수 있어야 한다.
const moveUpButton = firstItem.getByRole("button", { name: "" });
const moveDownButton = firstItem.getByRole("button", { name: "아래" });
const indentButton = firstItem.getByRole("button", { name: "들여쓰기" });
const outdentButton = firstItem.getByRole("button", { name: "내어쓰기" });
const moveUpButton = firstItem.getByRole("button", { name: "Move item up" });
const moveDownButton = firstItem.getByRole("button", { name: "Move item down" });
const indentButton = firstItem.getByRole("button", { name: "Indent item" });
const outdentButton = firstItem.getByRole("button", { name: "Outdent item" });
await expect(moveUpButton).toBeVisible();
await expect(moveDownButton).toBeVisible();
+22 -18
View File
@@ -34,8 +34,8 @@ test("에디터에서 Hero 템플릿을 추가하면 프리뷰에서 동일한 H
// 헤드라인과 CTA 버튼이 에디터에서 생성되었는지 한 번 확인한다.
const editorCanvas = page.getByTestId("editor-canvas");
await expect(editorCanvas.getByText("Hero 제목을 여기에 입력하세요")).toBeVisible();
await expect(editorCanvas.getByRole("button", { name: "지금 시작하기" })).toBeVisible();
await expect(editorCanvas.getByText("Your hero headline goes here")).toBeVisible();
await expect(editorCanvas.getByRole("button", { name: "Get started now" })).toBeVisible();
// 헤더의 프리뷰 열기 링크를 통해 /preview 로 이동한다 (클라이언트 내 내비게이션 유지).
await page.getByRole("link", { name: "프리뷰 열기" }).click();
@@ -44,9 +44,9 @@ test("에디터에서 Hero 템플릿을 추가하면 프리뷰에서 동일한 H
await expect(page.getByRole("heading", { name: "Page Preview" })).toBeVisible();
// 프리뷰에서도 Hero 헤드라인과 CTA 버튼이 그대로 보여야 한다.
await expect(page.getByText("Hero 제목을 여기에 입력하세요")).toBeVisible();
await expect(page.getByText("Your hero headline goes here")).toBeVisible();
// 프리뷰에서는 CTA를 실제 링크(<a>)로 렌더링하므로 텍스트 기준으로만 검증한다.
await expect(page.getByText("지금 시작하기")).toBeVisible();
await expect(page.getByText("Get started now")).toBeVisible();
// 프리뷰 화면에는 에디터용 "텍스트" 버튼이 없어야 한다.
await expect(page.getByRole("button", { name: "텍스트" })).toHaveCount(0);
@@ -73,17 +73,17 @@ test("에디터에서 Features 템플릿을 추가하면 프리뷰에서 3개의
await page.getByRole("button", { name: "기능 템플릿" }).click();
const editorCanvas = page.getByTestId("editor-canvas");
await expect(editorCanvas.getByText("Feature 1 제목")).toBeVisible();
await expect(editorCanvas.getByText("Feature 2 제목")).toBeVisible();
await expect(editorCanvas.getByText("Feature 3 제목")).toBeVisible();
await expect(editorCanvas.getByText("Feature 1")).toBeVisible();
await expect(editorCanvas.getByText("Feature 2")).toBeVisible();
await expect(editorCanvas.getByText("Feature 3")).toBeVisible();
await page.getByRole("link", { name: "프리뷰 열기" }).click();
await expect(page.getByRole("heading", { name: "Page Preview" })).toBeVisible();
await expect(page.getByText("Feature 1 제목")).toBeVisible();
await expect(page.getByText("Feature 2 제목")).toBeVisible();
await expect(page.getByText("Feature 3 제목")).toBeVisible();
await expect(page.getByText("Feature 1")).toBeVisible();
await expect(page.getByText("Feature 2")).toBeVisible();
await expect(page.getByText("Feature 3")).toBeVisible();
});
test("에디터에서 CTA 템플릿을 추가하면 프리뷰에서 CTA 텍스트와 버튼이 보여야 한다", async ({ page }) => {
@@ -92,15 +92,15 @@ test("에디터에서 CTA 템플릿을 추가하면 프리뷰에서 CTA 텍스
await page.getByRole("button", { name: "CTA 템플릿" }).click();
const editorCanvas = page.getByTestId("editor-canvas");
await expect(editorCanvas.getByText("지금 바로 행동을 유도하는 CTA 텍스트를 입력하세요.")).toBeVisible();
await expect(editorCanvas.getByRole("button", { name: "CTA 버튼" })).toBeVisible();
await expect(editorCanvas.getByText("Write a compelling CTA message here.")).toBeVisible();
await expect(editorCanvas.getByRole("button", { name: "Call to action" })).toBeVisible();
await page.getByRole("link", { name: "프리뷰 열기" }).click();
await expect(page.getByRole("heading", { name: "Page Preview" })).toBeVisible();
await expect(page.getByText("지금 바로 행동을 유도하는 CTA 텍스트를 입력하세요.")).toBeVisible();
await expect(page.getByText("CTA 버튼")).toBeVisible();
await expect(page.getByText("Write a compelling CTA message here.")).toBeVisible();
await expect(page.getByText("Call to action")).toBeVisible();
});
test("모바일 뷰에서도 프리뷰 페이지가 깨지지 않고 주요 템플릿 컨텐츠를 보여줘야 한다", async ({ page }) => {
@@ -122,17 +122,21 @@ test("모바일 뷰에서도 프리뷰 페이지가 깨지지 않고 주요 템
await expect(page.getByRole("heading", { name: "Page Preview" })).toBeVisible();
// Hero 헤드라인
await expect(page.getByText("Hero 제목을 여기에 입력하세요")).toBeVisible();
await expect(page.getByText("Your hero headline goes here")).toBeVisible();
// Features 제목 일부
await expect(page.getByText("Feature 1 제목")).toBeVisible();
await expect(page.getByText("Feature 1")).toBeVisible();
// Pricing 플랜 이름/가격 일부
await expect(page.getByText("Basic")).toBeVisible();
await expect(page.getByText("₩9,900/월")).toBeVisible();
await expect(page.getByText("$9/month")).toBeVisible();
// Testimonials 본문 일부
await expect(page.getByText("이 서비스 덕분에 랜딩 페이지 제작 시간이 크게 줄었습니다.")).toBeVisible();
await expect(
page.getByText(
"This builder dramatically reduced the time we spend creating landing pages.",
),
).toBeVisible();
});
test("텍스트 블록의 정렬/폰트 크기/색상이 프리뷰에도 반영되어야 한다", async ({ page }) => {
+9 -9
View File
@@ -209,7 +209,7 @@ test("프로젝트 목록에서 '폼 제출 내역' 링크를 클릭하면 해
await expect(page.getByText("test-project-a")).toBeVisible();
// 액션 영역의 "폼 제출 내역" 링크를 클릭하면 제출 내역 페이지로 이동해야 한다.
await page.getByRole("link", { name: "폼 제출 내역" }).click();
await page.getByRole("link", { name: "Form submissions" }).click();
await expect(page).toHaveURL(/\/projects\/test-project-a\/submissions/);
@@ -284,10 +284,10 @@ test("프로젝트 목록 헤더에서 대시보드로 이동할 수 있어야
await expect(page.getByText("테스트 프로젝트 A")).toBeVisible();
await page.getByRole("link", { name: "대시보드" }).click();
await page.getByRole("link", { name: "Dashboard" }).click();
await expect(page).toHaveURL(/\/dashboard/);
await expect(page.getByRole("heading", { name: "대시보드" })).toBeVisible();
await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible();
});
test("전체 제출 내역 페이지에서 공통 GNB와 메뉴가 보여야 한다", async ({ page }) => {
@@ -309,20 +309,20 @@ test("전체 제출 내역 페이지에서 공통 GNB와 메뉴가 보여야 한
await page.goto("/projects/submissions");
await expect(page.getByRole("heading", { name: "전체 폼 제출 내역" })).toBeVisible();
await expect(page.getByRole("heading", { name: "All form submissions" })).toBeVisible();
const dashboardLink = page.getByRole("link", { name: "대시보드" });
const dashboardLink = page.getByRole("link", { name: "Dashboard" });
await expect(dashboardLink).toBeVisible();
const projectsLink = page.getByRole("link", { name: "프로젝트 목록" });
const projectsLink = page.getByRole("link", { name: "Projects" });
await expect(projectsLink).toBeVisible();
const submissionsLink = page.getByRole("link", { name: "전체 제출 내역" });
const submissionsLink = page.getByRole("link", { name: "All submissions" });
await expect(submissionsLink).toBeVisible();
const menuButton = page.getByRole("button", { name: "메뉴" });
const menuButton = page.getByRole("button", { name: "Menu" });
await expect(menuButton).toBeVisible();
await menuButton.click();
await expect(page.getByRole("button", { name: "로그아웃" })).toBeVisible();
await expect(page.getByRole("button", { name: "Log out" })).toBeVisible();
});
+11 -11
View File
@@ -73,7 +73,7 @@ describe("AllProjectSubmissionsPage - 전체 폼 제출 내역", () => {
expect(url).toBe("/api/projects/submissions");
expect(options).toBeUndefined();
expect(await screen.findByText("전체 폼 제출 내역")).toBeTruthy();
expect(await screen.findByText("All form submissions")).toBeTruthy();
expect(screen.getByText("프로젝트 1")).toBeTruthy();
expect(screen.getByText("proj-1")).toBeTruthy();
@@ -126,7 +126,7 @@ describe("AllProjectSubmissionsPage - 전체 폼 제출 내역", () => {
render(<AllProjectSubmissionsPage />);
const errorText = await screen.findByText(
"폼 제출 내역을 불러오는 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.",
"An error occurred while loading form submissions. Please try again later.",
);
expect(errorText).toBeTruthy();
@@ -144,7 +144,7 @@ describe("AllProjectSubmissionsPage - 전체 폼 제출 내역", () => {
render(<AllProjectSubmissionsPage />);
const backLink = await screen.findByRole("link", { name: "프로젝트 목록" });
const backLink = await screen.findByRole("link", { name: "Projects" });
expect(backLink).toBeTruthy();
expect(backLink.closest("a")?.getAttribute("href")).toBe("/projects");
});
@@ -161,13 +161,13 @@ describe("AllProjectSubmissionsPage - 전체 폼 제출 내역", () => {
render(<AllProjectSubmissionsPage />);
const dashboardLink = await screen.findByRole("link", { name: "대시보드" });
const dashboardLink = await screen.findByRole("link", { name: "Dashboard" });
expect(dashboardLink.closest("a")?.getAttribute("href")).toBe("/dashboard");
const projectsLink = await screen.findByRole("link", { name: "프로젝트 목록" });
const projectsLink = await screen.findByRole("link", { name: "Projects" });
expect(projectsLink.closest("a")?.getAttribute("href")).toBe("/projects");
const submissionsLink = await screen.findByRole("link", { name: "전체 제출 내역" });
const submissionsLink = await screen.findByRole("link", { name: "All submissions" });
expect(submissionsLink.closest("a")?.getAttribute("href")).toBe("/projects/submissions");
});
@@ -237,9 +237,9 @@ describe("AllProjectSubmissionsPage - 전체 폼 제출 내역", () => {
expect(fetchMock).toHaveBeenCalledTimes(1);
});
const dashboardLink = await screen.findByRole("link", { name: "대시보드" });
const projectsLink = await screen.findByRole("link", { name: "프로젝트 목록" });
const submissionsLink = await screen.findByRole("link", { name: "전체 제출 내역" });
const dashboardLink = await screen.findByRole("link", { name: "Dashboard" });
const projectsLink = await screen.findByRole("link", { name: "Projects" });
const submissionsLink = await screen.findByRole("link", { name: "All submissions" });
expect(submissionsLink.getAttribute("href")).toBe("/projects/submissions");
expect(submissionsLink.getAttribute("aria-current")).toBe("page");
@@ -273,10 +273,10 @@ describe("AllProjectSubmissionsPage - 전체 폼 제출 내역", () => {
render(<AllProjectSubmissionsPage />);
const menuButton = await screen.findByRole("button", { name: "메뉴" });
const menuButton = await screen.findByRole("button", { name: "Menu" });
menuButton.click();
const logoutButton = await screen.findByRole("button", { name: "로그아웃" });
const logoutButton = await screen.findByRole("button", { name: "Log out" });
logoutButton.click();
await waitFor(() => {
+43 -38
View File
@@ -54,7 +54,8 @@ describe("BlocksSidebar - 템플릿 버튼", () => {
it("Hero 템플릿 버튼 클릭 시 addHeroTemplateSection 이 호출되어야 한다", () => {
render(<BlocksSidebar />);
const button = screen.getByRole("button", { name: "Hero 템플릿" });
const button = screen.getByRole("button", { name: "Hero template" });
fireEvent.click(button);
expect(templateActions.addHeroTemplateSection).toHaveBeenCalledTimes(1);
@@ -62,7 +63,8 @@ describe("BlocksSidebar - 템플릿 버튼", () => {
it("Features 템플릿 버튼 클릭 시 addFeaturesTemplateSection 이 호출되어야 한다", () => {
render(<BlocksSidebar />);
const button = screen.getByRole("button", { name: "기능 템플릿" });
const button = screen.getByRole("button", { name: "Features template" });
fireEvent.click(button);
expect(templateActions.addFeaturesTemplateSection).toHaveBeenCalledTimes(1);
@@ -70,7 +72,8 @@ describe("BlocksSidebar - 템플릿 버튼", () => {
it("CTA 템플릿 버튼 클릭 시 addCtaTemplateSection 이 호출되어야 한다", () => {
render(<BlocksSidebar />);
const button = screen.getByRole("button", { name: "CTA 템플릿" });
const button = screen.getByRole("button", { name: "CTA template" });
fireEvent.click(button);
expect(templateActions.addCtaTemplateSection).toHaveBeenCalledTimes(1);
@@ -78,12 +81,13 @@ describe("BlocksSidebar - 템플릿 버튼", () => {
it("FAQ/Pricing/Testimonials/Blog/Team/Footer 템플릿 버튼도 각각의 액션을 호출해야 한다", () => {
render(<BlocksSidebar />);
fireEvent.click(screen.getByRole("button", { name: "FAQ 템플릿" }));
fireEvent.click(screen.getByRole("button", { name: "상품 템플릿" }));
fireEvent.click(screen.getByRole("button", { name: "후기 템플릿" }));
fireEvent.click(screen.getByRole("button", { name: "블로그 템플릿" }));
fireEvent.click(screen.getByRole("button", { name: "Team 템플릿" }));
fireEvent.click(screen.getByRole("button", { name: "Footer 템플릿" }));
fireEvent.click(screen.getByRole("button", { name: "FAQ template" }));
fireEvent.click(screen.getByRole("button", { name: "Pricing template" }));
fireEvent.click(screen.getByRole("button", { name: "Testimonials template" }));
fireEvent.click(screen.getByRole("button", { name: "Blog template" }));
fireEvent.click(screen.getByRole("button", { name: "Team template" }));
fireEvent.click(screen.getByRole("button", { name: "Footer template" }));
expect(templateActions.addFaqTemplateSection).toHaveBeenCalledTimes(1);
expect(templateActions.addPricingTemplateSection).toHaveBeenCalledTimes(1);
@@ -96,20 +100,21 @@ describe("BlocksSidebar - 템플릿 버튼", () => {
it("템플릿 섹션에는 각 템플릿의 용도를 설명하는 텍스트가 함께 표시되어야 한다", () => {
render(<BlocksSidebar />);
expect(screen.getByText("페이지 상단 Hero 섹션 (큰 제목 + 서브텍스트 + 버튼)"));
expect(screen.getByText("3컬럼 기능 소개 섹션"));
expect(screen.getByText("콜투액션(CTA) 섹션"));
expect(screen.getByText("자주 묻는 질문(FAQ) 섹션"));
expect(screen.getByText("요금제/플랜 소개 섹션"));
expect(screen.getByText("고객 후기(Testimonials) 섹션"));
expect(screen.getByText("블로그 포스트 목록 섹션"));
expect(screen.getByText("팀 소개 섹션"));
expect(screen.getByText("페이지 푸터 섹션"));
expect(screen.getByText("Top-of-page hero section (headline + subtext + button)"));
expect(screen.getByText("Three-column features section"));
expect(screen.getByText("Call-to-action (CTA) section"));
expect(screen.getByText("Frequently asked questions (FAQ) section"));
expect(screen.getByText("Pricing plans section"));
expect(screen.getByText("Customer testimonials section"));
expect(screen.getByText("Blog post listing section"));
expect(screen.getByText("Team members section"));
expect(screen.getByText("Page footer section"));
});
it("비디오 버튼 클릭 시 addVideoBlock 이 호출되어야 한다", () => {
render(<BlocksSidebar />);
const button = screen.getByRole("button", { name: "비디오" });
const button = screen.getByRole("button", { name: "Video" });
fireEvent.click(button);
expect(otherActions.addVideoBlock).toHaveBeenCalledTimes(1);
@@ -118,10 +123,10 @@ describe("BlocksSidebar - 템플릿 버튼", () => {
it("템플릿들은 카테고리별로 그룹 헤더를 가져야 한다", () => {
render(<BlocksSidebar />);
expect(screen.getByText("히어로 · CTA")).toBeDefined();
expect(screen.getByText("콘텐츠 섹션")).toBeDefined();
expect(screen.getByText("신뢰/소개")).toBeDefined();
expect(screen.getByText("푸터/기타")).toBeDefined();
expect(screen.getByText("Hero · CTA")).toBeDefined();
expect(screen.getByText("Content sections")).toBeDefined();
expect(screen.getByText("Trust & about")).toBeDefined();
expect(screen.getByText("Footer & misc")).toBeDefined();
});
it("각 템플릿 카드에는 미니 썸네일(레이아웃 힌트)가 렌더링되어야 한다", () => {
@@ -165,57 +170,57 @@ describe("BlocksSidebar - 템플릿 버튼", () => {
render(<BlocksSidebar />);
// 초기에는 텍스트 버튼이 보여야 한다.
expect(screen.getByRole("button", { name: "텍스트" })).toBeDefined();
expect(screen.getByRole("button", { name: "Text" })).toBeDefined();
const blockToggle = screen.getByRole("button", { name: "블록" });
const blockToggle = screen.getByRole("button", { name: "Blocks" });
fireEvent.click(blockToggle);
// 접힌 상태에서는 텍스트 버튼이 보이지 않아야 한다.
expect(screen.queryByRole("button", { name: "텍스트" })).toBeNull();
expect(screen.queryByRole("button", { name: "Text" })).toBeNull();
// 다시 클릭하면 펼쳐져야 한다.
fireEvent.click(blockToggle);
expect(screen.getByRole("button", { name: "텍스트" })).toBeDefined();
expect(screen.getByRole("button", { name: "Text" })).toBeDefined();
});
it("폼 요소 섹션 타이틀을 클릭하면 폼 관련 버튼들을 접고 펼 수 있어야 한다", () => {
render(<BlocksSidebar />);
// 초기에는 폼 입력 버튼이 보여야 한다.
expect(screen.getByRole("button", { name: "입력(Input)" })).toBeDefined();
expect(screen.getByRole("button", { name: "Input field" })).toBeDefined();
const formToggle = screen.getByRole("button", { name: "폼 요소" });
const formToggle = screen.getByRole("button", { name: "Form elements" });
fireEvent.click(formToggle);
// 접힌 상태에서는 폼 입력 버튼이 보이지 않아야 한다.
expect(screen.queryByRole("button", { name: "입력(Input)" })).toBeNull();
expect(screen.queryByRole("button", { name: "Input field" })).toBeNull();
// 다시 클릭하면 펼쳐져야 한다.
fireEvent.click(formToggle);
expect(screen.getByRole("button", { name: "입력(Input)" })).toBeDefined();
expect(screen.getByRole("button", { name: "Input field" })).toBeDefined();
});
it("템플릿 섹션 타이틀을 클릭하면 템플릿 카드들을 접고 펼 수 있어야 한다", () => {
render(<BlocksSidebar />);
// 초기에는 Hero 템플릿 버튼이 보여야 한다.
expect(screen.getByRole("button", { name: "Hero 템플릿" })).toBeDefined();
expect(screen.getByRole("button", { name: "Hero template" })).toBeDefined();
const templateToggle = screen.getByRole("button", { name: "템플릿" });
const templateToggle = screen.getByRole("button", { name: "Templates" });
fireEvent.click(templateToggle);
// 접힌 상태에서는 Hero 템플릿 버튼이 보이지 않아야 한다.
expect(screen.queryByRole("button", { name: "Hero 템플릿" })).toBeNull();
expect(screen.queryByRole("button", { name: "Hero template" })).toBeNull();
// 다시 클릭하면 펼쳐져야 한다.
fireEvent.click(templateToggle);
expect(screen.getByRole("button", { name: "Hero 템플릿" })).toBeDefined();
expect(screen.getByRole("button", { name: "Hero template" })).toBeDefined();
});
it("블록 추가 버튼들은 라이트/다크 테마에 맞는 배경/텍스트 클래스를 사용해야 한다", () => {
render(<BlocksSidebar />);
const textButton = screen.getByRole("button", { name: "텍스트" });
const textButton = screen.getByRole("button", { name: "Text" });
const className = textButton.getAttribute("class") ?? "";
// 라이트 모드: 밝은 배경 + 어두운 텍스트
@@ -231,7 +236,7 @@ describe("BlocksSidebar - 템플릿 버튼", () => {
render(<BlocksSidebar />);
const heroDescription = screen.getByText(
"페이지 상단 Hero 섹션 (큰 제목 + 서브텍스트 + 버튼)",
"Top-of-page hero section (headline + subtext + button)",
);
const card = heroDescription.closest("div");
expect(card).not.toBeNull();
@@ -246,7 +251,7 @@ describe("BlocksSidebar - 템플릿 버튼", () => {
it("히어로 템플릿 추가 버튼은 라이트/다크 테마에 맞는 배경/텍스트 클래스를 사용해야 한다", () => {
render(<BlocksSidebar />);
const heroButton = screen.getByRole("button", { name: "Hero 템플릿" });
const heroButton = screen.getByRole("button", { name: "Hero template" });
const className = heroButton.getAttribute("class") ?? "";
// 라이트 모드: 밝은 배경 + 어두운 텍스트
+27 -27
View File
@@ -31,7 +31,7 @@ describe("ButtonPropertiesPanel", () => {
/>,
);
const hexInput = screen.getByLabelText("버튼 텍스트 색상 HEX");
const hexInput = screen.getByLabelText("Button text color HEX");
fireEvent.change(hexInput, { target: { value: "#112233" } });
expect(updateBlock).toHaveBeenCalledWith(
@@ -51,7 +51,7 @@ describe("ButtonPropertiesPanel", () => {
/>,
);
const hexInput = screen.getByLabelText("버튼 채움 색상 HEX");
const hexInput = screen.getByLabelText("Button fill color HEX");
fireEvent.change(hexInput, { target: { value: "#445566" } });
expect(updateBlock).toHaveBeenCalledWith(
@@ -71,7 +71,7 @@ describe("ButtonPropertiesPanel", () => {
/>,
);
const hexInput = screen.getByLabelText("버튼 외곽선 색상 HEX");
const hexInput = screen.getByLabelText("Button border color HEX");
fireEvent.change(hexInput, { target: { value: "#778899" } });
expect(updateBlock).toHaveBeenCalledWith(
@@ -80,7 +80,7 @@ describe("ButtonPropertiesPanel", () => {
);
});
it("텍스트 색상 팔레트에서 \"없음\" 을 선택하면 textColorCustom 이 빈 문자열로 업데이트되어야 한다", () => {
it("텍스트 색상 팔레트에서 \"None\" 을 선택하면 textColorCustom 이 빈 문자열로 업데이트되어야 한다", () => {
const updateBlock = vi.fn();
render(
@@ -92,11 +92,11 @@ describe("ButtonPropertiesPanel", () => {
);
// 첫 번째 ColorPickerField(텍스트 색상)의 팔레트 버튼을 연다.
const paletteButtons = screen.getAllByText("색상 팔레트");
const paletteButtons = screen.getAllByText("Color palette");
fireEvent.click(paletteButtons[0].closest("button") as HTMLButtonElement);
// 팔레트에서 "없음" 항목을 버튼 role 기준으로 선택한다.
const noneButtons = screen.getAllByRole("button", { name: "없음" });
// 팔레트에서 "None" 항목을 버튼 role 기준으로 선택한다.
const noneButtons = screen.getAllByRole("button", { name: "None" });
fireEvent.click(noneButtons[0]);
expect(updateBlock).toHaveBeenCalledWith(
@@ -116,7 +116,7 @@ describe("ButtonPropertiesPanel", () => {
/>,
);
const hexInput = screen.getByLabelText("버튼 텍스트 색상 HEX") as HTMLInputElement;
const hexInput = screen.getByLabelText("Button text color HEX") as HTMLInputElement;
expect(hexInput.value).toBe("");
});
@@ -131,7 +131,7 @@ describe("ButtonPropertiesPanel", () => {
/>,
);
const select = screen.getByLabelText("버튼 너비 모드");
const select = screen.getByLabelText("Button width mode");
fireEvent.change(select, { target: { value: "fixed" } });
expect(updateBlock).toHaveBeenCalledWith(
@@ -155,7 +155,7 @@ describe("ButtonPropertiesPanel", () => {
/>,
);
const urlInput = screen.getByLabelText("버튼 이미지 URL");
const urlInput = screen.getByLabelText("Button image URL");
fireEvent.change(urlInput, { target: { value: "https://example.com/new.png" } });
expect(updateBlock).toHaveBeenCalledWith(
@@ -183,7 +183,7 @@ describe("ButtonPropertiesPanel", () => {
/>,
);
const select = screen.getByLabelText("버튼 이미지 위치");
const select = screen.getByLabelText("Button image placement");
fireEvent.change(select, { target: { value: "right" } });
expect(updateBlock).toHaveBeenCalledWith(
@@ -207,7 +207,7 @@ describe("ButtonPropertiesPanel", () => {
/>,
);
const select = screen.getByLabelText("버튼 이미지 소스");
const select = screen.getByLabelText("Button image source");
// 업로드 → URL 로 전환
fireEvent.change(select, { target: { value: "url" } });
@@ -231,7 +231,7 @@ describe("ButtonPropertiesPanel", () => {
/>,
);
const textarea = screen.getByLabelText("버튼 텍스트");
const textarea = screen.getByLabelText("Button text");
fireEvent.change(textarea, { target: { value: "새 버튼" } });
expect(updateBlock).toHaveBeenCalledWith(
@@ -251,7 +251,7 @@ describe("ButtonPropertiesPanel", () => {
/>,
);
const textarea = screen.getByLabelText("버튼 텍스트") as HTMLTextAreaElement;
const textarea = screen.getByLabelText("Button text") as HTMLTextAreaElement;
const className = textarea.getAttribute("class") ?? "";
// 라이트 모드: 흰 배경 + 어두운 텍스트
@@ -273,7 +273,7 @@ describe("ButtonPropertiesPanel", () => {
/>,
);
const slider = screen.getByLabelText("가로 패딩 (px) 슬라이더");
const slider = screen.getByLabelText("Horizontal padding (px) 슬라이더");
fireEvent.change(slider, { target: { value: "24" } });
expect(updateBlock).toHaveBeenCalledWith(
@@ -293,7 +293,7 @@ describe("ButtonPropertiesPanel", () => {
/>,
);
const slider = screen.getByLabelText("세로 패딩 (px) 슬라이더");
const slider = screen.getByLabelText("Vertical padding (px) 슬라이더");
fireEvent.change(slider, { target: { value: "18" } });
expect(updateBlock).toHaveBeenCalledWith(
@@ -313,7 +313,7 @@ describe("ButtonPropertiesPanel", () => {
/>,
);
const input = screen.getByLabelText("버튼 링크");
const input = screen.getByLabelText("Button link");
fireEvent.change(input, { target: { value: "/signup" } });
expect(updateBlock).toHaveBeenCalledWith(
@@ -333,7 +333,7 @@ describe("ButtonPropertiesPanel", () => {
/>,
);
const select = screen.getByLabelText("버튼 정렬");
const select = screen.getByLabelText("Button alignment");
fireEvent.change(select, { target: { value: "center" } });
expect(updateBlock).toHaveBeenCalledWith(
@@ -353,7 +353,7 @@ describe("ButtonPropertiesPanel", () => {
/>,
);
const select = screen.getByLabelText("버튼 스타일");
const select = screen.getByLabelText("Button style");
fireEvent.change(select, { target: { value: "outline" } });
expect(updateBlock).toHaveBeenCalledWith(
@@ -373,7 +373,7 @@ describe("ButtonPropertiesPanel", () => {
/>,
);
const slider = screen.getByLabelText("모서리 둥글기 슬라이더");
const slider = screen.getByLabelText("Border radius 슬라이더");
fireEvent.change(slider, { target: { value: "4" } });
expect(updateBlock).toHaveBeenCalledWith(
@@ -393,7 +393,7 @@ describe("ButtonPropertiesPanel", () => {
/>,
);
const slider = screen.getByLabelText("버튼 고정 너비 슬라이더");
const slider = screen.getByLabelText("Button fixed width 슬라이더");
fireEvent.change(slider, { target: { value: "300" } });
expect(updateBlock).toHaveBeenCalledWith(
@@ -413,7 +413,7 @@ describe("ButtonPropertiesPanel", () => {
/>,
);
const slider = screen.getByLabelText("버튼 크기 슬라이더");
const slider = screen.getByLabelText("Button font size 슬라이더");
fireEvent.change(slider, { target: { value: "20" } });
expect(updateBlock).toHaveBeenCalledWith(
@@ -433,7 +433,7 @@ describe("ButtonPropertiesPanel", () => {
/>,
);
const slider = screen.getByLabelText("줄 간격 슬라이더");
const slider = screen.getByLabelText("Line height 슬라이더");
fireEvent.change(slider, { target: { value: "1.8" } });
expect(updateBlock).toHaveBeenCalledWith(
@@ -453,7 +453,7 @@ describe("ButtonPropertiesPanel", () => {
/>,
);
const slider = screen.getByLabelText("글자 간격 슬라이더");
const slider = screen.getByLabelText("Letter spacing 슬라이더");
fireEvent.change(slider, { target: { value: "16" } });
expect(updateBlock).toHaveBeenCalledWith(
@@ -473,7 +473,7 @@ describe("ButtonPropertiesPanel", () => {
/>,
);
const labelTextarea = screen.getByLabelText("버튼 텍스트") as HTMLTextAreaElement;
const labelTextarea = screen.getByLabelText("Button text") as HTMLTextAreaElement;
const labelClass = labelTextarea.getAttribute("class") ?? "";
expect(labelClass).toContain("bg-white");
expect(labelClass).toContain("text-slate-900");
@@ -482,7 +482,7 @@ describe("ButtonPropertiesPanel", () => {
expect(labelClass).toContain("dark:text-slate-100");
expect(labelClass).toContain("dark:border-slate-700");
const imageSourceSelect = screen.getByLabelText("버튼 이미지 소스") as HTMLSelectElement;
const imageSourceSelect = screen.getByLabelText("Button image source") as HTMLSelectElement;
const imageSourceClass = imageSourceSelect.getAttribute("class") ?? "";
expect(imageSourceClass).toContain("bg-white");
expect(imageSourceClass).toContain("text-slate-900");
@@ -491,7 +491,7 @@ describe("ButtonPropertiesPanel", () => {
expect(imageSourceClass).toContain("dark:text-slate-100");
expect(imageSourceClass).toContain("dark:border-slate-700");
const alignSelect = screen.getByLabelText("버튼 정렬") as HTMLSelectElement;
const alignSelect = screen.getByLabelText("Button alignment") as HTMLSelectElement;
const alignClass = alignSelect.getAttribute("class") ?? "";
expect(alignClass).toContain("bg-white");
expect(alignClass).toContain("text-slate-900");
+4 -5
View File
@@ -38,7 +38,7 @@ describe("ColorPickerField - 테마", () => {
/>,
);
const [label] = screen.getAllByText("색상 팔레트");
const [label] = screen.getAllByText("Color palette");
const button = label.closest("button") as HTMLButtonElement | null;
expect(button).not.toBeNull();
@@ -65,14 +65,13 @@ describe("ColorPickerField - 테마", () => {
);
// 드롭다운을 연다
const [label] = screen.getAllByText("색상 팔레트");
const [label] = screen.getAllByText("Color palette");
const toggleButton = label.closest("button") as HTMLButtonElement | null;
expect(toggleButton).not.toBeNull();
fireEvent.click(toggleButton!);
// 팔레트 첫 항목 버튼을 찾는다 (TEXT_COLOR_PALETTE[0] 라벨과 동기화)
const firstItem = TEXT_COLOR_PALETTE[0];
const itemButton = screen.getByRole("button", { name: firstItem.label }) as HTMLButtonElement;
// 팔레트 첫 항목 버튼(기본값: None)을 role/name 기준으로 찾는다
const itemButton = screen.getByRole("button", { name: "None" }) as HTMLButtonElement;
const className = itemButton.getAttribute("class") ?? "";
// 라이트 모드: 흰 배경 + 어두운 텍스트
+51 -4
View File
@@ -42,9 +42,9 @@ describe("DashboardPage - GNB와 테마", () => {
render(<DashboardPage />);
const dashboardLink = await screen.findByRole("link", { name: "대시보드" });
const projectsLink = await screen.findByRole("link", { name: "프로젝트 목록" });
const submissionsLink = await screen.findByRole("link", { name: "전체 제출 내역" });
const dashboardLink = await screen.findByRole("link", { name: "Dashboard" });
const projectsLink = await screen.findByRole("link", { name: "Projects" });
const submissionsLink = await screen.findByRole("link", { name: "All submissions" });
expect(dashboardLink.getAttribute("href")).toBe("/dashboard");
expect(dashboardLink.getAttribute("aria-current")).toBe("page");
@@ -79,7 +79,7 @@ describe("DashboardPage - GNB와 테마", () => {
const html = document.documentElement;
html.classList.remove("dark");
const themeToggleButton = await screen.findByRole("button", { name: "테마 전환" });
const themeToggleButton = await screen.findByRole("button", { name: "Toggle theme" });
expect(html.classList.contains("dark")).toBe(false);
@@ -91,4 +91,51 @@ describe("DashboardPage - GNB와 테마", () => {
expect(html.classList.contains("dark")).toBe(false);
});
it("대시보드 요약 카드와 프로젝트 카드가 라이트/다크 테마에서 읽기 쉬운 텍스트 색상 클래스를 사용해야 한다", async () => {
const overview = {
summaryStats: {
totalProjects: 1,
totalSubmissions: 10,
todaySubmissions: 2,
last7DaysSubmissions: 5,
},
projectStats: [
{
projectId: "p1",
title: "Test project 1",
slug: "test-project-1",
status: "DRAFT",
totalSubmissions: 3,
lastSubmissionAt: "2025-01-01T00:00:00.000Z",
},
],
dailySubmissions: [],
};
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify(overview), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);
vi.stubGlobal("fetch", fetchMock as any);
const { container } = render(<DashboardPage />);
// 요약 카드가 렌더링될 때까지 기다린다.
await screen.findByTestId("dashboard-summary-total-projects");
const summaryCard = container.querySelector(
'[data-testid="dashboard-summary-total-projects"]',
) as HTMLElement | null;
expect(summaryCard).not.toBeNull();
const summaryLabel = summaryCard!.querySelector("span") as HTMLElement | null;
expect(summaryLabel).not.toBeNull();
const summaryLabelClass = summaryLabel!.className;
expect(summaryLabelClass).toContain("text-slate-500");
expect(summaryLabelClass).toContain("dark:text-slate-400");
});
});
+9 -9
View File
@@ -32,7 +32,7 @@ describe("DividerPropertiesPanel", () => {
/>,
);
const select = screen.getByLabelText("구분선 정렬");
const select = screen.getByLabelText("Divider alignment");
fireEvent.change(select, { target: { value: "center" } });
expect(updateBlock).toHaveBeenCalledWith(
@@ -52,7 +52,7 @@ describe("DividerPropertiesPanel", () => {
/>,
);
const select = screen.getByLabelText("구분선 두께");
const select = screen.getByLabelText("Divider thickness");
fireEvent.change(select, { target: { value: "medium" } });
expect(updateBlock).toHaveBeenCalledWith(
@@ -72,7 +72,7 @@ describe("DividerPropertiesPanel", () => {
/>,
);
const select = screen.getByLabelText("구분선 길이 모드");
const select = screen.getByLabelText("Divider length mode");
fireEvent.change(select, { target: { value: "fixed" } });
expect(updateBlock).toHaveBeenCalledWith(
@@ -92,7 +92,7 @@ describe("DividerPropertiesPanel", () => {
/>,
);
const widthInput = screen.getByLabelText("고정 길이 (px) 커스텀 (px)");
const widthInput = screen.getByLabelText("Fixed length (px) 커스텀 (px)");
fireEvent.change(widthInput, { target: { value: "480" } });
expect(updateBlock).toHaveBeenCalledWith(
@@ -112,7 +112,7 @@ describe("DividerPropertiesPanel", () => {
/>,
);
const hexInputs = screen.getAllByLabelText("구분선 색상 HEX");
const hexInputs = screen.getAllByLabelText("Divider color HEX");
const hexInput = hexInputs[0] as HTMLInputElement;
fireEvent.change(hexInput, { target: { value: "#123456" } });
@@ -133,7 +133,7 @@ describe("DividerPropertiesPanel", () => {
/>,
);
const marginInputs = screen.getAllByLabelText("위/아래 여백 커스텀 (px)");
const marginInputs = screen.getAllByLabelText("Vertical margin 커스텀 (px)");
const marginInput = marginInputs[0] as HTMLInputElement;
fireEvent.change(marginInput, { target: { value: "24" } });
@@ -155,7 +155,7 @@ describe("DividerPropertiesPanel", () => {
/>,
);
const alignSelect = screen.getByLabelText("구분선 정렬") as HTMLSelectElement;
const alignSelect = screen.getByLabelText("Divider alignment") as HTMLSelectElement;
const alignClass = alignSelect.getAttribute("class") ?? "";
expect(alignClass).toContain("bg-white");
expect(alignClass).toContain("text-slate-900");
@@ -164,7 +164,7 @@ describe("DividerPropertiesPanel", () => {
expect(alignClass).toContain("dark:text-slate-100");
expect(alignClass).toContain("dark:border-slate-700");
const thicknessSelect = screen.getByLabelText("구분선 두께") as HTMLSelectElement;
const thicknessSelect = screen.getByLabelText("Divider thickness") as HTMLSelectElement;
const thicknessClass = thicknessSelect.getAttribute("class") ?? "";
expect(thicknessClass).toContain("bg-white");
expect(thicknessClass).toContain("text-slate-900");
@@ -173,7 +173,7 @@ describe("DividerPropertiesPanel", () => {
expect(thicknessClass).toContain("dark:text-slate-100");
expect(thicknessClass).toContain("dark:border-slate-700");
const widthModeSelect = screen.getByLabelText("구분선 길이 모드") as HTMLSelectElement;
const widthModeSelect = screen.getByLabelText("Divider length mode") as HTMLSelectElement;
const widthModeClass = widthModeSelect.getAttribute("class") ?? "";
expect(widthModeClass).toContain("bg-white");
expect(widthModeClass).toContain("text-slate-900");
+58 -4
View File
@@ -2,6 +2,8 @@ import { describe, it, expect, afterEach } from "vitest";
import { render, screen, cleanup } from "@testing-library/react";
import type { Block, ProjectConfig } from "@/features/editor/state/editorStore";
import { EditorCanvas, EditorCanvasDragPreview } from "@/app/editor/EditorCanvas";
import { LocaleProvider } from "@/features/i18n/LocaleProvider";
import { DEFAULT_LOCALE } from "@/features/i18n/locale";
// 에디터 캔버스/페이지 배경색 적용 규칙에 대한 최소 TDD
// - canvasBgColorHex: 실제 캔버스 영역(editor-canvas-inner)의 배경색
@@ -37,15 +39,38 @@ describe("EditorCanvas - 캔버스/페이지 배경색", () => {
};
it("canvasBgColorHex 는 editor-canvas-inner 배경색에 적용되어야 한다", () => {
render(<EditorCanvas {...baseProps} />);
render(
<LocaleProvider initialLocale={DEFAULT_LOCALE}>
<EditorCanvas {...baseProps} />
</LocaleProvider>,
);
const inner = screen.getByTestId("editor-canvas-inner") as HTMLElement;
expect(inner.style.backgroundColor).toBe("rgb(17, 17, 17)");
});
it("blocks 가 없을 때 en 로케일에서는 영어 안내 문구를 표시해야 한다", () => {
render(
<LocaleProvider initialLocale={DEFAULT_LOCALE}>
<EditorCanvas {...baseProps} />
</LocaleProvider>,
);
const hint = screen.getByText(
'Click the "Text" button on the left to add your first block.',
);
expect(hint.textContent).toBe(
'Click the "Text" button on the left to add your first block.',
);
});
it("bodyBgColorHex 는 editor-canvas 바깥 래퍼(editor-canvas)의 배경색에 적용되어야 한다", () => {
const { container } = render(<EditorCanvas {...baseProps} />);
const { container } = render(
<LocaleProvider initialLocale={DEFAULT_LOCALE}>
<EditorCanvas {...baseProps} />
</LocaleProvider>,
);
const outer = container.querySelector('[data-testid="editor-canvas"]') as HTMLElement | null;
@@ -56,7 +81,11 @@ describe("EditorCanvas - 캔버스/페이지 배경색", () => {
it("canvasPreset / canvasWidthPx 에 따라 editor-canvas-inner 의 maxWidth 가 설정되어야 한다", () => {
const renderWithConfig = (partial: Partial<ProjectConfig>) => {
const projectConfig = { ...baseProjectConfig, ...partial } as ProjectConfig;
const { getByTestId, unmount } = render(<EditorCanvas {...baseProps} projectConfig={projectConfig} />);
const { getByTestId, unmount } = render(
<LocaleProvider initialLocale={DEFAULT_LOCALE}>
<EditorCanvas {...baseProps} projectConfig={projectConfig} />
</LocaleProvider>,
);
const inner = getByTestId("editor-canvas-inner") as HTMLElement;
const maxWidth = inner.style.maxWidth;
unmount();
@@ -85,7 +114,11 @@ describe("EditorCanvas - 캔버스/페이지 배경색", () => {
},
} as any;
const { container } = render(<EditorCanvasDragPreview block={block} />);
const { container } = render(
<LocaleProvider initialLocale={DEFAULT_LOCALE}>
<EditorCanvasDragPreview block={block} />
</LocaleProvider>,
);
const overlay = container.querySelector(
"div.pointer-events-none.rounded.border",
@@ -99,4 +132,25 @@ describe("EditorCanvas - 캔버스/페이지 배경색", () => {
expect(className).toContain("dark:bg-slate-900");
expect(className).toContain("dark:text-slate-100");
});
it("en 로케일에서는 EditorCanvasDragPreview 의 텍스트 블록 fallback 라벨이 영어로 표시되어야 한다", () => {
const block: Block = {
id: "text_fallback_1",
type: "text",
props: {
text: "",
align: "left",
size: "base",
},
} as any;
render(
<LocaleProvider initialLocale={DEFAULT_LOCALE}>
<EditorCanvasDragPreview block={block} />
</LocaleProvider>,
);
const fallbackLabel = screen.getByText("Text block");
expect(fallbackLabel.textContent).toBe("Text block");
});
});
+3 -3
View File
@@ -102,20 +102,20 @@ describe("EditorPage - 상단 메뉴 (Export 미리보기 제거)", () => {
it("메뉴를 열었을 때 'Export 미리보기' 항목은 보이지 않고, '프로젝트 목록' 항목이 노출되어야 한다", () => {
render(<EditorPage />);
const menuButton = screen.getByText("메뉴");
const menuButton = screen.getByText("Menu");
fireEvent.click(menuButton);
const removedItem = screen.queryByText("Export 미리보기");
expect(removedItem).toBeNull();
const projectListItem = screen.getByText("프로젝트 목록");
const projectListItem = screen.getByText("Projects");
expect(projectListItem).toBeTruthy();
});
it("헤더의 '프리뷰 열기' 링크는 현재 프로젝트 slug 를 쿼리로 포함한 /preview?slug=... 형식이어야 한다", () => {
render(<EditorPage />);
const previewLink = screen.getByRole("link", { name: "프리뷰 열기" });
const previewLink = screen.getByRole("link", { name: "Open preview" });
expect(previewLink).toBeTruthy();
expect(previewLink.getAttribute("href")).toBe("/preview?slug=export-preview-test");
});
+8 -8
View File
@@ -103,10 +103,10 @@ vi.mock("next/navigation", () => {
});
function openJsonModal() {
const menuButton = screen.getByText("메뉴");
const menuButton = screen.getByText("Menu");
fireEvent.click(menuButton);
const jsonMenuItem = screen.getByText("JSON 내보내기/불러오기");
const jsonMenuItem = screen.getByText("JSON export / import");
fireEvent.click(jsonMenuItem);
const modalTitle = screen.getByText("JSON Export / Import");
@@ -120,13 +120,13 @@ describe("EditorPage - JSON 내보내기/불러오기", () => {
const jsonModal = openJsonModal();
const exportLabel = within(jsonModal).getByText("에디터 상태 JSON").closest("label") as HTMLElement;
const exportLabel = within(jsonModal).getByText("Editor state JSON").closest("label") as HTMLElement;
const exportTextarea = within(exportLabel).getByRole("textbox") as HTMLTextAreaElement;
// 초기에는 비어 있어야 한다.
expect(exportTextarea.value).toBe("");
const exportButton = within(jsonModal).getByText("JSON 내보내기");
const exportButton = within(jsonModal).getByText("Export JSON");
fireEvent.click(exportButton);
const value = exportTextarea.value;
@@ -143,7 +143,7 @@ describe("EditorPage - JSON 내보내기/불러오기", () => {
const jsonModal = openJsonModal();
const importLabel = within(jsonModal).getByText("JSON에서 불러오기").closest("label") as HTMLElement;
const importLabel = within(jsonModal).getByText("Import from JSON").closest("label") as HTMLElement;
const importTextarea = within(importLabel).getByRole("textbox") as HTMLTextAreaElement;
const importedBlocks: Block[] = [
@@ -175,7 +175,7 @@ describe("EditorPage - JSON 내보내기/불러오기", () => {
mockState.replaceBlocks.mockReset();
mockState.updateProjectConfig.mockReset();
const applyButton = within(importLabel).getByText("JSON 적용하기");
const applyButton = within(importLabel).getByText("Apply JSON");
fireEvent.click(applyButton);
expect(mockState.replaceBlocks).toHaveBeenCalledTimes(1);
@@ -190,7 +190,7 @@ describe("EditorPage - JSON 내보내기/불러오기", () => {
const jsonModal = openJsonModal();
const importLabel = within(jsonModal).getByText("JSON에서 불러오기").closest("label") as HTMLElement;
const importLabel = within(jsonModal).getByText("Import from JSON").closest("label") as HTMLElement;
const importTextarea = within(importLabel).getByRole("textbox") as HTMLTextAreaElement;
fireEvent.change(importTextarea, { target: { value: "{ this-is: not-valid-json" } });
@@ -198,7 +198,7 @@ describe("EditorPage - JSON 내보내기/불러오기", () => {
mockState.replaceBlocks.mockReset();
mockState.updateProjectConfig.mockReset();
const applyButton = within(importLabel).getByText("JSON 적용하기");
const applyButton = within(importLabel).getByText("Apply JSON");
fireEvent.click(applyButton);
expect(mockState.replaceBlocks).not.toHaveBeenCalled();
+1 -1
View File
@@ -90,7 +90,7 @@ describe("EditorPage - 레이아웃 스크롤 컨테이너", () => {
it("좌측 사이드바, 캔버스, 우측 속성 패널에 pb-scroll 클래스가 적용되어야 한다", () => {
render(<EditorPage />);
const blocksHeading = screen.getByText("블록");
const blocksHeading = screen.getByText("Blocks");
const blocksSidebar = blocksHeading.closest("aside") as HTMLElement;
const canvas = screen.getByTestId("editor-canvas");
const propertiesSidebar = screen.getByTestId("properties-sidebar");
+7 -7
View File
@@ -114,13 +114,13 @@ describe("EditorPage - 페이지/캔버스 배경", () => {
it("프로젝트 저장/불러오기 모달 카드는 라이트/다크 테마에 맞는 카드/인풋 크롬을 사용해야 한다", () => {
render(<EditorPage />);
const menuButton = screen.getByRole("button", { name: /메뉴/ });
const menuButton = screen.getByRole("button", { name: /Menu/ });
fireEvent.click(menuButton);
const projectMenuItem = screen.getByRole("button", { name: "프로젝트 저장/불러오기" });
const projectMenuItem = screen.getByRole("button", { name: "Save / load project" });
fireEvent.click(projectMenuItem);
const heading = screen.getByText("프로젝트 저장 / 불러오기");
const heading = screen.getByText("Save / load project");
const headerDiv = heading.closest("div") as HTMLDivElement | null;
expect(headerDiv).not.toBeNull();
@@ -136,7 +136,7 @@ describe("EditorPage - 페이지/캔버스 배경", () => {
expect(cardClass).toContain("dark:border-slate-700");
const titleSpan = Array.from(card!.querySelectorAll("span")).find(
(el) => el.textContent === "프로젝트 제목",
(el) => el.textContent === "Project title",
);
expect(titleSpan).not.toBeUndefined();
const titleWrapper = (titleSpan as HTMLSpanElement).parentElement as HTMLElement | null;
@@ -157,10 +157,10 @@ describe("EditorPage - 페이지/캔버스 배경", () => {
it("JSON Export / Import 모달 카드는 라이트/다크 테마에 맞는 카드/textarea 크롬을 사용해야 한다", () => {
render(<EditorPage />);
const menuButton = screen.getByRole("button", { name: /메뉴/ });
const menuButton = screen.getByRole("button", { name: /Menu/ });
fireEvent.click(menuButton);
const jsonMenuItem = screen.getByRole("button", { name: "JSON 내보내기/불러오기" });
const jsonMenuItem = screen.getByRole("button", { name: "JSON export / import" });
fireEvent.click(jsonMenuItem);
const heading = screen.getByText(/JSON Export \/ Import/);
@@ -178,7 +178,7 @@ describe("EditorPage - 페이지/캔버스 배경", () => {
expect(cardClass).toContain("dark:text-slate-100");
expect(cardClass).toContain("dark:border-slate-700");
const jsonLabel = screen.getByText("에디터 상태 JSON");
const jsonLabel = screen.getByText("Editor state JSON");
const jsonWrapper = jsonLabel.parentElement as HTMLElement | null;
expect(jsonWrapper).not.toBeNull();
+27 -27
View File
@@ -126,17 +126,17 @@ describe("EditorPage - 프로젝트 저장/불러오기", () => {
const { rerender } = render(<EditorPage />);
const menuButton = screen.getByText("메뉴");
const menuButton = screen.getByText("Menu");
fireEvent.click(menuButton);
const projectMenuItem = screen.getByText("프로젝트 저장/불러오기");
const projectMenuItem = screen.getByText("Save / load project");
fireEvent.click(projectMenuItem);
const modalTitle = screen.getByText("프로젝트 저장 / 불러오기");
const modalTitle = screen.getByText("Save / load project");
const projectModal = (modalTitle.parentElement?.parentElement ?? document.body) as HTMLElement;
const titleInput = within(projectModal).getByLabelText("프로젝트 제목") as HTMLInputElement;
const slugInput = within(projectModal).getByLabelText("프로젝트 주소 (예: my-landing)") as HTMLInputElement;
const titleInput = within(projectModal).getByLabelText("Project title") as HTMLInputElement;
const slugInput = within(projectModal).getByLabelText("Project address (e.g. my-landing)") as HTMLInputElement;
expect(titleInput.value).toBe("프로젝트 저장/불러오기 테스트");
expect(slugInput.value).toBe("editor-project-test");
@@ -149,7 +149,7 @@ describe("EditorPage - 프로젝트 저장/불러오기", () => {
rerender(<EditorPage />);
const saveButton = screen.getByText("저장 (로컬 + 서버)");
const saveButton = screen.getByText("Save (local + server)");
fireEvent.click(saveButton);
await waitFor(() => {
@@ -184,7 +184,7 @@ describe("EditorPage - 프로젝트 저장/불러오기", () => {
expect(Array.isArray(body.contentJson)).toBe(true);
expect(body.contentJson[0].id).toBe("blk_1");
const message = await screen.findByText("프로젝트가 저장되었습니다: save-test-slug");
const message = await screen.findByText("Project saved: save-test-slug");
expect(message).toBeTruthy();
});
@@ -213,24 +213,24 @@ describe("EditorPage - 프로젝트 저장/불러오기", () => {
const { rerender } = render(<EditorPage />);
const menuButton = screen.getByText("메뉴");
const menuButton = screen.getByText("Menu");
fireEvent.click(menuButton);
const projectMenuItem = screen.getByText("프로젝트 저장/불러오기");
const projectMenuItem = screen.getByText("Save / load project");
fireEvent.click(projectMenuItem);
const modalTitle = screen.getByText("프로젝트 저장 / 불러오기");
const modalTitle = screen.getByText("Save / load project");
const projectModal = (modalTitle.parentElement?.parentElement ?? document.body) as HTMLElement;
const titleInput = within(projectModal).getByLabelText("프로젝트 제목") as HTMLInputElement;
const slugInput = within(projectModal).getByLabelText("프로젝트 주소 (예: my-landing)") as HTMLInputElement;
const titleInput = within(projectModal).getByLabelText("Project title") as HTMLInputElement;
const slugInput = within(projectModal).getByLabelText("Project address (e.g. my-landing)") as HTMLInputElement;
fireEvent.change(titleInput, { target: { value: "충돌 테스트 프로젝트" } });
fireEvent.change(slugInput, { target: { value: "conflict-slug" } });
rerender(<EditorPage />);
const saveButton = screen.getByText("저장 (로컬 + 서버)");
const saveButton = screen.getByText("Save (local + server)");
fireEvent.click(saveButton);
const message = await screen.findByText("이미 다른 사용자가 사용 중인 프로젝트 주소입니다.");
@@ -268,16 +268,16 @@ describe("EditorPage - 프로젝트 저장/불러오기", () => {
render(<EditorPage />);
const menuButton = screen.getByText("메뉴");
const menuButton = screen.getByText("Menu");
fireEvent.click(menuButton);
const projectMenuItem = screen.getByText("프로젝트 저장/불러오기");
const projectMenuItem = screen.getByText("Save / load project");
fireEvent.click(projectMenuItem);
const modalTitle = screen.getByText("프로젝트 저장 / 불러오기");
const modalTitle = screen.getByText("Save / load project");
const projectModal = (modalTitle.parentElement?.parentElement ?? document.body) as HTMLElement;
const loadButton = screen.getByText("불러오기");
const loadButton = screen.getByText("Load");
fireEvent.click(loadButton);
await waitFor(() => {
@@ -297,7 +297,7 @@ describe("EditorPage - 프로젝트 저장/불러오기", () => {
);
expect(serverCalls.length).toBe(0);
const message = await screen.findByText(`로컬에서 프로젝트를 불러왔습니다: ${slug}`);
const message = await screen.findByText(`Loaded project from local: ${slug}`);
expect(message).toBeTruthy();
});
@@ -328,13 +328,13 @@ describe("EditorPage - 프로젝트 저장/불러오기", () => {
render(<EditorPage />);
const menuButton = screen.getByText("메뉴");
const menuButton = screen.getByText("Menu");
fireEvent.click(menuButton);
const projectMenuItem = screen.getByText("프로젝트 저장/불러오기");
const projectMenuItem = screen.getByText("Save / load project");
fireEvent.click(projectMenuItem);
const loadButton = screen.getByText("불러오기");
const loadButton = screen.getByText("Load");
fireEvent.click(loadButton);
await waitFor(() => {
@@ -362,7 +362,7 @@ describe("EditorPage - 프로젝트 저장/불러오기", () => {
),
).toBe(true);
const message = await screen.findByText(`프로젝트를 불러왔습니다: ${slug}`);
const message = await screen.findByText(`Loaded project from server: ${slug}`);
expect(message).toBeTruthy();
});
@@ -472,13 +472,13 @@ describe("EditorPage - 프로젝트 저장/불러오기", () => {
render(<EditorPage />);
const menuButton = screen.getByText("메뉴");
const menuButton = screen.getByText("Menu");
fireEvent.click(menuButton);
const projectMenuItem = screen.getByText("프로젝트 저장/불러오기");
const projectMenuItem = screen.getByText("Save / load project");
fireEvent.click(projectMenuItem);
const loadButton = screen.getByText("불러오기");
const loadButton = screen.getByText("Load");
fireEvent.click(loadButton);
await waitFor(() => {
@@ -514,10 +514,10 @@ describe("EditorPage - 프로젝트 저장/불러오기", () => {
render(<EditorPage />);
const menuButton = screen.getByText("메뉴");
const menuButton = screen.getByText("Menu");
fireEvent.click(menuButton);
const deleteMenuItem = screen.getByText("프로젝트 삭제");
const deleteMenuItem = screen.getByText("Delete project");
fireEvent.click(deleteMenuItem);
await waitFor(() => {
+8 -1
View File
@@ -3,6 +3,8 @@ import { render, screen, cleanup } from "@testing-library/react";
import type { Block, ProjectConfig } from "@/features/editor/state/editorStore";
import EditorPage from "@/app/editor/page";
import { createHeroTemplateBlocks } from "@/app/editor/templates/heroTemplate";
import { getEditorTemplatesMessages } from "@/features/i18n/messages/editorTemplates";
import { DEFAULT_LOCALE } from "@/features/i18n/locale";
let mockState: any;
@@ -191,7 +193,12 @@ describe("EditorPage - 섹션 레이아웃 속성", () => {
return () => `hero_${++i}`;
})();
const { blocks } = createHeroTemplateBlocks({ sectionId, createId });
const tpl = getEditorTemplatesMessages(DEFAULT_LOCALE);
const { blocks } = createHeroTemplateBlocks({
sectionId,
createId,
messages: tpl.hero,
});
mockState.blocks = blocks as Block[];
mockState.selectedBlockId = sectionId;
+10 -39
View File
@@ -107,8 +107,8 @@ vi.mock("next/navigation", () => {
};
});
describe("EditorPage - 서버 자동 저장", () => {
it("로그인된 사용자가 새 페이지를 편집하면 자동 생성된 slug 로 /api/projects 에 주기적으로 저장해야 한다", async () => {
describe("EditorPage - 서버 자동 저장 비활성화", () => {
it("로그인된 사용자가 새 페이지를 편집하더라도 서버(/api/projects) 자동저장을 호출하지 않아야 한다", async () => {
const fetchMock = vi.fn().mockImplementation((input: any, init?: any) => {
const url = typeof input === "string" ? input : input.url;
const method = init?.method ?? "GET";
@@ -152,28 +152,14 @@ describe("EditorPage - 서버 자동 저장", () => {
).toBe(true);
});
await waitFor(() => {
expect(
fetchMock.mock.calls.some(
([url, options]: any[]) => url === "/api/projects" && (options?.method ?? "GET") === "POST",
),
).toBe(true);
});
const projectCall = fetchMock.mock.calls.find(
const projectCalls = fetchMock.mock.calls.filter(
([url, options]: any[]) => url === "/api/projects" && (options?.method ?? "GET") === "POST",
) as any;
);
const [, options] = projectCall;
const body = JSON.parse((options.body as string) ?? "{}");
expect(typeof body.slug).toBe("string");
expect(body.slug).not.toBe("my-landing");
expect(body.slug.startsWith("page-")).toBe(false);
expect(mockState.projectConfig.slug).toBe(body.slug);
expect(projectCalls.length).toBe(0);
});
it("authUserId 가 없으면 /api/projects 자동 저장을 호출하지 않아야 한다", async () => {
it("authUserId 가 없을 때는 물론, 서버 자동저장은 어떤 경우에도 /api/projects 호출하지 않아야 한다", async () => {
const fetchMock = vi.fn().mockImplementation((input: any, init?: any) => {
const url = typeof input === "string" ? input : input.url;
const method = init?.method ?? "GET";
@@ -204,7 +190,7 @@ describe("EditorPage - 서버 자동 저장", () => {
expect(projectCalls.length).toBe(0);
});
it("slug 쿼리 파라미터로 기존 프로젝트를 열었을 때는 해당 slug 로 서버 자동 저장을 야 한다", async () => {
it("slug 쿼리 파라미터로 기존 프로젝트를 열었을 때 서버 자동저장을 호출하지 않아야 한다", async () => {
searchParamsSlug = "existing-slug";
mockState.projectConfig.slug = "my-landing" as any;
@@ -268,25 +254,10 @@ describe("EditorPage - 서버 자동 저장", () => {
).toBe(true);
});
await waitFor(() => {
expect(
fetchMock.mock.calls.some(
([url, options]: any[]) =>
url === "/api/projects" && (options?.method ?? "GET") === "POST",
),
).toBe(true);
});
const projectCall = fetchMock.mock.calls.find(
const projectCalls = fetchMock.mock.calls.filter(
([url, options]: any[]) => url === "/api/projects" && (options?.method ?? "GET") === "POST",
) as any;
);
expect(projectCall).toBeTruthy();
const [, options] = projectCall;
const body = JSON.parse((options.body as string) ?? "{}");
expect(body.slug).toBe("existing-slug");
expect(mockState.projectConfig.slug).toBe("existing-slug");
expect(projectCalls.length).toBe(0);
});
});
+19 -21
View File
@@ -42,7 +42,7 @@ describe("FormControllerPanel", () => {
/>,
);
const input = screen.getByLabelText("성공 메시지");
const input = screen.getByLabelText("Success message");
fireEvent.change(input, { target: { value: "새 성공 메시지" } });
expect(updateBlock).toHaveBeenCalledWith(
@@ -67,7 +67,7 @@ describe("FormControllerPanel", () => {
/>,
);
const input = screen.getByLabelText("에러 메시지");
const input = screen.getByLabelText("Error message");
fireEvent.change(input, { target: { value: "새 에러 메시지" } });
expect(updateBlock).toHaveBeenCalledWith(
@@ -143,7 +143,7 @@ describe("FormControllerPanel", () => {
/>,
);
const select = screen.getByLabelText("Submit 버튼") as HTMLSelectElement;
const select = screen.getByLabelText("Submit button") as HTMLSelectElement;
const submitMainOption = screen.getByText("submit-main (제출하기)");
@@ -168,17 +168,15 @@ describe("FormControllerPanel", () => {
/>,
);
const guideButton = screen.getByRole("button", { name: "Google Sheets 연동 가이드" });
const guideButton = screen.getByRole("button", { name: "Google Sheets integration guide" });
guideButton.click();
// Google Sheets 연동 가이드 헤딩과 Apps Script 안내 문구가 노출되어야 한다.
expect(screen.getByText("Google Sheets 연동 가이드")).toBeTruthy();
expect(
screen.getByText(/Apps Script 웹 앱 URL/i),
).toBeTruthy();
// Google Sheets integration guide heading and Apps Script web app URL hint should be visible.
expect(screen.getByText("Google Sheets integration guide")).toBeTruthy();
expect(screen.getByText(/Apps Script web app URL/i)).toBeTruthy();
});
it("Google Sheets 가이드 모달의 Apps Script 코드가 컨트롤러에 매핑된 전송 키와 작성일시를 포함해야 한다", async () => {
it("Google Sheets guide modal Apps Script code should include mapped submit keys and createdAt field", async () => {
const formBlock = makeFormBlock("form-webhook", {
submitTarget: "webhook",
fieldIds: ["input-name", "input-email"],
@@ -213,7 +211,7 @@ describe("FormControllerPanel", () => {
/>,
);
const guideButton = screen.getByRole("button", { name: "Google Sheets 연동 가이드" });
const guideButton = screen.getByRole("button", { name: "Google Sheets integration guide" });
fireEvent.click(guideButton);
const scriptTextarea = (await screen.findByDisplayValue(/function doPost/)) as HTMLTextAreaElement;
@@ -239,7 +237,7 @@ describe("FormControllerPanel", () => {
/>,
);
const submitTargetSelect = screen.getByLabelText("전송 대상") as HTMLSelectElement;
const submitTargetSelect = screen.getByLabelText("Submit target") as HTMLSelectElement;
const submitTargetClass = submitTargetSelect.getAttribute("class") ?? "";
expect(submitTargetClass).toContain("bg-white");
expect(submitTargetClass).toContain("text-slate-900");
@@ -248,7 +246,7 @@ describe("FormControllerPanel", () => {
expect(submitTargetClass).toContain("dark:text-slate-100");
expect(submitTargetClass).toContain("dark:border-slate-700");
const formWidthModeSelect = screen.getByLabelText("폼 너비 모드") as HTMLSelectElement;
const formWidthModeSelect = screen.getByLabelText("Form width mode") as HTMLSelectElement;
const formWidthModeClass = formWidthModeSelect.getAttribute("class") ?? "";
expect(formWidthModeClass).toContain("bg-white");
expect(formWidthModeClass).toContain("text-slate-900");
@@ -257,7 +255,7 @@ describe("FormControllerPanel", () => {
expect(formWidthModeClass).toContain("dark:text-slate-100");
expect(formWidthModeClass).toContain("dark:border-slate-700");
const successInput = screen.getByLabelText("성공 메시지") as HTMLInputElement;
const successInput = screen.getByLabelText("Success message") as HTMLInputElement;
const successClass = successInput.getAttribute("class") ?? "";
expect(successClass).toContain("bg-white");
expect(successClass).toContain("text-slate-900");
@@ -266,7 +264,7 @@ describe("FormControllerPanel", () => {
expect(successClass).toContain("dark:text-slate-100");
expect(successClass).toContain("dark:border-slate-700");
const errorInput = screen.getByLabelText("에러 메시지") as HTMLInputElement;
const errorInput = screen.getByLabelText("Error message") as HTMLInputElement;
const errorClass = errorInput.getAttribute("class") ?? "";
expect(errorClass).toContain("bg-white");
expect(errorClass).toContain("text-slate-900");
@@ -275,7 +273,7 @@ describe("FormControllerPanel", () => {
expect(errorClass).toContain("dark:text-slate-100");
expect(errorClass).toContain("dark:border-slate-700");
const extraParamsTextarea = screen.getByLabelText(/추가 파라미터/) as HTMLTextAreaElement;
const extraParamsTextarea = screen.getByLabelText(/Additional parameters/) as HTMLTextAreaElement;
const extraParamsClass = extraParamsTextarea.getAttribute("class") ?? "";
expect(extraParamsClass).toContain("bg-white");
expect(extraParamsClass).toContain("text-slate-900");
@@ -285,7 +283,7 @@ describe("FormControllerPanel", () => {
expect(extraParamsClass).toContain("dark:border-slate-700");
});
it("Google Sheets 연동 가이드 모달 카드와 textarea 는 라이트/다크 듀얼 테마 크롬을 사용해야 한다", () => {
it("Google Sheets integration guide modal card and textarea should use dual light/dark theme chrome", () => {
const formBlock = makeFormBlock("form-webhook-theme", {
submitTarget: "webhook",
} as any);
@@ -301,10 +299,10 @@ describe("FormControllerPanel", () => {
/>,
);
const guideButton = screen.getByRole("button", { name: "Google Sheets 연동 가이드" });
const guideButton = screen.getByRole("button", { name: "Google Sheets integration guide" });
fireEvent.click(guideButton);
const heading = screen.getByRole("heading", { name: "Google Sheets 연동 가이드" });
const heading = screen.getByRole("heading", { name: "Google Sheets integration guide" });
const card = heading.closest("div")?.parentElement as HTMLDivElement | null;
expect(card).not.toBeNull();
@@ -352,7 +350,7 @@ describe("FormControllerPanel", () => {
/>,
);
const fieldset = screen.getByRole("group", { name: "폼 필드 매핑" });
const fieldset = screen.getByRole("group", { name: "Form field mapping" });
const checkboxes = within(fieldset).getAllByRole("checkbox");
const includeCheckbox = checkboxes[0] as HTMLInputElement;
@@ -391,7 +389,7 @@ describe("FormControllerPanel", () => {
/>,
);
const select = screen.getByLabelText("Submit 버튼") as HTMLSelectElement;
const select = screen.getByLabelText("Submit button") as HTMLSelectElement;
const className = select.getAttribute("class") ?? "";
expect(className).toContain("bg-white");
+76 -76
View File
@@ -45,7 +45,7 @@ describe("FormInputPropertiesPanel", () => {
/>,
);
const hexInput = screen.getByLabelText("필드 텍스트 색상 HEX");
const hexInput = screen.getByLabelText("Field text color HEX");
fireEvent.change(hexInput, { target: { value: "#112233" } });
expect(updateBlock).toHaveBeenCalledWith(
@@ -70,7 +70,7 @@ describe("FormInputPropertiesPanel", () => {
/>,
);
const hexInput = screen.getByLabelText("필드 채움 색상 HEX");
const hexInput = screen.getByLabelText("Field fill color HEX");
fireEvent.change(hexInput, { target: { value: "#445566" } });
expect(updateBlock).toHaveBeenCalledWith(
@@ -95,7 +95,7 @@ describe("FormInputPropertiesPanel", () => {
/>,
);
const hexInput = screen.getByLabelText("필드 테두리 색상 HEX");
const hexInput = screen.getByLabelText("Field border color HEX");
fireEvent.change(hexInput, { target: { value: "#778899" } });
expect(updateBlock).toHaveBeenCalledWith(
@@ -104,7 +104,7 @@ describe("FormInputPropertiesPanel", () => {
);
});
it("텍스트 색상 팔레트에서 \"없음\" 을 선택하면 FormInput textColorCustom 이 빈 문자열로 업데이트되어야 한다", () => {
it("텍스트 색상 팔레트에서 \"None\" 을 선택하면 FormInput textColorCustom 이 빈 문자열로 업데이트되어야 한다", () => {
const updateBlock = vi.fn();
const block = makeBlock<FormInputBlockProps>("f-input-none", "formInput", {
@@ -121,11 +121,11 @@ describe("FormInputPropertiesPanel", () => {
);
// 첫 번째 ColorPickerField(필드 텍스트 색상)의 팔레트 버튼을 연다.
const paletteButtons = screen.getAllByText("색상 팔레트");
const paletteButtons = screen.getAllByText("Color palette");
fireEvent.click(paletteButtons[0].closest("button") as HTMLButtonElement);
// 팔레트에서 "없음" 항목을 버튼 role 기준으로 선택한다.
const noneButtons = screen.getAllByRole("button", { name: "없음" });
// 팔레트에서 "None" 항목을 버튼 role 기준으로 선택한다.
const noneButtons = screen.getAllByRole("button", { name: "None" });
fireEvent.click(noneButtons[0]);
expect(updateBlock).toHaveBeenCalledWith(
@@ -150,7 +150,7 @@ describe("FormInputPropertiesPanel", () => {
/>,
);
const hexInput = screen.getByLabelText("필드 텍스트 색상 HEX") as HTMLInputElement;
const hexInput = screen.getByLabelText("Field text color HEX") as HTMLInputElement;
expect(hexInput.value).toBe("");
});
@@ -171,7 +171,7 @@ describe("FormInputPropertiesPanel", () => {
/>,
);
const select = screen.getByLabelText("너비");
const select = screen.getByLabelText("Width");
fireEvent.change(select, { target: { value: "fixed" } });
expect(updateBlock).toHaveBeenCalledWith(
@@ -197,7 +197,7 @@ describe("FormInputPropertiesPanel", () => {
/>,
);
const layoutSelect = screen.getByLabelText("레이아웃");
const layoutSelect = screen.getByLabelText("Layout");
expect(layoutSelect).toBeTruthy();
cleanup();
@@ -216,7 +216,7 @@ describe("FormInputPropertiesPanel", () => {
/>,
);
expect(screen.queryByLabelText("레이아웃")).toBeNull();
expect(screen.queryByLabelText("Layout")).toBeNull();
});
it("폼 입력 텍스트/줄간격/자간 px 컨트롤이 fontSizeCustom/lineHeightCustom/letterSpacingCustom 으로 저장되어야 한다", () => {
@@ -237,13 +237,13 @@ describe("FormInputPropertiesPanel", () => {
/>,
);
const fontSizeInput = screen.getByLabelText("필드 텍스트 크기 (px) 커스텀 (px)");
const fontSizeInput = screen.getByLabelText("Field text size (px) 커스텀 (px)");
fireEvent.change(fontSizeInput, { target: { value: "24" } });
const lineHeightInput = screen.getByLabelText("필드 줄간격 (px) 커스텀 (px)");
const lineHeightInput = screen.getByLabelText("Field line height (px) 커스텀 (px)");
fireEvent.change(lineHeightInput, { target: { value: "30" } });
const letterSpacingInput = screen.getByLabelText("필드 자간 (px) 커스텀 (px)");
const letterSpacingInput = screen.getByLabelText("Field letter spacing (px) 커스텀 (px)");
fireEvent.change(letterSpacingInput, { target: { value: "1.5" } });
expect(updateBlock).toHaveBeenCalledWith(
@@ -280,19 +280,19 @@ describe("FormInputPropertiesPanel", () => {
/>,
);
const alignSelect = screen.getByLabelText("텍스트 정렬");
const alignSelect = screen.getByLabelText("Text alignment");
fireEvent.change(alignSelect, { target: { value: "center" } });
const widthModeSelect = screen.getByLabelText("너비");
const widthModeSelect = screen.getByLabelText("Width");
fireEvent.change(widthModeSelect, { target: { value: "fixed" } });
const widthPxInput = screen.getByLabelText("필드 고정 너비 커스텀 (px)");
const widthPxInput = screen.getByLabelText("Field fixed width 커스텀 (px)");
fireEvent.change(widthPxInput, { target: { value: "320" } });
const paddingXInput = screen.getByLabelText("필드 가로 패딩 (px) 커스텀 (px)");
const paddingXInput = screen.getByLabelText("Field horizontal padding (px) 커스텀 (px)");
fireEvent.change(paddingXInput, { target: { value: "16" } });
const paddingYInput = screen.getByLabelText("필드 세로 패딩 (px) 커스텀 (px)");
const paddingYInput = screen.getByLabelText("Field vertical padding (px) 커스텀 (px)");
fireEvent.change(paddingYInput, { target: { value: "12" } });
expect(updateBlock).toHaveBeenCalledWith(
@@ -332,7 +332,7 @@ describe("FormInputPropertiesPanel", () => {
/>,
);
const labelTypeSelect = screen.getByLabelText("라벨 타입") as HTMLSelectElement;
const labelTypeSelect = screen.getByLabelText("Label type") as HTMLSelectElement;
const labelTypeClass = labelTypeSelect.getAttribute("class") ?? "";
expect(labelTypeClass).toContain("bg-white");
expect(labelTypeClass).toContain("text-slate-900");
@@ -341,7 +341,7 @@ describe("FormInputPropertiesPanel", () => {
expect(labelTypeClass).toContain("dark:text-slate-100");
expect(labelTypeClass).toContain("dark:border-slate-700");
const labelInput = screen.getByLabelText("필드 라벨") as HTMLInputElement;
const labelInput = screen.getByLabelText("Field label") as HTMLInputElement;
const labelInputClass = labelInput.getAttribute("class") ?? "";
expect(labelInputClass).toContain("bg-white");
expect(labelInputClass).toContain("text-slate-900");
@@ -350,7 +350,7 @@ describe("FormInputPropertiesPanel", () => {
expect(labelInputClass).toContain("dark:text-slate-100");
expect(labelInputClass).toContain("dark:border-slate-700");
const labelDisplaySelect = screen.getByLabelText("라벨 표시 방식") as HTMLSelectElement;
const labelDisplaySelect = screen.getByLabelText("Label display mode") as HTMLSelectElement;
const labelDisplayClass = labelDisplaySelect.getAttribute("class") ?? "";
expect(labelDisplayClass).toContain("bg-white");
expect(labelDisplayClass).toContain("text-slate-900");
@@ -359,7 +359,7 @@ describe("FormInputPropertiesPanel", () => {
expect(labelDisplayClass).toContain("dark:text-slate-100");
expect(labelDisplayClass).toContain("dark:border-slate-700");
const fieldTypeSelect = screen.getByLabelText("필드 타입") as HTMLSelectElement;
const fieldTypeSelect = screen.getByLabelText("Field type") as HTMLSelectElement;
const fieldTypeClass = fieldTypeSelect.getAttribute("class") ?? "";
expect(fieldTypeClass).toContain("bg-white");
expect(fieldTypeClass).toContain("text-slate-900");
@@ -406,7 +406,7 @@ describe("FormSelectPropertiesPanel", () => {
/>,
);
const hexInput = screen.getByLabelText("셀렉트 텍스트 색상 HEX");
const hexInput = screen.getByLabelText("Select text color HEX");
fireEvent.change(hexInput, { target: { value: "#112233" } });
expect(updateBlock).toHaveBeenCalledWith(
@@ -431,7 +431,7 @@ describe("FormSelectPropertiesPanel", () => {
/>,
);
const hexInput = screen.getByLabelText("셀렉트 채움 색상 HEX");
const hexInput = screen.getByLabelText("Select fill color HEX");
fireEvent.change(hexInput, { target: { value: "#445566" } });
expect(updateBlock).toHaveBeenCalledWith(
@@ -456,7 +456,7 @@ describe("FormSelectPropertiesPanel", () => {
/>,
);
const hexInput = screen.getByLabelText("셀렉트 테두리 색상 HEX");
const hexInput = screen.getByLabelText("Select border color HEX");
fireEvent.change(hexInput, { target: { value: "#778899" } });
expect(updateBlock).toHaveBeenCalledWith(
@@ -482,7 +482,7 @@ describe("FormSelectPropertiesPanel", () => {
/>,
);
const select = screen.getByLabelText("필드 너비");
const select = screen.getByLabelText("Field width");
fireEvent.change(select, { target: { value: "fixed" } });
expect(updateBlock).toHaveBeenCalledWith(
@@ -509,13 +509,13 @@ describe("FormSelectPropertiesPanel", () => {
/>,
);
const fontSizeInput = screen.getByLabelText("셀렉트 텍스트 크기 (px) 커스텀 (px)");
const fontSizeInput = screen.getByLabelText("Select text size (px) 커스텀 (px)");
fireEvent.change(fontSizeInput, { target: { value: "24" } });
const lineHeightInput = screen.getByLabelText("셀렉트 줄간격 (px) 커스텀 (px)");
const lineHeightInput = screen.getByLabelText("Select line height (px) 커스텀 (px)");
fireEvent.change(lineHeightInput, { target: { value: "30" } });
const letterSpacingInput = screen.getByLabelText("셀렉트 자간 (px) 커스텀 (px)");
const letterSpacingInput = screen.getByLabelText("Select letter spacing (px) 커스텀 (px)");
fireEvent.change(letterSpacingInput, { target: { value: "1.5" } });
expect(updateBlock).toHaveBeenCalledWith(
@@ -551,13 +551,13 @@ describe("FormSelectPropertiesPanel", () => {
/>,
);
const widthPxInput = screen.getByLabelText("필드 고정 너비 커스텀 (px)");
const widthPxInput = screen.getByLabelText("Field fixed width 커스텀 (px)");
fireEvent.change(widthPxInput, { target: { value: "320" } });
const paddingXInput = screen.getByLabelText("셀렉트 가로 패딩 (px) 커스텀 (px)");
const paddingXInput = screen.getByLabelText("Select horizontal padding (px) 커스텀 (px)");
fireEvent.change(paddingXInput, { target: { value: "16" } });
const paddingYInput = screen.getByLabelText("셀렉트 세로 패딩 (px) 커스텀 (px)");
const paddingYInput = screen.getByLabelText("Select vertical padding (px) 커스텀 (px)");
fireEvent.change(paddingYInput, { target: { value: "12" } });
expect(updateBlock).toHaveBeenCalledWith(
@@ -591,7 +591,7 @@ describe("FormSelectPropertiesPanel", () => {
/>,
);
const layoutSelect = screen.getByLabelText("레이아웃");
const layoutSelect = screen.getByLabelText("Layout");
expect(layoutSelect).toBeTruthy();
cleanup();
@@ -610,7 +610,7 @@ describe("FormSelectPropertiesPanel", () => {
/>,
);
expect(screen.queryByLabelText("레이아웃")).toBeNull();
expect(screen.queryByLabelText("Layout")).toBeNull();
});
it("라벨 타입 셀렉트 변경 시 updateBlock 이 labelMode 로 호출되어야 한다", () => {
@@ -629,7 +629,7 @@ describe("FormSelectPropertiesPanel", () => {
/>,
);
const select = screen.getByLabelText("라벨 타입");
const select = screen.getByLabelText("Label type");
fireEvent.change(select, { target: { value: "image" } });
expect(updateBlock).toHaveBeenCalledWith(
@@ -654,7 +654,7 @@ describe("FormSelectPropertiesPanel", () => {
/>,
);
const select = screen.getByLabelText("라벨 표시 방식");
const select = screen.getByLabelText("Label display mode");
fireEvent.change(select, { target: { value: "hidden" } });
expect(updateBlock).toHaveBeenCalledWith(
@@ -680,7 +680,7 @@ describe("FormSelectPropertiesPanel", () => {
/>,
);
const select = screen.getByLabelText("레이아웃");
const select = screen.getByLabelText("Layout");
fireEvent.change(select, { target: { value: "inline" } });
expect(updateBlock).toHaveBeenCalledWith(
@@ -707,7 +707,7 @@ describe("FormSelectPropertiesPanel", () => {
/>,
);
const gapInput = screen.getByLabelText("라벨/필드 간격 (px) 커스텀 (px)");
const gapInput = screen.getByLabelText("Label/field gap (px) 커스텀 (px)");
fireEvent.change(gapInput, { target: { value: "16" } });
expect(updateBlock).toHaveBeenCalledWith(
@@ -732,7 +732,7 @@ describe("FormSelectPropertiesPanel", () => {
/>,
);
const input = screen.getByLabelText("필드 라벨");
const input = screen.getByLabelText("Field label");
fireEvent.change(input, { target: { value: "새 카테고리" } });
expect(updateBlock).toHaveBeenCalledWith(
@@ -757,7 +757,7 @@ describe("FormSelectPropertiesPanel", () => {
/>,
);
const input = screen.getByLabelText("전송 키");
const input = screen.getByLabelText("Submit key");
fireEvent.change(input, { target: { value: "category_new" } });
expect(updateBlock).toHaveBeenCalledWith(
@@ -782,7 +782,7 @@ describe("FormSelectPropertiesPanel", () => {
/>,
);
const radiusInput = screen.getByLabelText("필드 모서리 둥글기 커스텀");
const radiusInput = screen.getByLabelText("Field border radius 커스텀");
fireEvent.change(radiusInput, { target: { value: "8" } });
expect(updateBlock).toHaveBeenCalledWith(
@@ -806,7 +806,7 @@ describe("FormSelectPropertiesPanel", () => {
/>,
);
const labelTypeSelect = screen.getByLabelText("라벨 타입") as HTMLSelectElement;
const labelTypeSelect = screen.getByLabelText("Label type") as HTMLSelectElement;
const labelTypeClass = labelTypeSelect.getAttribute("class") ?? "";
expect(labelTypeClass).toContain("bg-white");
expect(labelTypeClass).toContain("text-slate-900");
@@ -815,7 +815,7 @@ describe("FormSelectPropertiesPanel", () => {
expect(labelTypeClass).toContain("dark:text-slate-100");
expect(labelTypeClass).toContain("dark:border-slate-700");
const labelInput = screen.getByLabelText("필드 라벨") as HTMLInputElement;
const labelInput = screen.getByLabelText("Field label") as HTMLInputElement;
const labelInputClass = labelInput.getAttribute("class") ?? "";
expect(labelInputClass).toContain("bg-white");
expect(labelInputClass).toContain("text-slate-900");
@@ -824,7 +824,7 @@ describe("FormSelectPropertiesPanel", () => {
expect(labelInputClass).toContain("dark:text-slate-100");
expect(labelInputClass).toContain("dark:border-slate-700");
const labelDisplaySelect = screen.getByLabelText("라벨 표시 방식") as HTMLSelectElement;
const labelDisplaySelect = screen.getByLabelText("Label display mode") as HTMLSelectElement;
const labelDisplayClass = labelDisplaySelect.getAttribute("class") ?? "";
expect(labelDisplayClass).toContain("bg-white");
expect(labelDisplayClass).toContain("text-slate-900");
@@ -833,7 +833,7 @@ describe("FormSelectPropertiesPanel", () => {
expect(labelDisplayClass).toContain("dark:text-slate-100");
expect(labelDisplayClass).toContain("dark:border-slate-700");
const widthModeSelect = screen.getByLabelText("필드 너비") as HTMLSelectElement;
const widthModeSelect = screen.getByLabelText("Field width") as HTMLSelectElement;
const widthModeClass = widthModeSelect.getAttribute("class") ?? "";
expect(widthModeClass).toContain("bg-white");
expect(widthModeClass).toContain("text-slate-900");
@@ -842,7 +842,7 @@ describe("FormSelectPropertiesPanel", () => {
expect(widthModeClass).toContain("dark:text-slate-100");
expect(widthModeClass).toContain("dark:border-slate-700");
const optionLabelInput = screen.getByPlaceholderText("라벨") as HTMLInputElement;
const optionLabelInput = screen.getByPlaceholderText("Label") as HTMLInputElement;
const optionLabelClass = optionLabelInput.getAttribute("class") ?? "";
expect(optionLabelClass).toContain("bg-white");
expect(optionLabelClass).toContain("text-slate-900");
@@ -867,7 +867,7 @@ describe("FormSelectPropertiesPanel", () => {
/>,
);
const optionLabelInput = screen.getByPlaceholderText("라벨") as HTMLInputElement;
const optionLabelInput = screen.getByPlaceholderText("Label") as HTMLInputElement;
const rowDiv = optionLabelInput.closest("div") as HTMLDivElement | null;
expect(rowDiv).not.toBeNull();
@@ -904,7 +904,7 @@ describe("FormCheckboxPropertiesPanel", () => {
/>,
);
const hexInput = screen.getByLabelText("체크박스 텍스트 색상 HEX");
const hexInput = screen.getByLabelText("Checkbox text color HEX");
fireEvent.change(hexInput, { target: { value: "#112233" } });
expect(updateBlock).toHaveBeenCalledWith(
@@ -931,13 +931,13 @@ describe("FormCheckboxPropertiesPanel", () => {
/>,
);
const fontSizeInput = screen.getByLabelText("체크박스 텍스트 크기 (px) 커스텀 (px)");
const fontSizeInput = screen.getByLabelText("Checkbox text size (px) 커스텀 (px)");
fireEvent.change(fontSizeInput, { target: { value: "24" } });
const lineHeightInput = screen.getByLabelText("체크박스 줄간격 (px) 커스텀 (px)");
const lineHeightInput = screen.getByLabelText("Checkbox line height (px) 커스텀 (px)");
fireEvent.change(lineHeightInput, { target: { value: "30" } });
const letterSpacingInput = screen.getByLabelText("체크박스 자간 (px) 커스텀 (px)");
const letterSpacingInput = screen.getByLabelText("Checkbox letter spacing (px) 커스텀 (px)");
fireEvent.change(letterSpacingInput, { target: { value: "1.5" } });
expect(updateBlock).toHaveBeenCalledWith(
@@ -973,13 +973,13 @@ describe("FormCheckboxPropertiesPanel", () => {
/>,
);
const widthPxInput = screen.getByLabelText("필드 고정 너비 커스텀 (px)");
const widthPxInput = screen.getByLabelText("Field fixed width 커스텀 (px)");
fireEvent.change(widthPxInput, { target: { value: "320" } });
const paddingXInput = screen.getByLabelText("체크박스 가로 패딩 (px) 커스텀 (px)");
const paddingXInput = screen.getByLabelText("Checkbox horizontal padding (px) 커스텀 (px)");
fireEvent.change(paddingXInput, { target: { value: "16" } });
const paddingYInput = screen.getByLabelText("체크박스 세로 패딩 (px) 커스텀 (px)");
const paddingYInput = screen.getByLabelText("Checkbox vertical padding (px) 커스텀 (px)");
fireEvent.change(paddingYInput, { target: { value: "12" } });
expect(updateBlock).toHaveBeenCalledWith(
@@ -1013,7 +1013,7 @@ describe("FormCheckboxPropertiesPanel", () => {
/>,
);
const layoutSelect = screen.getByLabelText("레이아웃");
const layoutSelect = screen.getByLabelText("Layout");
expect(layoutSelect).toBeTruthy();
cleanup();
@@ -1032,7 +1032,7 @@ describe("FormCheckboxPropertiesPanel", () => {
/>,
);
expect(screen.queryByLabelText("레이아웃")).toBeNull();
expect(screen.queryByLabelText("Layout")).toBeNull();
});
it("그룹 타이틀 타입 셀렉트 변경 시 updateBlock 이 groupLabelMode 로 호출되어야 한다", () => {
@@ -1051,7 +1051,7 @@ describe("FormCheckboxPropertiesPanel", () => {
/>,
);
const select = screen.getByLabelText("그룹 타이틀 타입");
const select = screen.getByLabelText("Group title type");
fireEvent.change(select, { target: { value: "image" } });
expect(updateBlock).toHaveBeenCalledWith(
@@ -1076,7 +1076,7 @@ describe("FormCheckboxPropertiesPanel", () => {
/>,
);
const select = screen.getByLabelText("그룹 타이틀 표시 방식");
const select = screen.getByLabelText("Group title display mode");
fireEvent.change(select, { target: { value: "hidden" } });
expect(updateBlock).toHaveBeenCalledWith(
@@ -1102,7 +1102,7 @@ describe("FormCheckboxPropertiesPanel", () => {
/>,
);
const select = screen.getByLabelText("레이아웃");
const select = screen.getByLabelText("Layout");
fireEvent.change(select, { target: { value: "inline" } });
expect(updateBlock).toHaveBeenCalledWith(
@@ -1129,7 +1129,7 @@ describe("FormCheckboxPropertiesPanel", () => {
/>,
);
const gapInput = screen.getByLabelText("라벨/필드 간격 (px) 커스텀 (px)");
const gapInput = screen.getByLabelText("Label/field gap (px) 커스텀 (px)");
fireEvent.change(gapInput, { target: { value: "16" } });
expect(updateBlock).toHaveBeenCalledWith(
@@ -1154,7 +1154,7 @@ describe("FormCheckboxPropertiesPanel", () => {
/>,
);
const input = screen.getByLabelText("그룹 타이틀");
const input = screen.getByLabelText("Group title");
fireEvent.change(input, { target: { value: "새 옵션들" } });
expect(updateBlock).toHaveBeenCalledWith(
@@ -1179,7 +1179,7 @@ describe("FormCheckboxPropertiesPanel", () => {
/>,
);
const gapInput = screen.getByLabelText("체크박스 옵션 간격 (px) 커스텀 (px)");
const gapInput = screen.getByLabelText("Checkbox option gap (px) 커스텀 (px)");
fireEvent.change(gapInput, { target: { value: "10" } });
expect(updateBlock).toHaveBeenCalledWith(
@@ -1204,7 +1204,7 @@ describe("FormCheckboxPropertiesPanel", () => {
/>,
);
const select = screen.getByLabelText("옵션 레이아웃");
const select = screen.getByLabelText("Option layout");
fireEvent.change(select, { target: { value: "inline" } });
expect(updateBlock).toHaveBeenCalledWith(
@@ -1229,7 +1229,7 @@ describe("FormCheckboxPropertiesPanel", () => {
/>,
);
const radiusInput = screen.getByLabelText("필드 모서리 둥글기 커스텀");
const radiusInput = screen.getByLabelText("Field border radius 커스텀");
fireEvent.change(radiusInput, { target: { value: "8" } });
expect(updateBlock).toHaveBeenCalledWith(
@@ -1253,7 +1253,7 @@ describe("FormCheckboxPropertiesPanel", () => {
/>,
);
const select = screen.getByLabelText("필드 너비") as HTMLSelectElement;
const select = screen.getByLabelText("Field width") as HTMLSelectElement;
const className = select.getAttribute("class") ?? "";
expect(className).toContain("bg-white");
@@ -1282,13 +1282,13 @@ describe("FormCheckboxPropertiesPanel", () => {
/>,
);
const fontSizeInput = screen.getByLabelText("라디오 텍스트 크기 (px) 커스텀 (px)");
const fontSizeInput = screen.getByLabelText("Radio text size (px) 커스텀 (px)");
fireEvent.change(fontSizeInput, { target: { value: "24" } });
const lineHeightInput = screen.getByLabelText("라디오 줄간격 (px) 커스텀 (px)");
const lineHeightInput = screen.getByLabelText("Radio line height (px) 커스텀 (px)");
fireEvent.change(lineHeightInput, { target: { value: "30" } });
const letterSpacingInput = screen.getByLabelText("라디오 자간 (px) 커스텀 (px)");
const letterSpacingInput = screen.getByLabelText("Radio letter spacing (px) 커스텀 (px)");
fireEvent.change(letterSpacingInput, { target: { value: "1.5" } });
expect(updateBlock).toHaveBeenCalledWith(
@@ -1325,16 +1325,16 @@ describe("FormCheckboxPropertiesPanel", () => {
/>,
);
const widthPxInput = screen.getByLabelText("필드 고정 너비 커스텀 (px)");
const widthPxInput = screen.getByLabelText("Field fixed width 커스텀 (px)");
fireEvent.change(widthPxInput, { target: { value: "320" } });
const paddingXInput = screen.getByLabelText("라디오 가로 패딩 (px) 커스텀 (px)");
const paddingXInput = screen.getByLabelText("Radio horizontal padding (px) 커스텀 (px)");
fireEvent.change(paddingXInput, { target: { value: "12" } });
const paddingYInput = screen.getByLabelText("라디오 세로 패딩 (px) 커스텀 (px)");
const paddingYInput = screen.getByLabelText("Radio vertical padding (px) 커스텀 (px)");
fireEvent.change(paddingYInput, { target: { value: "6" } });
const optionGapInput = screen.getByLabelText("라디오 옵션 간격 (px) 커스텀 (px)");
const optionGapInput = screen.getByLabelText("Radio option gap (px) 커스텀 (px)");
fireEvent.change(optionGapInput, { target: { value: "10" } });
expect(updateBlock).toHaveBeenCalledWith(
@@ -1616,7 +1616,7 @@ describe("FormCheckboxPropertiesPanel", () => {
/>,
);
const hexInput = screen.getByLabelText("라디오 배경 색상 HEX");
const hexInput = screen.getByLabelText("Radio background color HEX");
fireEvent.change(hexInput, { target: { value: "#445566" } });
expect(updateBlock).toHaveBeenCalledWith(
@@ -1641,7 +1641,7 @@ describe("FormCheckboxPropertiesPanel", () => {
/>,
);
const hexInput = screen.getByLabelText("라디오 테두리 색상 HEX");
const hexInput = screen.getByLabelText("Radio border color HEX");
fireEvent.change(hexInput, { target: { value: "#778899" } });
expect(updateBlock).toHaveBeenCalledWith(
@@ -1666,7 +1666,7 @@ describe("FormCheckboxPropertiesPanel", () => {
/>,
);
const radiusInput = screen.getByLabelText("필드 모서리 둥글기 커스텀");
const radiusInput = screen.getByLabelText("Field border radius 커스텀");
fireEvent.change(radiusInput, { target: { value: "8" } });
expect(updateBlock).toHaveBeenCalledWith(
+11 -11
View File
@@ -30,7 +30,7 @@ describe("ImagePropertiesPanel", () => {
/>,
);
const hexInput = screen.getByLabelText("이미지 카드 배경색 HEX");
const hexInput = screen.getByLabelText("Image card background color HEX");
fireEvent.change(hexInput, { target: { value: "#112233" } });
expect(updateBlock).toHaveBeenCalledWith(
@@ -55,7 +55,7 @@ describe("ImagePropertiesPanel", () => {
/>,
);
const select = screen.getByLabelText("이미지 소스");
const select = screen.getByLabelText("Image source");
fireEvent.change(select, { target: { value: "url" } });
expect(updateBlock).toHaveBeenCalledWith(
@@ -80,7 +80,7 @@ describe("ImagePropertiesPanel", () => {
/>,
);
const urlInput = screen.getByLabelText("이미지 URL");
const urlInput = screen.getByLabelText("Image URL");
fireEvent.change(urlInput, { target: { value: "https://example.com/after.png" } });
expect(updateBlock).toHaveBeenCalledWith(
@@ -104,7 +104,7 @@ describe("ImagePropertiesPanel", () => {
/>,
);
const altInput = screen.getByLabelText("대체 텍스트");
const altInput = screen.getByLabelText("Alt text");
fireEvent.change(altInput, { target: { value: "새 대체 텍스트" } });
expect(updateBlock).toHaveBeenCalledWith(
@@ -124,7 +124,7 @@ describe("ImagePropertiesPanel", () => {
/>,
);
const select = screen.getByLabelText("이미지 정렬");
const select = screen.getByLabelText("Image alignment");
fireEvent.change(select, { target: { value: "left" } });
expect(updateBlock).toHaveBeenCalledWith(
@@ -144,7 +144,7 @@ describe("ImagePropertiesPanel", () => {
/>,
);
const select = screen.getByLabelText("이미지 너비 모드");
const select = screen.getByLabelText("Image width mode");
fireEvent.change(select, { target: { value: "fixed" } });
expect(updateBlock).toHaveBeenCalledWith(
@@ -164,7 +164,7 @@ describe("ImagePropertiesPanel", () => {
/>,
);
const slider = screen.getByLabelText("고정 너비 (px) 슬라이더");
const slider = screen.getByLabelText("Fixed width (px) 슬라이더");
fireEvent.change(slider, { target: { value: "480" } });
expect(updateBlock).toHaveBeenCalledWith(
@@ -184,7 +184,7 @@ describe("ImagePropertiesPanel", () => {
/>,
);
const slider = screen.getByLabelText("모서리 둥글기 슬라이더");
const slider = screen.getByLabelText("Border radius 슬라이더");
fireEvent.change(slider, { target: { value: "24" } });
expect(updateBlock).toHaveBeenCalledWith(
@@ -204,7 +204,7 @@ describe("ImagePropertiesPanel", () => {
/>,
);
const sourceSelect = screen.getByLabelText("이미지 소스") as HTMLSelectElement;
const sourceSelect = screen.getByLabelText("Image source") as HTMLSelectElement;
const sourceClass = sourceSelect.getAttribute("class") ?? "";
expect(sourceClass).toContain("bg-white");
expect(sourceClass).toContain("text-slate-900");
@@ -213,7 +213,7 @@ describe("ImagePropertiesPanel", () => {
expect(sourceClass).toContain("dark:text-slate-100");
expect(sourceClass).toContain("dark:border-slate-700");
const urlInput = screen.getByLabelText("이미지 URL") as HTMLInputElement;
const urlInput = screen.getByLabelText("Image URL") as HTMLInputElement;
const urlClass = urlInput.getAttribute("class") ?? "";
expect(urlClass).toContain("bg-white");
expect(urlClass).toContain("text-slate-900");
@@ -222,7 +222,7 @@ describe("ImagePropertiesPanel", () => {
expect(urlClass).toContain("dark:text-slate-100");
expect(urlClass).toContain("dark:border-slate-700");
const alignSelect = screen.getByLabelText("이미지 정렬") as HTMLSelectElement;
const alignSelect = screen.getByLabelText("Image alignment") as HTMLSelectElement;
const alignClass = alignSelect.getAttribute("class") ?? "";
expect(alignClass).toContain("bg-white");
expect(alignClass).toContain("text-slate-900");
+16 -16
View File
@@ -26,7 +26,7 @@ describe("ListPropertiesPanel", () => {
/>,
);
const textarea = screen.getByLabelText("리스트 아이템들");
const textarea = screen.getByLabelText("List items");
fireEvent.change(textarea, { target: { value: "첫째\n둘째" } });
expect(updateBlock).toHaveBeenCalledWith(
@@ -49,7 +49,7 @@ describe("ListPropertiesPanel", () => {
/>,
);
const select = screen.getByLabelText("리스트 정렬");
const select = screen.getByLabelText("List alignment");
fireEvent.change(select, { target: { value: "center" } });
expect(updateBlock).toHaveBeenCalledWith(
@@ -70,7 +70,7 @@ describe("ListPropertiesPanel", () => {
/>,
);
const slider = screen.getByLabelText("글자 크기 (px) 슬라이더");
const slider = screen.getByLabelText("Font size (px) 슬라이더");
fireEvent.change(slider, { target: { value: "20" } });
expect(updateBlock).toHaveBeenCalledWith(
@@ -91,7 +91,7 @@ describe("ListPropertiesPanel", () => {
/>,
);
const slider = screen.getByLabelText("줄 간격 슬라이더");
const slider = screen.getByLabelText("Line height 슬라이더");
fireEvent.change(slider, { target: { value: "1.8" } });
expect(updateBlock).toHaveBeenCalledWith(
@@ -112,7 +112,7 @@ describe("ListPropertiesPanel", () => {
/>,
);
const hexInput = screen.getByLabelText("리스트 텍스트 색상 HEX");
const hexInput = screen.getByLabelText("List text color HEX");
fireEvent.change(hexInput, { target: { value: "#112233" } });
expect(updateBlock).toHaveBeenCalledWith(
@@ -121,7 +121,7 @@ describe("ListPropertiesPanel", () => {
);
});
it("텍스트 색상 팔레트에서 \"없음\" 을 선택하면 textColorCustom 이 빈 문자열로 업데이트되어야 한다", () => {
it("텍스트 색상 팔레트에서 \"None\" 을 선택하면 textColorCustom 이 빈 문자열로 업데이트되어야 한다", () => {
const updateBlock = vi.fn();
render(
@@ -134,11 +134,11 @@ describe("ListPropertiesPanel", () => {
);
// 첫 번째 ColorPickerField(텍스트 색상)의 팔레트 버튼을 연다.
const paletteButtons = screen.getAllByText("색상 팔레트");
const paletteButtons = screen.getAllByText("Color palette");
fireEvent.click(paletteButtons[0].closest("button") as HTMLButtonElement);
// 팔레트에서 "없음" 항목을 버튼 role 기준으로 선택한다 (불릿 스타일 select 의 option "없음" 과 구분).
const noneButtons = screen.getAllByRole("button", { name: "없음" });
// 팔레트에서 "None" 항목을 버튼 role 기준으로 선택한다 (불릿 스타일 select 의 option "none" 과 구분).
const noneButtons = screen.getAllByRole("button", { name: "None" });
fireEvent.click(noneButtons[0]);
expect(updateBlock).toHaveBeenCalledWith(
@@ -159,7 +159,7 @@ describe("ListPropertiesPanel", () => {
/>,
);
const hexInput = screen.getByLabelText("리스트 텍스트 색상 HEX") as HTMLInputElement;
const hexInput = screen.getByLabelText("List text color HEX") as HTMLInputElement;
expect(hexInput.value).toBe("");
});
@@ -175,7 +175,7 @@ describe("ListPropertiesPanel", () => {
/>,
);
const select = screen.getByLabelText("리스트 불릿 스타일");
const select = screen.getByLabelText("List bullet style");
fireEvent.change(select, { target: { value: "decimal" } });
expect(updateBlock).toHaveBeenCalledWith(
@@ -196,7 +196,7 @@ describe("ListPropertiesPanel", () => {
/>,
);
const select = screen.getByLabelText("리스트 불릿 스타일");
const select = screen.getByLabelText("List bullet style");
fireEvent.change(select, { target: { value: "none" } });
expect(updateBlock).toHaveBeenCalledWith(
@@ -217,7 +217,7 @@ describe("ListPropertiesPanel", () => {
/>,
);
const slider = screen.getByLabelText("아이템 간 여백 (px) 슬라이더");
const slider = screen.getByLabelText("Item gap (px) 슬라이더");
fireEvent.change(slider, { target: { value: "16" } });
expect(updateBlock).toHaveBeenCalledWith(
@@ -238,7 +238,7 @@ describe("ListPropertiesPanel", () => {
/>,
);
const select = screen.getByLabelText("리스트 정렬") as HTMLSelectElement;
const select = screen.getByLabelText("List alignment") as HTMLSelectElement;
const className = select.getAttribute("class") ?? "";
expect(className).toContain("bg-white");
@@ -261,7 +261,7 @@ describe("ListPropertiesPanel", () => {
/>,
);
const select = screen.getByLabelText("리스트 불릿 스타일") as HTMLSelectElement;
const select = screen.getByLabelText("List bullet style") as HTMLSelectElement;
const className = select.getAttribute("class") ?? "";
expect(className).toContain("bg-white");
@@ -284,7 +284,7 @@ describe("ListPropertiesPanel", () => {
/>,
);
const textarea = screen.getByLabelText("리스트 아이템들") as HTMLTextAreaElement;
const textarea = screen.getByLabelText("List items") as HTMLTextAreaElement;
const className = textarea.getAttribute("class") ?? "";
// 라이트 모드: 흰 배경 + 어두운 텍스트
+56
View File
@@ -0,0 +1,56 @@
import { describe, it, expect, afterEach } from "vitest";
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
import { LocaleProvider, useAppLocale, useLocaleActions } from "@/features/i18n/LocaleProvider";
// LocaleProvider / useAppLocale / useLocaleActions TDD
// - initialLocale 로 전달된 로케일이 하위 컴포넌트에서 그대로 노출되는지 검증한다.
// - useLocaleActions 로 setLocale 을 호출하면 하위 컴포넌트의 로케일이 변경되어야 한다.
describe("LocaleProvider", () => {
afterEach(() => {
cleanup();
});
function LocaleConsumer() {
const locale = useAppLocale();
return <div data-testid="current-locale">{locale}</div>;
}
function LocaleSetterButton({ next }: { next: "en" | "ko" }) {
const { setLocale } = useLocaleActions();
return (
<button type="button" onClick={() => setLocale(next)}>
set-{next}
</button>
);
}
it("initialLocale 로 en 을 전달하면 하위 컴포넌트에서 en 로케일을 읽을 수 있어야 한다", () => {
render(
<LocaleProvider initialLocale="en">
<LocaleConsumer />
</LocaleProvider>,
);
const current = screen.getByTestId("current-locale");
expect(current.textContent).toBe("en");
});
it("setLocale 을 호출하면 하위 컴포넌트의 useAppLocale 결과가 업데이트되어야 한다", () => {
render(
<LocaleProvider initialLocale="en">
<LocaleConsumer />
<LocaleSetterButton next="ko" />
</LocaleProvider>,
);
const current = screen.getByTestId("current-locale");
expect(current.textContent).toBe("en");
fireEvent.click(screen.getByText("set-ko"));
expect(current.textContent).toBe("ko");
});
});
+54
View File
@@ -0,0 +1,54 @@
import { describe, it, expect, afterEach } from "vitest";
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
import { LocaleProvider, useAppLocale } from "@/features/i18n/LocaleProvider";
import { LocaleSwitcher } from "@/features/i18n/LocaleSwitcher";
// LocaleSwitcher TDD
// - 현재 로케일에 따라 EN/KO 버튼 텍스트가 표시되어야 한다.
// - 버튼을 클릭하면 로케일이 토글되고, 하위 컴포넌트의 useAppLocale 결과도 함께 변경되어야 한다.
describe("LocaleSwitcher", () => {
afterEach(() => {
cleanup();
});
function LocaleConsumer() {
const locale = useAppLocale();
return <div data-testid="current-locale">{locale}</div>;
}
it("초기 로케일이 en 이면 버튼 텍스트는 EN 이어야 한다", () => {
render(
<LocaleProvider initialLocale="en">
<LocaleSwitcher />
<LocaleConsumer />
</LocaleProvider>,
);
const button = screen.getByRole("button", { name: "Toggle locale" });
const current = screen.getByTestId("current-locale");
expect(button.textContent).toContain("EN");
expect(current.textContent).toBe("en");
});
it("버튼을 클릭하면 로케일이 en/ko 사이에서 토글되어야 한다", () => {
render(
<LocaleProvider initialLocale="en">
<LocaleSwitcher />
<LocaleConsumer />
</LocaleProvider>,
);
const button = screen.getByRole("button", { name: "Toggle locale" });
const current = screen.getByTestId("current-locale");
expect(current.textContent).toBe("en");
expect(button.textContent).toContain("EN");
fireEvent.click(button);
expect(current.textContent).toBe("ko");
expect(button.textContent).toContain("KO");
});
});
+9 -9
View File
@@ -54,9 +54,9 @@ describe("LoginPage", () => {
render(<LoginPage />);
const emailInput = screen.getByLabelText("이메일") as HTMLInputElement;
const passwordInput = screen.getByLabelText("비밀번호") as HTMLInputElement;
const submitButton = screen.getByRole("button", { name: "로그인" });
const emailInput = screen.getByLabelText("Email") as HTMLInputElement;
const passwordInput = screen.getByLabelText("Password") as HTMLInputElement;
const submitButton = screen.getByRole("button", { name: "Log in" });
fireEvent.change(emailInput, { target: { value: "user@example.com" } });
fireEvent.change(passwordInput, { target: { value: "securePass1" } });
@@ -116,9 +116,9 @@ describe("LoginPage", () => {
render(<LoginPage />);
const emailInput = screen.getByLabelText("이메일") as HTMLInputElement;
const passwordInput = screen.getByLabelText("비밀번호") as HTMLInputElement;
const submitButton = screen.getByRole("button", { name: "로그인" });
const emailInput = screen.getByLabelText("Email") as HTMLInputElement;
const passwordInput = screen.getByLabelText("Password") as HTMLInputElement;
const submitButton = screen.getByRole("button", { name: "Log in" });
fireEvent.change(emailInput, { target: { value: "user@example.com" } });
fireEvent.change(passwordInput, { target: { value: "wrongpass" } });
@@ -179,8 +179,8 @@ describe("LoginPage", () => {
render(<LoginPage />);
const emailInput = screen.getByLabelText("이메일") as HTMLInputElement;
const passwordInput = screen.getByLabelText("비밀번호") as HTMLInputElement;
const emailInput = screen.getByLabelText("Email") as HTMLInputElement;
const passwordInput = screen.getByLabelText("Password") as HTMLInputElement;
const emailClass = emailInput.className;
const passwordClass = passwordInput.className;
@@ -215,7 +215,7 @@ describe("LoginPage", () => {
const html = document.documentElement;
html.classList.remove("dark");
const themeToggleButton = await screen.findByRole("button", { name: "테마 전환" });
const themeToggleButton = await screen.findByRole("button", { name: "Toggle theme" });
expect(html.classList.contains("dark")).toBe(false);
+3 -3
View File
@@ -65,7 +65,7 @@ describe("PreviewPage - ZIP Export", () => {
it("프리뷰 헤더에 프로젝트 목록 링크가 노출되고 /projects 로 연결되어야 한다", () => {
render(<PreviewPage />);
const link = screen.getByText("프로젝트 목록");
const link = screen.getByText("Projects");
expect(link).toBeTruthy();
expect(link.closest("a")?.getAttribute("href")).toBe("/projects");
});
@@ -73,7 +73,7 @@ describe("PreviewPage - ZIP Export", () => {
it("프리뷰 헤더에 ZIP Export 버튼이 노출되어야 한다", () => {
render(<PreviewPage />);
const exportButton = screen.getByText("페이지 파일로 내보내기 (ZIP)");
const exportButton = screen.getByText("Export as page files (ZIP)");
expect(exportButton).toBeTruthy();
});
@@ -88,7 +88,7 @@ describe("PreviewPage - ZIP Export", () => {
render(<PreviewPage />);
const exportButton = screen.getByText("페이지 파일로 내보내기 (ZIP)");
const exportButton = screen.getByText("Export as page files (ZIP)");
fireEvent.click(exportButton);
await waitFor(() => {
@@ -41,7 +41,7 @@ describe("ProjectPropertiesPanel SEO 메타", () => {
it("SEO 타이틀 입력을 변경하면 seoTitle 이 updateProjectConfig 로 전달되어야 한다", () => {
render(<ProjectPropertiesPanel />);
const input = screen.getByLabelText("SEO 타이틀") as HTMLInputElement;
const input = screen.getByLabelText("SEO title") as HTMLInputElement;
fireEvent.change(input, { target: { value: "새 SEO 타이틀" } });
@@ -51,7 +51,7 @@ describe("ProjectPropertiesPanel SEO 메타", () => {
it("메타 디스크립션 입력을 변경하면 seoDescription 이 updateProjectConfig 로 전달되어야 한다", () => {
render(<ProjectPropertiesPanel />);
const textarea = screen.getByLabelText("메타 디스크립션") as HTMLTextAreaElement;
const textarea = screen.getByLabelText("Meta description") as HTMLTextAreaElement;
fireEvent.change(textarea, { target: { value: "SEO 설명" } });
@@ -63,7 +63,7 @@ describe("ProjectPropertiesPanel SEO 메타", () => {
it("OG/Twitter 이미지 URL 입력을 변경하면 seoOgImageUrl 이 updateProjectConfig 로 전달되어야 한다", () => {
render(<ProjectPropertiesPanel />);
const input = screen.getByLabelText("OG/Twitter 이미지 URL") as HTMLInputElement;
const input = screen.getByLabelText("OG/Twitter image URL") as HTMLInputElement;
fireEvent.change(input, { target: { value: "https://example.com/og.png" } });
@@ -88,7 +88,7 @@ describe("ProjectPropertiesPanel SEO 메타", () => {
render(<ProjectPropertiesPanel />);
const checkbox = screen.getByLabelText(
"검색 엔진에 노출하지 않기 (noindex)",
"Hide from search engines (noindex)",
) as HTMLInputElement;
expect(checkbox.checked).toBe(false);
+19 -19
View File
@@ -7,7 +7,7 @@ let mockState: { projectConfig: ProjectConfig; updateProjectConfig: ReturnType<t
beforeEach(() => {
const baseConfig: ProjectConfig = {
title: "프로젝트",
title: "Project",
slug: "project",
canvasPreset: "full",
canvasWidthPx: 1024,
@@ -37,7 +37,7 @@ describe("ProjectPropertiesPanel", () => {
it("캔버스 너비 프리셋을 변경하면 canvasPreset/canvasWidthPx 가 업데이트되어야 한다", () => {
render(<ProjectPropertiesPanel />);
const select = screen.getByLabelText("캔버스 너비 프리셋") as HTMLSelectElement;
const select = screen.getByLabelText("Canvas width 프리셋") as HTMLSelectElement;
fireEvent.change(select, { target: { value: "mobile" } });
@@ -47,7 +47,7 @@ describe("ProjectPropertiesPanel", () => {
it("태블릿 프리셋을 선택하면 canvasPreset=tablet 과 canvasWidthPx=768 로 업데이트되어야 한다", () => {
render(<ProjectPropertiesPanel />);
const select = screen.getByLabelText("캔버스 너비 프리셋") as HTMLSelectElement;
const select = screen.getByLabelText("Canvas width 프리셋") as HTMLSelectElement;
fireEvent.change(select, { target: { value: "tablet" } });
@@ -57,7 +57,7 @@ describe("ProjectPropertiesPanel", () => {
it("데스크톱 프리셋을 선택하면 canvasPreset=desktop 과 canvasWidthPx=1200 으로 업데이트되어야 한다", () => {
render(<ProjectPropertiesPanel />);
const select = screen.getByLabelText("캔버스 너비 프리셋") as HTMLSelectElement;
const select = screen.getByLabelText("Canvas width 프리셋") as HTMLSelectElement;
fireEvent.change(select, { target: { value: "desktop" } });
@@ -67,7 +67,7 @@ describe("ProjectPropertiesPanel", () => {
it("캔버스 너비를 390 으로 입력하면 mobile 프리셋으로 동기화되어야 한다", () => {
render(<ProjectPropertiesPanel />);
const input = screen.getByLabelText("캔버스 너비 커스텀 (px)") as HTMLInputElement;
const input = screen.getByLabelText("Canvas width 커스텀 (px)") as HTMLInputElement;
fireEvent.change(input, { target: { value: "390" } });
@@ -77,7 +77,7 @@ describe("ProjectPropertiesPanel", () => {
it("캔버스 너비를 768 로 입력하면 tablet 프리셋으로 동기화되어야 한다", () => {
render(<ProjectPropertiesPanel />);
const input = screen.getByLabelText("캔버스 너비 커스텀 (px)") as HTMLInputElement;
const input = screen.getByLabelText("Canvas width 커스텀 (px)") as HTMLInputElement;
fireEvent.change(input, { target: { value: "768" } });
@@ -87,7 +87,7 @@ describe("ProjectPropertiesPanel", () => {
it("캔버스 너비를 1200 으로 입력하면 desktop 프리셋으로 동기화되어야 한다", () => {
render(<ProjectPropertiesPanel />);
const input = screen.getByLabelText("캔버스 너비 커스텀 (px)") as HTMLInputElement;
const input = screen.getByLabelText("Canvas width 커스텀 (px)") as HTMLInputElement;
fireEvent.change(input, { target: { value: "1200" } });
@@ -97,7 +97,7 @@ describe("ProjectPropertiesPanel", () => {
it("캔버스 너비를 커스텀 값으로 입력하면 canvasPreset=custom 과 함께 canvasWidthPx 가 설정되어야 한다", () => {
render(<ProjectPropertiesPanel />);
const input = screen.getByLabelText("캔버스 너비 커스텀 (px)") as HTMLInputElement;
const input = screen.getByLabelText("Canvas width 커스텀 (px)") as HTMLInputElement;
fireEvent.change(input, { target: { value: "1000" } });
@@ -107,7 +107,7 @@ describe("ProjectPropertiesPanel", () => {
it("캔버스 배경색 HEX 입력을 변경하면 canvasBgColorHex 가 업데이트되어야 한다", () => {
render(<ProjectPropertiesPanel />);
const input = screen.getByLabelText("캔버스 배경색 HEX") as HTMLInputElement;
const input = screen.getByLabelText("Canvas background HEX") as HTMLInputElement;
fireEvent.change(input, { target: { value: "#123456" } });
@@ -117,35 +117,35 @@ describe("ProjectPropertiesPanel", () => {
it("페이지 배경색 HEX 입력을 변경하면 bodyBgColorHex 가 업데이트되어야 한다", () => {
render(<ProjectPropertiesPanel />);
const input = screen.getByLabelText("페이지 배경색 HEX") as HTMLInputElement;
const input = screen.getByLabelText("Page background HEX") as HTMLInputElement;
fireEvent.change(input, { target: { value: "#654321" } });
expect(mockState.updateProjectConfig).toHaveBeenCalledWith({ bodyBgColorHex: "#654321" });
});
it("캔버스 배경색 팔레트에서 \"없음\" 을 선택하면 canvasBgColorHex 가 빈 문자열로 업데이트되어야 한다", () => {
it("캔버스 배경색 팔레트에서 \"None\" 을 선택하면 canvasBgColorHex 가 빈 문자열로 업데이트되어야 한다", () => {
render(<ProjectPropertiesPanel />);
// 첫 번째 ColorPickerField(캔버스 배경색)의 팔레트 버튼을 연다.
const paletteButtons = screen.getAllByText("색상 팔레트");
const paletteButtons = screen.getAllByText("Color palette");
fireEvent.click(paletteButtons[0].closest("button") as HTMLButtonElement);
// 팔레트에서 "없음" 항목을 선택한다.
const noneButton = screen.getByText("없음");
// 팔레트에서 "None" 항목을 선택한다.
const noneButton = screen.getByText("None");
fireEvent.click(noneButton);
expect(mockState.updateProjectConfig).toHaveBeenCalledWith({ canvasBgColorHex: "" });
});
it("페이지 배경색 팔레트에서 \"없음\" 을 선택하면 bodyBgColorHex 가 빈 문자열로 업데이트되어야 한다", () => {
it("페이지 배경색 팔레트에서 \"None\" 을 선택하면 bodyBgColorHex 가 빈 문자열로 업데이트되어야 한다", () => {
render(<ProjectPropertiesPanel />);
// 두 번째 ColorPickerField(페이지 배경색)의 팔레트 버튼은 기본 선택 팔레트 라벨("어두운 텍스트")를 기준으로 찾는다.
const darkLabel = screen.getByText("어두운 텍스트");
// 두 번째 ColorPickerField(페이지 배경색)의 팔레트 버튼은 기본 선택 팔레트 라벨("Dark text")를 기준으로 찾는다.
const darkLabel = screen.getByText("Dark text");
fireEvent.click(darkLabel.closest("button") as HTMLButtonElement);
const noneButton = screen.getByText("없음");
const noneButton = screen.getByText("None");
fireEvent.click(noneButton);
expect(mockState.updateProjectConfig).toHaveBeenCalledWith({ bodyBgColorHex: "" });
@@ -154,7 +154,7 @@ describe("ProjectPropertiesPanel", () => {
it("프로젝트 제목 입력은 라이트/다크 테마에 맞는 배경/텍스트 클래스를 사용해야 한다", () => {
render(<ProjectPropertiesPanel />);
const input = screen.getByLabelText("프로젝트 제목") as HTMLInputElement;
const input = screen.getByLabelText("Project title") as HTMLInputElement;
const className = input.getAttribute("class") ?? "";
// 라이트 모드: 흰 배경 + 어두운 텍스트
+49 -33
View File
@@ -69,12 +69,12 @@ describe("ProjectsPage - 프로젝트 목록", () => {
expect(await screen.findByText("테스트 프로젝트 B")).toBeTruthy();
expect(screen.getByText("test-project-b")).toBeTruthy();
const editLinks = screen.getAllByText("편집");
const editLinks = screen.getAllByText("Edit");
expect(editLinks).toHaveLength(2);
expect(editLinks[0].closest("a")?.getAttribute("href")).toBe("/editor?slug=test-project-a");
expect(editLinks[1].closest("a")?.getAttribute("href")).toBe("/editor?slug=test-project-b");
const previewLinks = screen.getAllByText("미리보기");
const previewLinks = screen.getAllByText("Preview");
expect(previewLinks).toHaveLength(2);
expect(previewLinks[0].closest("a")?.getAttribute("href")).toBe("/preview?slug=test-project-a");
expect(previewLinks[1].closest("a")?.getAttribute("href")).toBe("/preview?slug=test-project-b");
@@ -107,7 +107,7 @@ describe("ProjectsPage - 프로젝트 목록", () => {
expect(fetchMock).toHaveBeenCalledTimes(1);
});
const link = screen.getByText("전체 제출 내역");
const link = screen.getByText("All submissions");
expect(link).toBeTruthy();
expect(link.closest("a")?.getAttribute("href")).toBe("/projects/submissions");
});
@@ -139,7 +139,7 @@ describe("ProjectsPage - 프로젝트 목록", () => {
expect(fetchMock).toHaveBeenCalledTimes(1);
});
const link = screen.getByText("대시보드");
const link = screen.getByText("Dashboard");
expect(link).toBeTruthy();
expect(link.closest("a")?.getAttribute("href")).toBe("/dashboard");
});
@@ -220,31 +220,31 @@ describe("ProjectsPage - 프로젝트 목록", () => {
expect(toolbarClass).toContain("text-slate-600");
expect(toolbarClass).toContain("dark:text-slate-300");
const editLink = screen.getByText("편집").closest("a") as HTMLElement | null;
const editLink = screen.getByText("Edit").closest("a") as HTMLElement | null;
expect(editLink).not.toBeNull();
const editClass = editLink!.className;
expect(editClass).toContain("text-sky-600");
expect(editClass).toContain("dark:text-sky-300");
const previewLink = screen.getByText("미리보기").closest("a") as HTMLElement | null;
const previewLink = screen.getByText("Preview").closest("a") as HTMLElement | null;
expect(previewLink).not.toBeNull();
const previewClass = previewLink!.className;
expect(previewClass).toContain("text-slate-600");
expect(previewClass).toContain("dark:text-slate-300");
const submissionsLink = screen.getByText("폼 제출 내역").closest("a") as HTMLElement | null;
const submissionsLink = screen.getByText("Form submissions").closest("a") as HTMLElement | null;
expect(submissionsLink).not.toBeNull();
const submissionsClass = submissionsLink!.className;
expect(submissionsClass).toContain("text-emerald-600");
expect(submissionsClass).toContain("dark:text-emerald-300");
const deleteButton = screen.getByText("삭제").closest("button") as HTMLElement | null;
const deleteButton = screen.getByText("Delete").closest("button") as HTMLElement | null;
expect(deleteButton).not.toBeNull();
const deleteClass = deleteButton!.className;
expect(deleteClass).toContain("text-red-600");
expect(deleteClass).toContain("dark:text-red-300");
const prevButton = screen.getByRole("button", { name: "이전" });
const prevButton = screen.getByRole("button", { name: "Previous" });
const prevClass = prevButton.className;
expect(prevClass).toContain("border-slate-300");
expect(prevClass).toContain("text-slate-700");
@@ -279,9 +279,9 @@ describe("ProjectsPage - 프로젝트 목록", () => {
expect(fetchMock).toHaveBeenCalledTimes(1);
});
const dashboardLink = await screen.findByRole("link", { name: "대시보드" });
const projectsLink = await screen.findByRole("link", { name: "프로젝트 목록" });
const submissionsLink = await screen.findByRole("link", { name: "전체 제출 내역" });
const dashboardLink = await screen.findByRole("link", { name: "Dashboard" });
const projectsLink = await screen.findByRole("link", { name: "Projects" });
const submissionsLink = await screen.findByRole("link", { name: "All submissions" });
expect(projectsLink.closest("a")?.getAttribute("href")).toBe("/projects");
expect(projectsLink.closest("a")?.getAttribute("aria-current")).toBe("page");
@@ -320,7 +320,7 @@ describe("ProjectsPage - 프로젝트 목록", () => {
const html = document.documentElement;
html.classList.remove("dark");
const themeToggleButton = await screen.findByRole("button", { name: "테마 전환" });
const themeToggleButton = await screen.findByRole("button", { name: "Toggle theme" });
expect(html.classList.contains("dark")).toBe(false);
@@ -367,7 +367,7 @@ describe("ProjectsPage - 프로젝트 목록", () => {
expect(await screen.findByText("테스트 프로젝트 A")).toBeTruthy();
expect(await screen.findByText("테스트 프로젝트 B")).toBeTruthy();
const submissionLinks = screen.getAllByText("폼 제출 내역");
const submissionLinks = screen.getAllByText("Form submissions");
expect(submissionLinks).toHaveLength(2);
expect(submissionLinks[0].closest("a")?.getAttribute("href")).toBe(
"/projects/test-project-a/submissions",
@@ -428,7 +428,7 @@ describe("ProjectsPage - 프로젝트 목록", () => {
expect(await screen.findByText("테스트 프로젝트 A")).toBeTruthy();
const deleteButtons = screen.getAllByText("삭제");
const deleteButtons = screen.getAllByText("Delete");
expect(deleteButtons.length).toBeGreaterThan(0);
deleteButtons[0].click();
@@ -482,8 +482,8 @@ describe("ProjectsPage - 프로젝트 목록", () => {
// 상단 툴바에서 선택 삭제와 새 프로젝트 만들기 버튼이 함께 보여야 한다.
const toolbar = await screen.findByTestId("projects-toolbar");
expect(toolbar.querySelector("button[type='button']")?.textContent).toContain("선택 삭제");
expect(toolbar.querySelector("a[href='/editor?new=1']")?.textContent).toContain("새 프로젝트 만들기");
expect(toolbar.querySelector("button[type='button']")?.textContent).toContain("Delete selected");
expect(toolbar.querySelector("a[href='/editor?new=1']")?.textContent).toContain("Create new project");
});
it("체크박스로 여러 프로젝트를 선택한 뒤 '선택 삭제' 버튼으로 일괄 삭제할 수 있어야 한다", async () => {
@@ -544,13 +544,20 @@ describe("ProjectsPage - 프로젝트 목록", () => {
expect(await screen.findByText("테스트 프로젝트 A")).toBeTruthy();
expect(await screen.findByText("테스트 프로젝트 B")).toBeTruthy();
const checkboxA = screen.getByLabelText("테스트 프로젝트 A 선택");
const checkboxB = screen.getByLabelText("테스트 프로젝트 B 선택");
const rowA = (await screen.findByText("테스트 프로젝트 A")).closest("tr") as HTMLElement | null;
const rowB = (await screen.findByText("테스트 프로젝트 B")).closest("tr") as HTMLElement | null;
expect(rowA).not.toBeNull();
expect(rowB).not.toBeNull();
fireEvent.click(checkboxA);
fireEvent.click(checkboxB);
const checkboxA = rowA!.querySelector("input[type='checkbox']") as HTMLInputElement | null;
const checkboxB = rowB!.querySelector("input[type='checkbox']") as HTMLInputElement | null;
expect(checkboxA).not.toBeNull();
expect(checkboxB).not.toBeNull();
const bulkDeleteButton = screen.getByText("선택 삭제");
fireEvent.click(checkboxA!);
fireEvent.click(checkboxB!);
const bulkDeleteButton = screen.getByText("Delete selected");
fireEvent.click(bulkDeleteButton);
await waitFor(() => {
@@ -635,10 +642,10 @@ describe("ProjectsPage - 프로젝트 목록", () => {
expect(fetchMock).toHaveBeenCalled();
});
const menuButton = await screen.findByRole("button", { name: "메뉴" });
const menuButton = await screen.findByRole("button", { name: "Menu" });
fireEvent.click(menuButton);
const logoutButton = await screen.findByRole("button", { name: "로그아웃" });
const logoutButton = await screen.findByRole("button", { name: "Log out" });
fireEvent.click(logoutButton);
await waitFor(() => {
@@ -669,7 +676,7 @@ describe("ProjectsPage - 프로젝트 목록", () => {
expect(
await screen.findByText(
"프로젝트 목록을 불러오는 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.",
"An error occurred while loading your projects. Please try again later.",
),
).toBeTruthy();
});
@@ -714,11 +721,13 @@ describe("ProjectsPage - 프로젝트 목록", () => {
expect(await screen.findByText("삭제 실패 프로젝트")).toBeTruthy();
const deleteButton = screen.getByText("삭제");
const deleteButton = screen.getByText("Delete");
fireEvent.click(deleteButton);
expect(
await screen.findByText("프로젝트 삭제 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요."),
await screen.findByText(
"An error occurred while deleting the project. Please try again later.",
),
).toBeTruthy();
// 삭제 실패이므로 여전히 목록에 남아 있어야 한다.
@@ -780,18 +789,25 @@ describe("ProjectsPage - 프로젝트 목록", () => {
expect(await screen.findByText("성공 프로젝트")).toBeTruthy();
expect(await screen.findByText("실패 프로젝트")).toBeTruthy();
const checkboxSuccess = screen.getByLabelText("성공 프로젝트 선택");
const checkboxFail = screen.getByLabelText("실패 프로젝트 선택");
const successRow = (await screen.findByText("성공 프로젝트")).closest("tr") as HTMLElement | null;
const failRow = (await screen.findByText("실패 프로젝트")).closest("tr") as HTMLElement | null;
expect(successRow).not.toBeNull();
expect(failRow).not.toBeNull();
fireEvent.click(checkboxSuccess);
fireEvent.click(checkboxFail);
const checkboxSuccess = successRow!.querySelector("input[type='checkbox']") as HTMLInputElement | null;
const checkboxFail = failRow!.querySelector("input[type='checkbox']") as HTMLInputElement | null;
expect(checkboxSuccess).not.toBeNull();
expect(checkboxFail).not.toBeNull();
const bulkDeleteButton = screen.getByText("선택 삭제");
fireEvent.click(checkboxSuccess!);
fireEvent.click(checkboxFail!);
const bulkDeleteButton = screen.getByText("Delete selected");
fireEvent.click(bulkDeleteButton);
expect(
await screen.findByText(
"일부 프로젝트 삭제에 실패했습니다. 페이지를 새로고침한 뒤 목록을 확인해 주세요.",
"Failed to delete some projects. Please refresh and review the list.",
),
).toBeTruthy();
+7 -7
View File
@@ -52,8 +52,8 @@ describe("PropertiesSidebar HELP 튜토리얼 모달", () => {
const helpButton = screen.getByRole("button", { name: "HELP" });
fireEvent.click(helpButton);
expect(screen.getByText("텍스트 블록 튜토리얼")).toBeDefined();
expect(screen.getByText(/제목과 본문 텍스트를 입력할 때 사용하는 기본 블록입니다/)).toBeDefined();
expect(screen.getByText("Text block tutorial")).toBeDefined();
expect(screen.getByText(/basic block for headings and body text/i)).toBeDefined();
});
it("HELP 모달은 라이트/다크 테마에 맞는 카드 배경/테두리/텍스트 클래스를 사용해야 한다", () => {
@@ -65,7 +65,7 @@ describe("PropertiesSidebar HELP 튜토리얼 모달", () => {
fireEvent.click(helpButton);
// 모달 카드 컨테이너 (제목 h3 → 헤더 div → 카드 div 순으로 감싸져 있으므로 parentElement.parentElement 사용)
const heading = screen.getByText("텍스트 블록 튜토리얼");
const heading = screen.getByText("Text block tutorial");
const modalCard = heading.parentElement?.parentElement as HTMLElement | null;
expect(modalCard).not.toBeNull();
const className = modalCard!.getAttribute("class") ?? "";
@@ -88,7 +88,7 @@ describe("PropertiesSidebar HELP 튜토리얼 모달", () => {
const helpButton = screen.getByRole("button", { name: "HELP" });
fireEvent.click(helpButton);
const closeButton = screen.getByRole("button", { name: "닫기" });
const closeButton = screen.getByRole("button", { name: "Close" });
fireEvent.click(closeButton);
expect(screen.queryByText("텍스트 블록 튜토리얼")).toBeNull();
@@ -99,8 +99,8 @@ describe("PropertiesSidebar HELP 튜토리얼 모달", () => {
render(<PropertiesSidebar {...defaultProps([textBlock], textBlock.id)} />);
const deleteButton = screen.getByRole("button", { name: "블록 삭제" });
const duplicateButton = screen.getByRole("button", { name: "블록 복제" });
const deleteButton = screen.getByRole("button", { name: "Delete block" });
const duplicateButton = screen.getByRole("button", { name: "Duplicate block" });
const helpButton = screen.getByRole("button", { name: "HELP" });
for (const btn of [deleteButton, duplicateButton, helpButton]) {
@@ -119,7 +119,7 @@ describe("PropertiesSidebar HELP 튜토리얼 모달", () => {
render(<PropertiesSidebar {...defaultProps([textBlock], textBlock.id)} />);
const textarea = screen.getByLabelText("선택한 텍스트 블록 내용") as HTMLTextAreaElement;
const textarea = screen.getByLabelText("Selected text block content") as HTMLTextAreaElement;
const className = textarea.getAttribute("class") ?? "";
// 라이트 모드: 흰 배경 + 어두운 텍스트
+8 -6
View File
@@ -3,6 +3,8 @@ import { render, screen, cleanup } from "@testing-library/react";
import { PublicPageRenderer } from "@/features/editor/components/PublicPageRenderer";
import { createFooterTemplateBlocks } from "@/app/editor/templates/footerTemplate";
import type { Block } from "@/features/editor/state/editorStore";
import { getEditorTemplatesMessages } from "@/features/i18n/messages/editorTemplates";
import { DEFAULT_LOCALE } from "@/features/i18n/locale";
// PublicPageRenderer 푸터 템플릿 TDD
// - footerTemplate 로 생성한 섹션이 프리뷰에서 섹션/텍스트 구조로 올바르게 렌더되는지 검증한다.
@@ -16,8 +18,8 @@ describe("PublicPageRenderer - 푸터 템플릿", () => {
const sectionId = "footer_preview_section";
let i = 0;
const createId = () => `footer_${++i}`;
const { blocks } = createFooterTemplateBlocks({ sectionId, createId });
const tpl = getEditorTemplatesMessages(DEFAULT_LOCALE);
const { blocks } = createFooterTemplateBlocks({ sectionId, createId, messages: tpl.footer });
render(<PublicPageRenderer blocks={blocks as Block[]} />);
@@ -25,9 +27,9 @@ describe("PublicPageRenderer - 푸터 템플릿", () => {
expect(section.getAttribute("data-section-id")).toBe(sectionId);
expect(screen.getByText("MyLanding")).toBeTruthy();
expect(screen.getByText("더 나은 웹사이트를 위한 최고의 선택.")).toBeTruthy();
expect(screen.getByText(/서비스 소개/)).toBeTruthy();
expect(screen.getByText(/문의하기/)).toBeTruthy();
expect(screen.getByText(/© 2025 MyLanding\./)).toBeTruthy();
expect(screen.getByText("The best choice for building better websites.")).toBeTruthy();
expect(screen.getByText(/Product/)).toBeTruthy();
expect(screen.getByText(/Contact/)).toBeTruthy();
expect(screen.getByText(/ 2025 MyLanding\./)).toBeTruthy();
});
});
@@ -23,8 +23,8 @@ describe("PublicPageRenderer - 폼 제출 메시지", () => {
props: {
kind: "contact",
submitTarget: "internal",
successMessage: "폼 성공 메시지 (config)",
errorMessage: "폼 에러 메시지 (config)",
successMessage: "Form success message (config)",
errorMessage: "Form error message (config)",
fieldIds: ["field_name"],
requiredFieldIds: ["field_name"],
submitButtonId: "submit_btn",
@@ -34,7 +34,7 @@ describe("PublicPageRenderer - 폼 제출 메시지", () => {
id: "field_name",
type: "formInput",
props: {
label: "이름",
label: "Name",
formFieldName: "name",
required: true,
} as any,
@@ -43,7 +43,7 @@ describe("PublicPageRenderer - 폼 제출 메시지", () => {
id: "submit_btn",
type: "button",
props: {
label: "제출하기",
label: "Submit",
href: "#",
} as any,
},
@@ -60,10 +60,10 @@ describe("PublicPageRenderer - 폼 제출 메시지", () => {
render(<PublicPageRenderer blocks={blocks} />);
const nameInput = screen.getByLabelText("이름") as HTMLInputElement;
const nameInput = screen.getByLabelText("Name") as HTMLInputElement;
fireEvent.change(nameInput, { target: { value: "홍길동" } });
const submitButton = screen.getByRole("link", { name: "제출하기" });
const submitButton = screen.getByRole("link", { name: "Submit" });
expect(submitButton).toBeTruthy();
fireEvent.click(submitButton);
@@ -73,7 +73,7 @@ describe("PublicPageRenderer - 폼 제출 메시지", () => {
});
await waitFor(() => {
const msg = screen.getByText("폼 성공 메시지 (config)");
const msg = screen.getByText("Form success message (config)");
expect(msg).toBeTruthy();
});
});
@@ -86,8 +86,8 @@ describe("PublicPageRenderer - 폼 제출 메시지", () => {
props: {
kind: "contact",
submitTarget: "internal",
successMessage: "폼 성공 메시지 (config)",
errorMessage: "폼 에러 메시지 (config)",
successMessage: "Form success message (config)",
errorMessage: "Form error message (config)",
fieldIds: ["field_name"],
requiredFieldIds: ["field_name"],
submitButtonId: "submit_btn",
@@ -97,7 +97,7 @@ describe("PublicPageRenderer - 폼 제출 메시지", () => {
id: "field_name",
type: "formInput",
props: {
label: "이름",
label: "Name",
formFieldName: "name",
required: true,
} as any,
@@ -106,7 +106,7 @@ describe("PublicPageRenderer - 폼 제출 메시지", () => {
id: "submit_btn",
type: "button",
props: {
label: "제출하기",
label: "Submit",
href: "#",
} as any,
},
@@ -123,10 +123,10 @@ describe("PublicPageRenderer - 폼 제출 메시지", () => {
render(<PublicPageRenderer blocks={blocks} />);
const nameInput = screen.getByLabelText("이름") as HTMLInputElement;
const nameInput = screen.getByLabelText("Name") as HTMLInputElement;
fireEvent.change(nameInput, { target: { value: "홍길동" } });
const submitButton = screen.getByRole("link", { name: "제출하기" });
const submitButton = screen.getByRole("link", { name: "Submit" });
expect(submitButton).toBeTruthy();
fireEvent.click(submitButton);
@@ -136,7 +136,7 @@ describe("PublicPageRenderer - 폼 제출 메시지", () => {
});
await waitFor(() => {
const errorMsg = screen.getByText("폼 에러 메시지 (config)");
const errorMsg = screen.getByText("Form error message (config)");
expect(errorMsg).toBeTruthy();
});
});
@@ -149,7 +149,7 @@ describe("PublicPageRenderer - 폼 제출 메시지", () => {
props: {
kind: "contact",
submitTarget: "internal",
successMessage: "폼 성공 메시지 (config)",
successMessage: "Form success message (config)",
fieldIds: ["field_email"],
requiredFieldIds: ["field_email"],
submitButtonId: "submit_btn",
@@ -159,7 +159,7 @@ describe("PublicPageRenderer - 폼 제출 메시지", () => {
id: "field_email",
type: "formInput",
props: {
label: "이메일",
label: "Email",
formFieldName: "email",
required: true,
} as any,
@@ -168,7 +168,7 @@ describe("PublicPageRenderer - 폼 제출 메시지", () => {
id: "submit_btn",
type: "button",
props: {
label: "제출하기",
label: "Submit",
href: "#",
} as any,
},
@@ -185,7 +185,7 @@ describe("PublicPageRenderer - 폼 제출 메시지", () => {
render(<PublicPageRenderer blocks={blocks} />);
const submitButton = screen.getByRole("link", { name: "제출하기" });
const submitButton = screen.getByRole("link", { name: "Submit" });
expect(submitButton).toBeTruthy();
fireEvent.click(submitButton);
@@ -195,7 +195,7 @@ describe("PublicPageRenderer - 폼 제출 메시지", () => {
});
await waitFor(() => {
const errorMsg = screen.getByText(/다음 필수 항목을 입력해 주세요:\s*-\s*이메일/);
const errorMsg = screen.getByText(/Please fill in the following required fields:\s*-\s*Email/);
expect(errorMsg).toBeTruthy();
});
});
@@ -208,8 +208,8 @@ describe("PublicPageRenderer - 폼 제출 메시지", () => {
props: {
kind: "contact",
submitTarget: "internal",
successMessage: "폼 성공 메시지 (config)",
errorMessage: "폼 에러 메시지 (config)",
successMessage: "Form success message (config)",
errorMessage: "Form error message (config)",
fieldIds: ["field_name"],
requiredFieldIds: ["field_name"],
submitButtonId: "submit_btn",
@@ -219,7 +219,7 @@ describe("PublicPageRenderer - 폼 제출 메시지", () => {
id: "field_name",
type: "formInput",
props: {
label: "이름",
label: "Name",
formFieldName: "name",
required: true,
} as any,
@@ -228,7 +228,7 @@ describe("PublicPageRenderer - 폼 제출 메시지", () => {
id: "submit_btn",
type: "button",
props: {
label: "제출하기",
label: "Submit",
href: "#",
} as any,
},
@@ -245,7 +245,7 @@ describe("PublicPageRenderer - 폼 제출 메시지", () => {
render(<PublicPageRenderer blocks={blocks} />);
const submitButton = screen.getByRole("link", { name: "제출하기" });
const submitButton = screen.getByRole("link", { name: "Submit" });
expect(submitButton).toBeTruthy();
// 이름 필드를 비운 채로 제출한다.
@@ -256,7 +256,7 @@ describe("PublicPageRenderer - 폼 제출 메시지", () => {
});
await waitFor(() => {
const errorMsg = screen.getByText(/다음 필수 항목을 입력해 주세요:\s*-\s*이름/);
const errorMsg = screen.getByText(/Please fill in the following required fields:\s*-\s*Name/);
expect(errorMsg).toBeTruthy();
});
});
+29 -12
View File
@@ -4,6 +4,8 @@ import { PublicPageRenderer } from "@/features/editor/components/PublicPageRende
import { createHeroTemplateBlocks } from "@/app/editor/templates/heroTemplate";
import { createFeaturesTemplateBlocks } from "@/app/editor/templates/featuresTemplate";
import { createCtaTemplateBlocks } from "@/app/editor/templates/ctaTemplate";
import { getEditorTemplatesMessages } from "@/features/i18n/messages/editorTemplates";
import { DEFAULT_LOCALE } from "@/features/i18n/locale";
import type { Block } from "@/features/editor/state/editorStore";
// PublicPageRenderer 템플릿 TDD
@@ -21,29 +23,39 @@ describe("PublicPageRenderer - 섹션 템플릿", () => {
it("Hero 템플릿 섹션은 섹션과 헤드라인/서브텍스트/버튼을 렌더해야 한다", () => {
const sectionId = "hero_section_1";
const { blocks } = createHeroTemplateBlocks({ sectionId, createId: createIdFactory() });
const tpl = getEditorTemplatesMessages(DEFAULT_LOCALE);
const { blocks } = createHeroTemplateBlocks({
sectionId,
createId: createIdFactory(),
messages: tpl.hero,
});
render(<PublicPageRenderer blocks={blocks as Block[]} />);
// 헤드라인/서브텍스트 텍스트가 존재해야 한다.
screen.getByText("Hero 제목을 여기에 입력하세요");
screen.getByText("제품이나 서비스를 한 문장으로 설명하는 서브텍스트입니다.");
screen.getByText("Your hero headline goes here");
screen.getByText("Describe your product or service in a single, clear sentence.");
// CTA 버튼 라벨 텍스트가 존재해야 한다.
screen.getByText("지금 시작하기");
screen.getByText("Get started now");
});
it("Features 템플릿 섹션은 3컬럼 Feature 제목/설명 텍스트를 렌더해야 한다", () => {
const sectionId = "features_section_1";
const { blocks } = createFeaturesTemplateBlocks({ sectionId, createId: createIdFactory() });
const tpl = getEditorTemplatesMessages(DEFAULT_LOCALE);
const { blocks } = createFeaturesTemplateBlocks({
sectionId,
createId: createIdFactory(),
messages: tpl.features,
});
render(<PublicPageRenderer blocks={blocks as Block[]} />);
// 각 컬럼의 Feature 제목과 설명 텍스트가 모두 존재해야 한다.
screen.getByText("Feature 1 제목");
screen.getByText("Feature 2 제목");
screen.getByText("Feature 3 제목");
screen.getByText("Feature 1");
screen.getByText("Feature 2");
screen.getByText("Feature 3");
const descText = "해당 기능을 간단히 설명하는 텍스트입니다.";
const descText = "A short description of this feature.";
// 설명 텍스트는 3번 등장해야 한다.
const allDesc = screen.getAllByText(descText);
if (allDesc.length !== 3) {
@@ -53,14 +65,19 @@ describe("PublicPageRenderer - 섹션 템플릿", () => {
it("CTA 템플릿 섹션은 텍스트와 CTA 버튼을 렌더해야 한다", () => {
const sectionId = "cta_section_1";
const { blocks } = createCtaTemplateBlocks({ sectionId, createId: createIdFactory() });
const tpl = getEditorTemplatesMessages(DEFAULT_LOCALE);
const { blocks } = createCtaTemplateBlocks({
sectionId,
createId: createIdFactory(),
messages: tpl.cta,
});
render(<PublicPageRenderer blocks={blocks as Block[]} />);
// CTA 본문 텍스트와 버튼 라벨이 존재해야 한다.
screen.getByText((content) =>
content.includes("지금 바로 행동을 유도하는 CTA 텍스트를 입력하세요."),
content.includes("Write a compelling CTA message here."),
);
screen.getByText("CTA 버튼");
screen.getByText("Call to action");
});
});
+1 -1
View File
@@ -24,7 +24,7 @@ describe("SectionPropertiesPanel - layout presets", () => {
/>,
);
const select = screen.getByLabelText("섹션 컬럼 레이아웃");
const select = screen.getByLabelText("Section column layout");
fireEvent.change(select, { target: { value: "two-equal" } });
+26 -26
View File
@@ -31,7 +31,7 @@ describe("SectionPropertiesPanel", () => {
/>,
);
const hexInput = screen.getByLabelText("섹션 배경 색상 HEX 입력");
const hexInput = screen.getByLabelText("Section background color HEX");
fireEvent.change(hexInput, { target: { value: "#112233" } });
expect(updateBlock).toHaveBeenCalledWith(
@@ -57,7 +57,7 @@ describe("SectionPropertiesPanel", () => {
/>,
);
const presetSelect = screen.getByLabelText("배경 이미지 가로 위치 프리셋");
const presetSelect = screen.getByLabelText("Background image horizontal position 프리셋");
fireEvent.change(presetSelect, { target: { value: "left" } });
expect(updateBlock).toHaveBeenCalledWith(
@@ -83,7 +83,7 @@ describe("SectionPropertiesPanel", () => {
/>,
);
const presetSelect = screen.getByLabelText("배경 이미지 세로 위치 프리셋");
const presetSelect = screen.getByLabelText("Background image vertical position 프리셋");
fireEvent.change(presetSelect, { target: { value: "top" } });
expect(updateBlock).toHaveBeenCalledWith(
@@ -110,7 +110,7 @@ describe("SectionPropertiesPanel", () => {
/>,
);
const xSlider = screen.getByLabelText("배경 이미지 가로 위치 슬라이더");
const xSlider = screen.getByLabelText("Background image horizontal position 슬라이더");
fireEvent.change(xSlider, { target: { value: "30" } });
expect(updateBlock).toHaveBeenCalledWith(
@@ -121,7 +121,7 @@ describe("SectionPropertiesPanel", () => {
}),
);
const ySlider = screen.getByLabelText("배경 이미지 세로 위치 슬라이더");
const ySlider = screen.getByLabelText("Background image vertical position 슬라이더");
fireEvent.change(ySlider, { target: { value: "70" } });
expect(updateBlock).toHaveBeenCalledWith(
@@ -143,7 +143,7 @@ describe("SectionPropertiesPanel", () => {
/>,
);
const slider = screen.getByLabelText("세로 패딩 슬라이더");
const slider = screen.getByLabelText("Vertical padding 슬라이더");
fireEvent.change(slider, { target: { value: "40" } });
expect(updateBlock).toHaveBeenCalledWith("section-1", expect.objectContaining({ paddingYPx: 40 }));
@@ -160,7 +160,7 @@ describe("SectionPropertiesPanel", () => {
/>,
);
const urlInput = screen.getByLabelText("배경 이미지 URL");
const urlInput = screen.getByLabelText("Background image URL");
fireEvent.change(urlInput, { target: { value: "https://example.com/bg.png" } });
expect(updateBlock).toHaveBeenCalledWith(
@@ -189,7 +189,7 @@ describe("SectionPropertiesPanel", () => {
/>,
);
const select = screen.getByLabelText("배경 이미지 크기");
const select = screen.getByLabelText("Background image size");
fireEvent.change(select, { target: { value: "contain" } });
expect(updateBlock).toHaveBeenCalledWith(
@@ -214,7 +214,7 @@ describe("SectionPropertiesPanel", () => {
/>,
);
const select = screen.getByLabelText("배경 이미지 위치");
const select = screen.getByLabelText("Background image position");
fireEvent.change(select, { target: { value: "top" } });
expect(updateBlock).toHaveBeenCalledWith(
@@ -234,7 +234,7 @@ describe("SectionPropertiesPanel", () => {
/>,
);
const sourceSelect = screen.getByLabelText("배경 이미지 소스") as HTMLSelectElement;
const sourceSelect = screen.getByLabelText("Background image source") as HTMLSelectElement;
expect(sourceSelect.value).toBe("none");
fireEvent.change(sourceSelect, { target: { value: "none" } });
@@ -246,9 +246,9 @@ describe("SectionPropertiesPanel", () => {
} as any);
// "없음" 상태에서는 크기/위치/반복 컨트롤이 렌더되지 않아야 한다.
expect(screen.queryByLabelText("배경 이미지 크기")).toBeNull();
expect(screen.queryByLabelText("배경 이미지 위치")).toBeNull();
expect(screen.queryByLabelText("배경 이미지 반복")).toBeNull();
expect(screen.queryByLabelText("Background image size")).toBeNull();
expect(screen.queryByLabelText("Background image position")).toBeNull();
expect(screen.queryByLabelText("Background image repeat")).toBeNull();
});
it("배경 이미지 반복 셀렉트 변경 시 updateBlock 이 backgroundImageRepeat 로 호출된다", () => {
@@ -267,7 +267,7 @@ describe("SectionPropertiesPanel", () => {
/>,
);
const repeatSelect = screen.getByLabelText("배경 이미지 반복");
const repeatSelect = screen.getByLabelText("Background image repeat");
fireEvent.change(repeatSelect, { target: { value: "repeat-x" } });
expect(updateBlock).toHaveBeenCalledWith(
@@ -292,7 +292,7 @@ describe("SectionPropertiesPanel", () => {
/>,
);
const sourceSelect = screen.getByLabelText("배경 이미지 소스") as HTMLSelectElement;
const sourceSelect = screen.getByLabelText("Background image source") as HTMLSelectElement;
expect(sourceSelect.value).toBe("upload");
fireEvent.change(sourceSelect, { target: { value: "url" } });
@@ -321,7 +321,7 @@ describe("SectionPropertiesPanel", () => {
/>,
);
const urlInput = screen.getByLabelText("배경 비디오 URL");
const urlInput = screen.getByLabelText("Background video URL");
fireEvent.change(urlInput, { target: { value: "https://example.com/bg-video.mp4" } });
expect(updateBlock).toHaveBeenCalledWith(
@@ -350,7 +350,7 @@ describe("SectionPropertiesPanel", () => {
/>,
);
const sourceSelect = screen.getByLabelText("배경 비디오 소스") as HTMLSelectElement;
const sourceSelect = screen.getByLabelText("Background video source") as HTMLSelectElement;
expect(sourceSelect.value).toBe("upload");
fireEvent.change(sourceSelect, { target: { value: "url" } });
@@ -381,7 +381,7 @@ describe("SectionPropertiesPanel", () => {
/>,
);
const bgImageSourceSelect = screen.getByLabelText("배경 이미지 소스") as HTMLSelectElement;
const bgImageSourceSelect = screen.getByLabelText("Background image source") as HTMLSelectElement;
const bgImageSourceClass = bgImageSourceSelect.getAttribute("class") ?? "";
expect(bgImageSourceClass).toContain("bg-white");
expect(bgImageSourceClass).toContain("text-slate-900");
@@ -390,7 +390,7 @@ describe("SectionPropertiesPanel", () => {
expect(bgImageSourceClass).toContain("dark:text-slate-100");
expect(bgImageSourceClass).toContain("dark:border-slate-700");
const bgImageUrlInput = screen.getByLabelText("배경 이미지 URL") as HTMLInputElement;
const bgImageUrlInput = screen.getByLabelText("Background image URL") as HTMLInputElement;
const bgImageUrlClass = bgImageUrlInput.getAttribute("class") ?? "";
expect(bgImageUrlClass).toContain("bg-white");
expect(bgImageUrlClass).toContain("text-slate-900");
@@ -399,7 +399,7 @@ describe("SectionPropertiesPanel", () => {
expect(bgImageUrlClass).toContain("dark:text-slate-100");
expect(bgImageUrlClass).toContain("dark:border-slate-700");
const bgVideoUrlInput = screen.getByLabelText("배경 비디오 URL") as HTMLInputElement;
const bgVideoUrlInput = screen.getByLabelText("Background video URL") as HTMLInputElement;
const bgVideoUrlClass = bgVideoUrlInput.getAttribute("class") ?? "";
expect(bgVideoUrlClass).toContain("bg-white");
expect(bgVideoUrlClass).toContain("text-slate-900");
@@ -433,12 +433,12 @@ describe("SectionPropertiesPanel", () => {
/>,
);
const columnLayoutSelect = screen.getByLabelText("섹션 컬럼 레이아웃") as HTMLSelectElement;
const alignItemsSelect = screen.getByLabelText("섹션 컬럼 세로 정렬") as HTMLSelectElement;
const positionModeSelect = screen.getByLabelText("배경 이미지 위치 모드") as HTMLSelectElement;
const sizeSelect = screen.getByLabelText("배경 이미지 크기") as HTMLSelectElement;
const positionSelect = screen.getByLabelText("배경 이미지 위치") as HTMLSelectElement;
const repeatSelect = screen.getByLabelText("배경 이미지 반복") as HTMLSelectElement;
const columnLayoutSelect = screen.getByLabelText("Section column layout") as HTMLSelectElement;
const alignItemsSelect = screen.getByLabelText("Section column vertical alignment") as HTMLSelectElement;
const positionModeSelect = screen.getByLabelText("Background image position mode") as HTMLSelectElement;
const sizeSelect = screen.getByLabelText("Background image size") as HTMLSelectElement;
const positionSelect = screen.getByLabelText("Background image position") as HTMLSelectElement;
const repeatSelect = screen.getByLabelText("Background image repeat") as HTMLSelectElement;
const assertDualTheme = (el: HTMLElement) => {
const className = el.getAttribute("class") ?? "";

Some files were not shown because too many files have changed in this diff Show More